diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index b3092718d6c..aa0b6a2f64b 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -93,6 +93,9 @@ bundle_config.pb.json @BenHenning LICENSE @BenHenning NOTICE @BenHenning +# Language configuration files. +config/**/languages/*.textproto @BenHenning + ##################################################################################### # app module # ##################################################################################### @@ -143,6 +146,9 @@ NOTICE @BenHenning # Global domain module code ownership. /domain/**/*.kt @BenHenning +# Domain test resources. +/domain/src/test/res/values/strings.xml @BenHenning + # Questions support. /domain/src/*/java/org/oppia/android/domain/question/ @vinitamurthi diff --git a/app/BUILD.bazel b/app/BUILD.bazel index 0589cd75b45..e3c4910f7ed 100644 --- a/app/BUILD.bazel +++ b/app/BUILD.bazel @@ -577,7 +577,7 @@ kt_android_library( "//third_party:androidx_databinding_databinding-runtime", "//third_party:circularimageview_circular_image_view", "//utility/src/main/java/org/oppia/android/util/accessibility", - "//utility/src/main/java/org/oppia/android/util/caching:caching_module", + "//utility/src/main/java/org/oppia/android/util/caching:caching_prod_module", "//utility/src/main/java/org/oppia/android/util/logging:prod_module", "//utility/src/main/java/org/oppia/android/util/logging/firebase:prod_module", ], @@ -690,6 +690,7 @@ kt_android_library( "//third_party:javax_annotation_javax_annotation-api_jar", "//utility", "//utility/src/main/java/org/oppia/android/util/accessibility:prod_module", + "//utility/src/main/java/org/oppia/android/util/caching:asset_prod_module", "//utility/src/main/java/org/oppia/android/util/extensions:bundle_extensions", # TODO(#2432): Replace debug_module with prod_module when building the app in prod mode. "//utility/src/main/java/org/oppia/android/util/networking:debug_module", @@ -773,6 +774,7 @@ TEST_DEPS = [ "//utility", "//utility/src/main/java/org/oppia/android/util/accessibility", "//utility/src/main/java/org/oppia/android/util/accessibility:test_module", + "//utility/src/main/java/org/oppia/android/util/caching:asset_prod_module", "//utility/src/main/java/org/oppia/android/util/caching/testing:caching_test_module", ] diff --git a/app/build.gradle b/app/build.gradle index daa5e412ae5..048c6160c0e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -120,6 +120,7 @@ dependencies { 'com.google.firebase:firebase-core:17.5.0', 'com.google.firebase:firebase-crashlytics:17.0.0', 'com.google.guava:guava:28.1-android', + 'com.google.protobuf:protobuf-javalite:3.17.3', 'com.github.oppia:CircularImageview:35d08ba88a', 'de.hdodenhof:circleimageview:3.0.1', 'nl.dionsegijn:konfetti:1.2.5', diff --git a/app/src/main/java/org/oppia/android/app/application/ApplicationComponent.kt b/app/src/main/java/org/oppia/android/app/application/ApplicationComponent.kt index dfc1c2cabba..93c8f28f2fa 100644 --- a/app/src/main/java/org/oppia/android/app/application/ApplicationComponent.kt +++ b/app/src/main/java/org/oppia/android/app/application/ApplicationComponent.kt @@ -37,6 +37,7 @@ import org.oppia.android.domain.question.QuestionModule import org.oppia.android.domain.topic.PrimeTopicAssetsControllerModule import org.oppia.android.domain.workmanager.WorkManagerConfigurationModule import org.oppia.android.util.accessibility.AccessibilityProdModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.CachingModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -87,7 +88,7 @@ import javax.inject.Singleton PlatformParameterModule::class, ExplorationStorageModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, PlatformParameterSyncUpWorkerModule::class, NetworkConnectionUtilDebugModule::class, - NetworkConfigProdModule::class, + NetworkConfigProdModule::class, AssetModule::class, // TODO(#59): Remove this module once we completely migrate to Bazel from Gradle as we can then // directly exclude debug files from the build and thus won't be requiring this module. NetworkConnectionDebugUtilModule::class diff --git a/app/src/sharedTest/java/org/oppia/android/app/administratorcontrols/AdministratorControlsActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/administratorcontrols/AdministratorControlsActivityTest.kt index 75e6862ce9d..fceda133cb3 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/administratorcontrols/AdministratorControlsActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/administratorcontrols/AdministratorControlsActivityTest.kt @@ -95,6 +95,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -710,7 +711,8 @@ class AdministratorControlsActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/administratorcontrols/AppVersionActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/administratorcontrols/AppVersionActivityTest.kt index c06a54996b2..d2586105055 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/administratorcontrols/AppVersionActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/administratorcontrols/AppVersionActivityTest.kt @@ -74,6 +74,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -273,7 +274,8 @@ class AppVersionActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/completedstorylist/CompletedStoryListActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/completedstorylist/CompletedStoryListActivityTest.kt index c86305b7537..2c17d492321 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/completedstorylist/CompletedStoryListActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/completedstorylist/CompletedStoryListActivityTest.kt @@ -79,6 +79,7 @@ import org.oppia.android.testing.threading.TestDispatcherModule import org.oppia.android.testing.time.FakeOppiaClock import org.oppia.android.testing.time.FakeOppiaClockModule import org.oppia.android.util.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -493,7 +494,8 @@ class CompletedStoryListActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/customview/LessonThumbnailImageViewTest.kt b/app/src/sharedTest/java/org/oppia/android/app/customview/LessonThumbnailImageViewTest.kt index 17326d84554..d20a1351c23 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/customview/LessonThumbnailImageViewTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/customview/LessonThumbnailImageViewTest.kt @@ -58,6 +58,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -151,7 +152,8 @@ class LessonThumbnailImageViewTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/databinding/MarginBindingAdaptersTest.kt b/app/src/sharedTest/java/org/oppia/android/app/databinding/MarginBindingAdaptersTest.kt index 214eb1e6093..174e85a9143 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/databinding/MarginBindingAdaptersTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/databinding/MarginBindingAdaptersTest.kt @@ -68,6 +68,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -289,7 +290,8 @@ class MarginBindingAdaptersTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) /** Create a TestApplicationComponent. */ diff --git a/app/src/sharedTest/java/org/oppia/android/app/databinding/StateAssemblerMarginBindingAdaptersTest.kt b/app/src/sharedTest/java/org/oppia/android/app/databinding/StateAssemblerMarginBindingAdaptersTest.kt index 3ca29443376..137c478ea85 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/databinding/StateAssemblerMarginBindingAdaptersTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/databinding/StateAssemblerMarginBindingAdaptersTest.kt @@ -70,6 +70,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -477,7 +478,8 @@ class StateAssemblerMarginBindingAdaptersTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) /** Create a TestApplicationComponent. */ diff --git a/app/src/sharedTest/java/org/oppia/android/app/databinding/StateAssemblerPaddingBindingAdaptersTest.kt b/app/src/sharedTest/java/org/oppia/android/app/databinding/StateAssemblerPaddingBindingAdaptersTest.kt index bbe14ac57dd..b9d73477a13 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/databinding/StateAssemblerPaddingBindingAdaptersTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/databinding/StateAssemblerPaddingBindingAdaptersTest.kt @@ -68,6 +68,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -475,7 +476,8 @@ class StateAssemblerPaddingBindingAdaptersTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) diff --git a/app/src/sharedTest/java/org/oppia/android/app/databinding/ViewBindingAdaptersTest.kt b/app/src/sharedTest/java/org/oppia/android/app/databinding/ViewBindingAdaptersTest.kt index 68e55b7d9da..318dccedf78 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/databinding/ViewBindingAdaptersTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/databinding/ViewBindingAdaptersTest.kt @@ -65,6 +65,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -209,7 +210,8 @@ class ViewBindingAdaptersTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) /** Create a TestApplicationComponent. */ diff --git a/app/src/sharedTest/java/org/oppia/android/app/devoptions/DeveloperOptionsActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/devoptions/DeveloperOptionsActivityTest.kt index 8e182ef7a03..e50b5e9faa6 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/devoptions/DeveloperOptionsActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/devoptions/DeveloperOptionsActivityTest.kt @@ -78,6 +78,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -276,7 +277,8 @@ class DeveloperOptionsActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/devoptions/DeveloperOptionsFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/devoptions/DeveloperOptionsFragmentTest.kt index adc149d799d..295a29be211 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/devoptions/DeveloperOptionsFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/devoptions/DeveloperOptionsFragmentTest.kt @@ -80,6 +80,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -604,7 +605,8 @@ class DeveloperOptionsFragmentTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/devoptions/MarkChaptersCompletedActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/devoptions/MarkChaptersCompletedActivityTest.kt index c3c50bc8e35..ffbc7f7bac8 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/devoptions/MarkChaptersCompletedActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/devoptions/MarkChaptersCompletedActivityTest.kt @@ -60,6 +60,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -166,7 +167,8 @@ class MarkChaptersCompletedActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/devoptions/MarkChaptersCompletedFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/devoptions/MarkChaptersCompletedFragmentTest.kt index 66bf6e135f4..4eb5895a733 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/devoptions/MarkChaptersCompletedFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/devoptions/MarkChaptersCompletedFragmentTest.kt @@ -72,6 +72,7 @@ import org.oppia.android.testing.threading.TestDispatcherModule import org.oppia.android.testing.time.FakeOppiaClock import org.oppia.android.testing.time.FakeOppiaClockModule import org.oppia.android.util.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -870,7 +871,8 @@ class MarkChaptersCompletedFragmentTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/devoptions/MarkStoriesCompletedActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/devoptions/MarkStoriesCompletedActivityTest.kt index b0c105b6452..fce67ba888f 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/devoptions/MarkStoriesCompletedActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/devoptions/MarkStoriesCompletedActivityTest.kt @@ -60,6 +60,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -166,7 +167,8 @@ class MarkStoriesCompletedActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/devoptions/MarkStoriesCompletedFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/devoptions/MarkStoriesCompletedFragmentTest.kt index b2fb88bd770..7dd9f01c1ee 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/devoptions/MarkStoriesCompletedFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/devoptions/MarkStoriesCompletedFragmentTest.kt @@ -72,6 +72,7 @@ import org.oppia.android.testing.threading.TestDispatcherModule import org.oppia.android.testing.time.FakeOppiaClock import org.oppia.android.testing.time.FakeOppiaClockModule import org.oppia.android.util.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -571,7 +572,8 @@ class MarkStoriesCompletedFragmentTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/devoptions/MarkTopicsCompletedActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/devoptions/MarkTopicsCompletedActivityTest.kt index cb4f7812c9b..4e3cc82a187 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/devoptions/MarkTopicsCompletedActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/devoptions/MarkTopicsCompletedActivityTest.kt @@ -60,6 +60,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -166,7 +167,8 @@ class MarkTopicsCompletedActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/devoptions/MarkTopicsCompletedFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/devoptions/MarkTopicsCompletedFragmentTest.kt index 9991d6a82fe..aafa86d1505 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/devoptions/MarkTopicsCompletedFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/devoptions/MarkTopicsCompletedFragmentTest.kt @@ -72,6 +72,7 @@ import org.oppia.android.testing.threading.TestDispatcherModule import org.oppia.android.testing.time.FakeOppiaClock import org.oppia.android.testing.time.FakeOppiaClockModule import org.oppia.android.util.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -541,7 +542,8 @@ class MarkTopicsCompletedFragmentTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/devoptions/ViewEventLogsActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/devoptions/ViewEventLogsActivityTest.kt index c6fc44819e4..936ced5f422 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/devoptions/ViewEventLogsActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/devoptions/ViewEventLogsActivityTest.kt @@ -60,6 +60,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -154,7 +155,8 @@ class ViewEventLogsActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/devoptions/ViewEventLogsFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/devoptions/ViewEventLogsFragmentTest.kt index 32cd2dc56bd..6dd16ffdde9 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/devoptions/ViewEventLogsFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/devoptions/ViewEventLogsFragmentTest.kt @@ -67,6 +67,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -609,7 +610,8 @@ class ViewEventLogsFragmentTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) diff --git a/app/src/sharedTest/java/org/oppia/android/app/devoptions/forcenetworktype/ForceNetworkTypeActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/devoptions/forcenetworktype/ForceNetworkTypeActivityTest.kt index 05d194a07e5..04e4425d862 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/devoptions/forcenetworktype/ForceNetworkTypeActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/devoptions/forcenetworktype/ForceNetworkTypeActivityTest.kt @@ -61,6 +61,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -158,7 +159,8 @@ class ForceNetworkTypeActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, HintsAndSolutionProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) /** [ApplicationComponent] for [ForceNetworkTypeActivityTest]. */ diff --git a/app/src/sharedTest/java/org/oppia/android/app/devoptions/forcenetworktype/ForceNetworkTypeFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/devoptions/forcenetworktype/ForceNetworkTypeFragmentTest.kt index 9de95902887..d0ec55f1357 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/devoptions/forcenetworktype/ForceNetworkTypeFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/devoptions/forcenetworktype/ForceNetworkTypeFragmentTest.kt @@ -67,6 +67,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -374,7 +375,8 @@ class ForceNetworkTypeFragmentTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, HintsAndSolutionProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) /** [ApplicationComponent] for [ForceNetworkTypeFragmentTest]. */ diff --git a/app/src/sharedTest/java/org/oppia/android/app/faq/FAQListFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/faq/FAQListFragmentTest.kt index 29e09b5ff14..482dc4adea8 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/faq/FAQListFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/faq/FAQListFragmentTest.kt @@ -72,6 +72,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -226,7 +227,8 @@ class FAQListFragmentTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/faq/FAQSingleActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/faq/FAQSingleActivityTest.kt index e8dc5ca6bc7..025739b9038 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/faq/FAQSingleActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/faq/FAQSingleActivityTest.kt @@ -66,6 +66,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.DefaultResourceBucketName import org.oppia.android.util.gcsresource.GcsResourceModule @@ -198,7 +199,8 @@ class FAQSingleActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/faq/FaqListActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/faq/FaqListActivityTest.kt index 3a524ae6aff..2ae447c843d 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/faq/FaqListActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/faq/FaqListActivityTest.kt @@ -55,6 +55,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -131,7 +132,8 @@ class FaqListActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/help/HelpActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/help/HelpActivityTest.kt index ef127cd6d9e..440437555d2 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/help/HelpActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/help/HelpActivityTest.kt @@ -54,6 +54,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -133,7 +134,8 @@ class HelpActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, NetworkModule::class, ExplorationStorageModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/help/HelpFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/help/HelpFragmentTest.kt index 4e1e30d0a31..e4cdcc5e5f5 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/help/HelpFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/help/HelpFragmentTest.kt @@ -83,6 +83,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -1171,7 +1172,8 @@ class HelpFragmentTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/home/HomeActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/home/HomeActivityTest.kt index 9eb62ea282d..e93908bf655 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/home/HomeActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/home/HomeActivityTest.kt @@ -98,6 +98,7 @@ import org.oppia.android.testing.threading.TestDispatcherModule import org.oppia.android.testing.time.FakeOppiaClock import org.oppia.android.testing.time.FakeOppiaClockModule import org.oppia.android.util.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -1467,7 +1468,8 @@ class HomeActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/home/RecentlyPlayedFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/home/RecentlyPlayedFragmentTest.kt index 20b7c233b7e..5f0463580e2 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/home/RecentlyPlayedFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/home/RecentlyPlayedFragmentTest.kt @@ -99,6 +99,7 @@ import org.oppia.android.testing.threading.TestDispatcherModule import org.oppia.android.testing.time.FakeOppiaClock import org.oppia.android.testing.time.FakeOppiaClockModule import org.oppia.android.util.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -1485,7 +1486,8 @@ class RecentlyPlayedFragmentTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/home/TopicSummaryViewModelTest.kt b/app/src/sharedTest/java/org/oppia/android/app/home/TopicSummaryViewModelTest.kt index 38c8d7576fd..74b33b75346 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/home/TopicSummaryViewModelTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/home/TopicSummaryViewModelTest.kt @@ -56,6 +56,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -343,6 +344,7 @@ class TopicSummaryViewModelTest { DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, HintsAndSolutionProdModule::class, NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class, NetworkConfigProdModule::class ] ) diff --git a/app/src/sharedTest/java/org/oppia/android/app/home/WelcomeViewModelTest.kt b/app/src/sharedTest/java/org/oppia/android/app/home/WelcomeViewModelTest.kt index 700bca38d21..7c314643011 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/home/WelcomeViewModelTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/home/WelcomeViewModelTest.kt @@ -57,6 +57,7 @@ import org.oppia.android.testing.threading.TestDispatcherModule import org.oppia.android.testing.time.FakeOppiaClock import org.oppia.android.testing.time.FakeOppiaClockModule import org.oppia.android.util.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -338,6 +339,7 @@ class WelcomeViewModelTest { DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, HintsAndSolutionProdModule::class, NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class, NetworkConfigProdModule::class ] ) diff --git a/app/src/sharedTest/java/org/oppia/android/app/home/promotedlist/PromotedStoryListViewModelTest.kt b/app/src/sharedTest/java/org/oppia/android/app/home/promotedlist/PromotedStoryListViewModelTest.kt index d8c95d63de4..b2090c6cf2e 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/home/promotedlist/PromotedStoryListViewModelTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/home/promotedlist/PromotedStoryListViewModelTest.kt @@ -57,6 +57,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -347,6 +348,7 @@ class PromotedStoryListViewModelTest { DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, HintsAndSolutionProdModule::class, NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class, NetworkConfigProdModule::class ] ) diff --git a/app/src/sharedTest/java/org/oppia/android/app/home/promotedlist/PromotedStoryViewModelTest.kt b/app/src/sharedTest/java/org/oppia/android/app/home/promotedlist/PromotedStoryViewModelTest.kt index 44d20679d40..253599fd6fc 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/home/promotedlist/PromotedStoryViewModelTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/home/promotedlist/PromotedStoryViewModelTest.kt @@ -55,6 +55,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -358,6 +359,7 @@ class PromotedStoryViewModelTest { DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, HintsAndSolutionProdModule::class, NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class, NetworkConfigProdModule::class ] ) diff --git a/app/src/sharedTest/java/org/oppia/android/app/mydownloads/MyDownloadsFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/mydownloads/MyDownloadsFragmentTest.kt index c1add50efe4..9149dfd06cb 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/mydownloads/MyDownloadsFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/mydownloads/MyDownloadsFragmentTest.kt @@ -64,6 +64,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -212,7 +213,8 @@ class MyDownloadsFragmentTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/onboarding/OnboardingActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/onboarding/OnboardingActivityTest.kt index de17ca73aaa..f330650d729 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/onboarding/OnboardingActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/onboarding/OnboardingActivityTest.kt @@ -54,6 +54,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -130,7 +131,8 @@ class OnboardingActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/onboarding/OnboardingFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/onboarding/OnboardingFragmentTest.kt index 3d7de3f3d88..64853a4919f 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/onboarding/OnboardingFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/onboarding/OnboardingFragmentTest.kt @@ -82,6 +82,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -673,7 +674,8 @@ class OnboardingFragmentTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/ongoingtopiclist/OngoingTopicListActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/ongoingtopiclist/OngoingTopicListActivityTest.kt index 663ab1c47a2..496c911f13d 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/ongoingtopiclist/OngoingTopicListActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/ongoingtopiclist/OngoingTopicListActivityTest.kt @@ -77,6 +77,7 @@ import org.oppia.android.testing.threading.TestDispatcherModule import org.oppia.android.testing.time.FakeOppiaClock import org.oppia.android.testing.time.FakeOppiaClockModule import org.oppia.android.util.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -437,7 +438,8 @@ class OngoingTopicListActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/options/AppLanguageActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/options/AppLanguageActivityTest.kt index dac16b30ccc..fc439789955 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/options/AppLanguageActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/options/AppLanguageActivityTest.kt @@ -54,6 +54,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -140,7 +141,8 @@ class AppLanguageActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/options/AppLanguageFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/options/AppLanguageFragmentTest.kt index c24add0959b..717349284b1 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/options/AppLanguageFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/options/AppLanguageFragmentTest.kt @@ -65,6 +65,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.CacheAssetsLocally import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule @@ -235,6 +236,7 @@ class AppLanguageFragmentTest { DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, HintsAndSolutionProdModule::class, NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class, NetworkConfigProdModule::class ] ) diff --git a/app/src/sharedTest/java/org/oppia/android/app/options/AudioLanguageActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/options/AudioLanguageActivityTest.kt index d87fcf61a6e..6505c0cc6e8 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/options/AudioLanguageActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/options/AudioLanguageActivityTest.kt @@ -54,6 +54,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -140,7 +141,8 @@ class AudioLanguageActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/options/AudioLanguageFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/options/AudioLanguageFragmentTest.kt index f14f82a0ee9..80a126fe483 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/options/AudioLanguageFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/options/AudioLanguageFragmentTest.kt @@ -64,6 +64,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.CacheAssetsLocally import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule @@ -228,6 +229,7 @@ class AudioLanguageFragmentTest { DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, HintsAndSolutionProdModule::class, NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class, NetworkConfigProdModule::class ] ) diff --git a/app/src/sharedTest/java/org/oppia/android/app/options/OptionsActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/options/OptionsActivityTest.kt index 90f9adb4b4e..5b4a99e556e 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/options/OptionsActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/options/OptionsActivityTest.kt @@ -54,6 +54,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -132,7 +133,8 @@ class OptionsActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/options/OptionsFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/options/OptionsFragmentTest.kt index 239a13655ce..4f6073a5b6f 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/options/OptionsFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/options/OptionsFragmentTest.kt @@ -77,6 +77,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.EnableConsoleLog @@ -631,7 +632,8 @@ class OptionsFragmentTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/options/ReadingTextSizeActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/options/ReadingTextSizeActivityTest.kt index d8218dd3716..395605162ec 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/options/ReadingTextSizeActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/options/ReadingTextSizeActivityTest.kt @@ -54,6 +54,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -140,7 +141,8 @@ class ReadingTextSizeActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/options/ReadingTextSizeFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/options/ReadingTextSizeFragmentTest.kt index 7201f023edd..175079f887d 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/options/ReadingTextSizeFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/options/ReadingTextSizeFragmentTest.kt @@ -71,6 +71,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.CacheAssetsLocally import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule @@ -294,6 +295,7 @@ class ReadingTextSizeFragmentTest { DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, HintsAndSolutionProdModule::class, NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class, NetworkConfigProdModule::class ] ) diff --git a/app/src/sharedTest/java/org/oppia/android/app/parser/CustomBulletSpanTest.kt b/app/src/sharedTest/java/org/oppia/android/app/parser/CustomBulletSpanTest.kt index f6f037f0867..134aa6ec9ab 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/parser/CustomBulletSpanTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/parser/CustomBulletSpanTest.kt @@ -54,6 +54,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -232,6 +233,7 @@ class CustomBulletSpanTest { DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, HintsAndSolutionProdModule::class, NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class, NetworkConfigProdModule::class ] ) 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 3c21325350b..12dfb0dcb17 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 @@ -83,6 +83,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.DefaultResourceBucketName import org.oppia.android.util.gcsresource.GcsResourceModule @@ -634,7 +635,8 @@ class HtmlParserTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/player/audio/AudioFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/player/audio/AudioFragmentTest.kt index 79e2fcc9427..ef8b3107676 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/player/audio/AudioFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/player/audio/AudioFragmentTest.kt @@ -85,6 +85,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -460,7 +461,8 @@ class AudioFragmentTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/player/exploration/ExplorationActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/player/exploration/ExplorationActivityTest.kt index 7627605d290..9c20029e9c5 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/player/exploration/ExplorationActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/player/exploration/ExplorationActivityTest.kt @@ -116,6 +116,7 @@ import org.oppia.android.testing.threading.TestDispatcherModule import org.oppia.android.testing.time.FakeOppiaClock import org.oppia.android.testing.time.FakeOppiaClockModule import org.oppia.android.util.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -1738,7 +1739,8 @@ class ExplorationActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, TestExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) diff --git a/app/src/sharedTest/java/org/oppia/android/app/player/state/StateFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/player/state/StateFragmentTest.kt index 6db54dbd925..8d9dd8dd6db 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/player/state/StateFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/player/state/StateFragmentTest.kt @@ -142,6 +142,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.CacheAssetsLocally import org.oppia.android.util.caching.LoadImagesFromAssets import org.oppia.android.util.caching.LoadLessonProtosFromAssets @@ -2036,7 +2037,8 @@ class StateFragmentTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkConnectionUtilDebugModule::class, - NetworkConnectionDebugUtilModule::class, NetworkModule::class, NetworkConfigProdModule::class + NetworkConnectionDebugUtilModule::class, NetworkModule::class, NetworkConfigProdModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/profile/AddProfileActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/profile/AddProfileActivityTest.kt index d7e480a090b..908a5cc9432 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/profile/AddProfileActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/profile/AddProfileActivityTest.kt @@ -95,6 +95,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -1633,7 +1634,8 @@ class AddProfileActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/profile/AdminAuthActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/profile/AdminAuthActivityTest.kt index 47f0fbfe30d..f65716eb7b7 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/profile/AdminAuthActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/profile/AdminAuthActivityTest.kt @@ -82,6 +82,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -644,7 +645,8 @@ class AdminAuthActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/profile/AdminPinActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/profile/AdminPinActivityTest.kt index bb1c8a4938e..92c0c6fa7e5 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/profile/AdminPinActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/profile/AdminPinActivityTest.kt @@ -91,6 +91,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -1095,7 +1096,8 @@ class AdminPinActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/profile/PinPasswordActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/profile/PinPasswordActivityTest.kt index 450bdaffb1d..3b95f2c7096 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/profile/PinPasswordActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/profile/PinPasswordActivityTest.kt @@ -85,6 +85,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -1102,7 +1103,8 @@ class PinPasswordActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/profile/ProfileChooserFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/profile/ProfileChooserFragmentTest.kt index 033cca4f60b..1a8726055ea 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/profile/ProfileChooserFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/profile/ProfileChooserFragmentTest.kt @@ -79,6 +79,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.data.DataProviders.Companion.toLiveData import org.oppia.android.util.gcsresource.GcsResourceModule @@ -509,7 +510,8 @@ class ProfileChooserFragmentTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/profileprogress/ProfilePictureActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/profileprogress/ProfilePictureActivityTest.kt index 7d91c69f017..ab5bde34c2e 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/profileprogress/ProfilePictureActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/profileprogress/ProfilePictureActivityTest.kt @@ -60,6 +60,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -144,7 +145,8 @@ class ProfilePictureActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/profileprogress/ProfileProgressActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/profileprogress/ProfileProgressActivityTest.kt index e45d3a9dff1..4be8dfc07d9 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/profileprogress/ProfileProgressActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/profileprogress/ProfileProgressActivityTest.kt @@ -54,6 +54,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -134,7 +135,8 @@ class ProfileProgressActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/profileprogress/ProfileProgressFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/profileprogress/ProfileProgressFragmentTest.kt index 9423bb9d83e..6458c787b01 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/profileprogress/ProfileProgressFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/profileprogress/ProfileProgressFragmentTest.kt @@ -96,6 +96,7 @@ import org.oppia.android.testing.threading.TestDispatcherModule import org.oppia.android.testing.time.FakeOppiaClock import org.oppia.android.testing.time.FakeOppiaClockModule import org.oppia.android.util.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.EnableConsoleLog @@ -739,7 +740,8 @@ class ProfileProgressFragmentTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/recyclerview/BindableAdapterTest.kt b/app/src/sharedTest/java/org/oppia/android/app/recyclerview/BindableAdapterTest.kt index a444ae25af2..42fe9c46f5e 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/recyclerview/BindableAdapterTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/recyclerview/BindableAdapterTest.kt @@ -89,6 +89,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -738,7 +739,8 @@ class BindableAdapterTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/resumelesson/ResumeLessonActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/resumelesson/ResumeLessonActivityTest.kt index ef1068cdd0e..d139b7c42ec 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/resumelesson/ResumeLessonActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/resumelesson/ResumeLessonActivityTest.kt @@ -74,6 +74,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -205,7 +206,8 @@ class ResumeLessonActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/resumelesson/ResumeLessonFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/resumelesson/ResumeLessonFragmentTest.kt index 11cb228841f..a4fd3694488 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/resumelesson/ResumeLessonFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/resumelesson/ResumeLessonFragmentTest.kt @@ -68,6 +68,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -235,7 +236,8 @@ class ResumeLessonFragmentTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/settings/profile/ProfileEditActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/settings/profile/ProfileEditActivityTest.kt index 6b867a85225..632e20c858a 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/settings/profile/ProfileEditActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/settings/profile/ProfileEditActivityTest.kt @@ -77,6 +77,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.data.DataProviders.Companion.toLiveData import org.oppia.android.util.gcsresource.GcsResourceModule @@ -556,7 +557,8 @@ class ProfileEditActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/settings/profile/ProfileListActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/settings/profile/ProfileListActivityTest.kt index e6c00278681..a4cf80f8c2f 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/settings/profile/ProfileListActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/settings/profile/ProfileListActivityTest.kt @@ -54,6 +54,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -130,7 +131,8 @@ class ProfileListActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/settings/profile/ProfileListFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/settings/profile/ProfileListFragmentTest.kt index 90fef1c9528..62d6701ab8c 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/settings/profile/ProfileListFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/settings/profile/ProfileListFragmentTest.kt @@ -71,6 +71,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -368,7 +369,8 @@ class ProfileListFragmentTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/settings/profile/ProfileRenameActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/settings/profile/ProfileRenameActivityTest.kt index d0ce6d1b1b5..95e737e1ba4 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/settings/profile/ProfileRenameActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/settings/profile/ProfileRenameActivityTest.kt @@ -81,6 +81,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -468,7 +469,8 @@ class ProfileRenameActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/settings/profile/ProfileResetPinActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/settings/profile/ProfileResetPinActivityTest.kt index fd3cb754575..56bcae2daf9 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/settings/profile/ProfileResetPinActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/settings/profile/ProfileResetPinActivityTest.kt @@ -83,6 +83,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -1023,7 +1024,8 @@ class ProfileResetPinActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/splash/SplashActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/splash/SplashActivityTest.kt index b256d050eb5..d2603a6cc7e 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/splash/SplashActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/splash/SplashActivityTest.kt @@ -71,6 +71,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -306,7 +307,8 @@ class SplashActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, HintsAndSolutionProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/story/StoryActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/story/StoryActivityTest.kt index d029d592d70..ae24740633d 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/story/StoryActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/story/StoryActivityTest.kt @@ -74,6 +74,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -214,7 +215,8 @@ class StoryActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/story/StoryFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/story/StoryFragmentTest.kt index 5ab17a39e18..dbe592d7211 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/story/StoryFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/story/StoryFragmentTest.kt @@ -110,6 +110,7 @@ import org.oppia.android.testing.threading.TestDispatcherModule import org.oppia.android.testing.time.FakeOppiaClock import org.oppia.android.testing.time.FakeOppiaClockModule import org.oppia.android.util.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -730,7 +731,8 @@ class StoryFragmentTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/testing/DragDropTestActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/testing/DragDropTestActivityTest.kt index fba2d4c96b6..f498e07e9f6 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/testing/DragDropTestActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/testing/DragDropTestActivityTest.kt @@ -63,6 +63,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -203,7 +204,8 @@ class DragDropTestActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/testing/ImageRegionSelectionInteractionViewTest.kt b/app/src/sharedTest/java/org/oppia/android/app/testing/ImageRegionSelectionInteractionViewTest.kt index f794d929aca..ff3d821b16c 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/testing/ImageRegionSelectionInteractionViewTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/testing/ImageRegionSelectionInteractionViewTest.kt @@ -74,6 +74,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -301,7 +302,8 @@ class ImageRegionSelectionInteractionViewTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/testing/InputInteractionViewTestActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/testing/InputInteractionViewTestActivityTest.kt index 8adaf149c09..ad919277ebd 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/testing/InputInteractionViewTestActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/testing/InputInteractionViewTestActivityTest.kt @@ -70,6 +70,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -1019,7 +1020,8 @@ class InputInteractionViewTestActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/testing/NavigationDrawerActivityDebugTest.kt b/app/src/sharedTest/java/org/oppia/android/app/testing/NavigationDrawerActivityDebugTest.kt index d893d0dc5ce..d4ee9bf4e43 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/testing/NavigationDrawerActivityDebugTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/testing/NavigationDrawerActivityDebugTest.kt @@ -96,6 +96,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -423,7 +424,8 @@ class NavigationDrawerActivityDebugTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/testing/NavigationDrawerActivityProdTest.kt b/app/src/sharedTest/java/org/oppia/android/app/testing/NavigationDrawerActivityProdTest.kt index fa3f20f5e12..e9cb4cbbb9d 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/testing/NavigationDrawerActivityProdTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/testing/NavigationDrawerActivityProdTest.kt @@ -106,6 +106,7 @@ import org.oppia.android.testing.threading.TestDispatcherModule import org.oppia.android.testing.time.FakeOppiaClock import org.oppia.android.testing.time.FakeOppiaClockModule import org.oppia.android.util.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -972,6 +973,7 @@ class NavigationDrawerActivityProdTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class, NetworkConfigProdModule::class ] ) diff --git a/app/src/sharedTest/java/org/oppia/android/app/testing/TestFontScaleConfigurationUtilActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/testing/TestFontScaleConfigurationUtilActivityTest.kt index eb240b41212..8901f0b4cf6 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/testing/TestFontScaleConfigurationUtilActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/testing/TestFontScaleConfigurationUtilActivityTest.kt @@ -58,6 +58,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -180,7 +181,8 @@ class TestFontScaleConfigurationUtilActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class, AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/testing/TopicTestActivityForStoryTest.kt b/app/src/sharedTest/java/org/oppia/android/app/testing/TopicTestActivityForStoryTest.kt index ade9ca0d589..5f2ffd565b3 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/testing/TopicTestActivityForStoryTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/testing/TopicTestActivityForStoryTest.kt @@ -65,6 +65,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -178,7 +179,8 @@ class TopicTestActivityForStoryTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/thirdparty/LicenseListActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/thirdparty/LicenseListActivityTest.kt index 7f3fe4447bf..601bb6ac9b8 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/thirdparty/LicenseListActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/thirdparty/LicenseListActivityTest.kt @@ -55,6 +55,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -142,7 +143,8 @@ class LicenseListActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) diff --git a/app/src/sharedTest/java/org/oppia/android/app/thirdparty/LicenseListFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/thirdparty/LicenseListFragmentTest.kt index 1a8165d6d9e..c0304c4fb52 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/thirdparty/LicenseListFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/thirdparty/LicenseListFragmentTest.kt @@ -73,6 +73,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -356,7 +357,8 @@ class LicenseListFragmentTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) diff --git a/app/src/sharedTest/java/org/oppia/android/app/thirdparty/LicenseTextViewerActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/thirdparty/LicenseTextViewerActivityTest.kt index 258955fe487..41813853cd3 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/thirdparty/LicenseTextViewerActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/thirdparty/LicenseTextViewerActivityTest.kt @@ -56,6 +56,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -151,7 +152,8 @@ class LicenseTextViewerActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) diff --git a/app/src/sharedTest/java/org/oppia/android/app/thirdparty/LicenseTextViewerFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/thirdparty/LicenseTextViewerFragmentTest.kt index f1087caf478..9d6d6bfb5f9 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/thirdparty/LicenseTextViewerFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/thirdparty/LicenseTextViewerFragmentTest.kt @@ -63,6 +63,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -334,7 +335,8 @@ class LicenseTextViewerFragmentTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) diff --git a/app/src/sharedTest/java/org/oppia/android/app/thirdparty/ThirdPartyDependencyListActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/thirdparty/ThirdPartyDependencyListActivityTest.kt index 61b55264b26..7bc6b28c583 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/thirdparty/ThirdPartyDependencyListActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/thirdparty/ThirdPartyDependencyListActivityTest.kt @@ -55,6 +55,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -139,7 +140,8 @@ class ThirdPartyDependencyListActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) diff --git a/app/src/sharedTest/java/org/oppia/android/app/thirdparty/ThirdPartyDependencyListFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/thirdparty/ThirdPartyDependencyListFragmentTest.kt index 4ffff7e8433..9e8ed1db6e4 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/thirdparty/ThirdPartyDependencyListFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/thirdparty/ThirdPartyDependencyListFragmentTest.kt @@ -72,6 +72,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -466,7 +467,8 @@ class ThirdPartyDependencyListFragmentTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) diff --git a/app/src/sharedTest/java/org/oppia/android/app/topic/TopicActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/topic/TopicActivityTest.kt index 432391a0c69..c615f95f266 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/topic/TopicActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/topic/TopicActivityTest.kt @@ -54,6 +54,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -140,7 +141,8 @@ class TopicActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/topic/TopicFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/topic/TopicFragmentTest.kt index 7d961425991..7e16d115b8e 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/topic/TopicFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/topic/TopicFragmentTest.kt @@ -77,6 +77,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -601,7 +602,8 @@ class TopicFragmentTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/topic/conceptcard/ConceptCardFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/topic/conceptcard/ConceptCardFragmentTest.kt index 3f9fdb6b2b3..9951624ae50 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/topic/conceptcard/ConceptCardFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/topic/conceptcard/ConceptCardFragmentTest.kt @@ -74,6 +74,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.CacheAssetsLocally import org.oppia.android.util.caching.LoadImagesFromAssets import org.oppia.android.util.caching.LoadLessonProtosFromAssets @@ -303,7 +304,8 @@ class ConceptCardFragmentTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/topic/info/TopicInfoFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/topic/info/TopicInfoFragmentTest.kt index 450119542e6..10e10daa277 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/topic/info/TopicInfoFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/topic/info/TopicInfoFragmentTest.kt @@ -83,6 +83,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -426,7 +427,8 @@ class TopicInfoFragmentTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/topic/lessons/TopicLessonsFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/topic/lessons/TopicLessonsFragmentTest.kt index 5b445f44c83..855cba5c4ee 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/topic/lessons/TopicLessonsFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/topic/lessons/TopicLessonsFragmentTest.kt @@ -97,6 +97,7 @@ import org.oppia.android.testing.threading.TestDispatcherModule import org.oppia.android.testing.time.FakeOppiaClock import org.oppia.android.testing.time.FakeOppiaClockModule import org.oppia.android.util.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -914,7 +915,8 @@ class TopicLessonsFragmentTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/topic/practice/TopicPracticeFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/topic/practice/TopicPracticeFragmentTest.kt index 3abbf827b28..74a1acc5288 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/topic/practice/TopicPracticeFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/topic/practice/TopicPracticeFragmentTest.kt @@ -79,6 +79,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -414,7 +415,8 @@ class TopicPracticeFragmentTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerActivityTest.kt index 5ac47e75e97..d0a086b5af2 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerActivityTest.kt @@ -106,6 +106,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -527,7 +528,8 @@ class QuestionPlayerActivityTest { LogUploadWorkerModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/topic/revision/TopicRevisionFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/topic/revision/TopicRevisionFragmentTest.kt index 1c5c516429d..f5bab908d76 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/topic/revision/TopicRevisionFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/topic/revision/TopicRevisionFragmentTest.kt @@ -82,6 +82,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -310,7 +311,8 @@ class TopicRevisionFragmentTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/topic/revisioncard/RevisionCardActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/topic/revisioncard/RevisionCardActivityTest.kt index 902fe961533..043e58229b0 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/topic/revisioncard/RevisionCardActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/topic/revisioncard/RevisionCardActivityTest.kt @@ -55,6 +55,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -152,7 +153,8 @@ class RevisionCardActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/topic/revisioncard/RevisionCardFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/topic/revisioncard/RevisionCardFragmentTest.kt index 3c921de1c5b..c8d4b56fe52 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/topic/revisioncard/RevisionCardFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/topic/revisioncard/RevisionCardFragmentTest.kt @@ -87,6 +87,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.CacheAssetsLocally import org.oppia.android.util.caching.LoadImagesFromAssets import org.oppia.android.util.caching.LoadLessonProtosFromAssets @@ -436,7 +437,8 @@ class RevisionCardFragmentTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/utility/RatioExtensionsTest.kt b/app/src/sharedTest/java/org/oppia/android/app/utility/RatioExtensionsTest.kt index e5f430db6c9..ffdc9a9b2e7 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/utility/RatioExtensionsTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/utility/RatioExtensionsTest.kt @@ -50,6 +50,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -126,7 +127,8 @@ class RatioExtensionsTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/walkthrough/WalkthroughActivityTest.kt b/app/src/sharedTest/java/org/oppia/android/app/walkthrough/WalkthroughActivityTest.kt index 1ae2a560a28..574dba12457 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/walkthrough/WalkthroughActivityTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/walkthrough/WalkthroughActivityTest.kt @@ -60,6 +60,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -185,7 +186,8 @@ class WalkthroughActivityTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/walkthrough/WalkthroughFinalFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/walkthrough/WalkthroughFinalFragmentTest.kt index ff4b24dbd46..bce1654de42 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/walkthrough/WalkthroughFinalFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/walkthrough/WalkthroughFinalFragmentTest.kt @@ -68,6 +68,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -270,7 +271,8 @@ class WalkthroughFinalFragmentTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/walkthrough/WalkthroughTopicListFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/walkthrough/WalkthroughTopicListFragmentTest.kt index d13a7f4625b..810ffeeee49 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/walkthrough/WalkthroughTopicListFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/walkthrough/WalkthroughTopicListFragmentTest.kt @@ -72,6 +72,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.CacheAssetsLocally import org.oppia.android.util.caching.LoadImagesFromAssets import org.oppia.android.util.caching.LoadLessonProtosFromAssets @@ -296,7 +297,8 @@ class WalkthroughTopicListFragmentTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/sharedTest/java/org/oppia/android/app/walkthrough/WalkthroughWelcomeFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/walkthrough/WalkthroughWelcomeFragmentTest.kt index 9dfb5fa1803..d1576a0f8e2 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/walkthrough/WalkthroughWelcomeFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/walkthrough/WalkthroughWelcomeFragmentTest.kt @@ -65,6 +65,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -193,7 +194,8 @@ class WalkthroughWelcomeFragmentTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/test/java/org/oppia/android/app/home/HomeActivityLocalTest.kt b/app/src/test/java/org/oppia/android/app/home/HomeActivityLocalTest.kt index cdfa64fc9db..b7ed266a2a0 100644 --- a/app/src/test/java/org/oppia/android/app/home/HomeActivityLocalTest.kt +++ b/app/src/test/java/org/oppia/android/app/home/HomeActivityLocalTest.kt @@ -56,6 +56,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -134,6 +135,7 @@ class HomeActivityLocalTest { DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, HintsAndSolutionProdModule::class, NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class, NetworkConfigProdModule::class ] ) diff --git a/app/src/test/java/org/oppia/android/app/parser/StringToFractionParserTest.kt b/app/src/test/java/org/oppia/android/app/parser/StringToFractionParserTest.kt index 261484739e8..147aba92b49 100644 --- a/app/src/test/java/org/oppia/android/app/parser/StringToFractionParserTest.kt +++ b/app/src/test/java/org/oppia/android/app/parser/StringToFractionParserTest.kt @@ -51,6 +51,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -445,7 +446,8 @@ class StringToFractionParserTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/test/java/org/oppia/android/app/parser/StringToRatioParserTest.kt b/app/src/test/java/org/oppia/android/app/parser/StringToRatioParserTest.kt index e138607e594..951470b0e53 100644 --- a/app/src/test/java/org/oppia/android/app/parser/StringToRatioParserTest.kt +++ b/app/src/test/java/org/oppia/android/app/parser/StringToRatioParserTest.kt @@ -51,6 +51,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -221,7 +222,8 @@ class StringToRatioParserTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/test/java/org/oppia/android/app/player/exploration/ExplorationActivityLocalTest.kt b/app/src/test/java/org/oppia/android/app/player/exploration/ExplorationActivityLocalTest.kt index bc2bde4595b..abf844bd674 100644 --- a/app/src/test/java/org/oppia/android/app/player/exploration/ExplorationActivityLocalTest.kt +++ b/app/src/test/java/org/oppia/android/app/player/exploration/ExplorationActivityLocalTest.kt @@ -62,6 +62,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -202,6 +203,7 @@ class ExplorationActivityLocalTest { DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, HintsAndSolutionProdModule::class, NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class, NetworkConfigProdModule::class ] ) diff --git a/app/src/test/java/org/oppia/android/app/player/state/StateFragmentLocalTest.kt b/app/src/test/java/org/oppia/android/app/player/state/StateFragmentLocalTest.kt index 046a962960e..de4fde506bb 100644 --- a/app/src/test/java/org/oppia/android/app/player/state/StateFragmentLocalTest.kt +++ b/app/src/test/java/org/oppia/android/app/player/state/StateFragmentLocalTest.kt @@ -108,6 +108,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -1666,7 +1667,8 @@ class StateFragmentLocalTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/test/java/org/oppia/android/app/profile/ProfileChooserFragmentLocalTest.kt b/app/src/test/java/org/oppia/android/app/profile/ProfileChooserFragmentLocalTest.kt index 9dcc85cfe2c..80c515a44dd 100644 --- a/app/src/test/java/org/oppia/android/app/profile/ProfileChooserFragmentLocalTest.kt +++ b/app/src/test/java/org/oppia/android/app/profile/ProfileChooserFragmentLocalTest.kt @@ -54,6 +54,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -124,7 +125,8 @@ class ProfileChooserFragmentLocalTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, HintsAndSolutionProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/test/java/org/oppia/android/app/story/StoryActivityLocalTest.kt b/app/src/test/java/org/oppia/android/app/story/StoryActivityLocalTest.kt index 9d8f5cf7fc4..c727a6a597a 100644 --- a/app/src/test/java/org/oppia/android/app/story/StoryActivityLocalTest.kt +++ b/app/src/test/java/org/oppia/android/app/story/StoryActivityLocalTest.kt @@ -55,6 +55,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -148,7 +149,8 @@ class StoryActivityLocalTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/test/java/org/oppia/android/app/testing/CompletedStoryListSpanTest.kt b/app/src/test/java/org/oppia/android/app/testing/CompletedStoryListSpanTest.kt index ec6236475ba..ef34ca446c1 100644 --- a/app/src/test/java/org/oppia/android/app/testing/CompletedStoryListSpanTest.kt +++ b/app/src/test/java/org/oppia/android/app/testing/CompletedStoryListSpanTest.kt @@ -56,6 +56,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -156,6 +157,7 @@ class CompletedStoryListSpanTest { DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, HintsAndSolutionProdModule::class, NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class, NetworkConfigProdModule::class ] ) diff --git a/app/src/test/java/org/oppia/android/app/testing/HomeSpanTest.kt b/app/src/test/java/org/oppia/android/app/testing/HomeSpanTest.kt index 0296ed9d216..887c5a4d22e 100644 --- a/app/src/test/java/org/oppia/android/app/testing/HomeSpanTest.kt +++ b/app/src/test/java/org/oppia/android/app/testing/HomeSpanTest.kt @@ -56,6 +56,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -170,6 +171,7 @@ class HomeSpanTest { DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, HintsAndSolutionProdModule::class, NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class, NetworkConfigProdModule::class ] ) diff --git a/app/src/test/java/org/oppia/android/app/testing/OngoingTopicListSpanTest.kt b/app/src/test/java/org/oppia/android/app/testing/OngoingTopicListSpanTest.kt index efa76a1a516..ed6742cba04 100644 --- a/app/src/test/java/org/oppia/android/app/testing/OngoingTopicListSpanTest.kt +++ b/app/src/test/java/org/oppia/android/app/testing/OngoingTopicListSpanTest.kt @@ -57,6 +57,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -167,6 +168,7 @@ class OngoingTopicListSpanTest { DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, HintsAndSolutionProdModule::class, NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class, NetworkConfigProdModule::class ] ) diff --git a/app/src/test/java/org/oppia/android/app/testing/PlatformParameterIntegrationTest.kt b/app/src/test/java/org/oppia/android/app/testing/PlatformParameterIntegrationTest.kt index a45763b659d..69509534265 100644 --- a/app/src/test/java/org/oppia/android/app/testing/PlatformParameterIntegrationTest.kt +++ b/app/src/test/java/org/oppia/android/app/testing/PlatformParameterIntegrationTest.kt @@ -75,6 +75,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -335,7 +336,7 @@ class PlatformParameterIntegrationTest { DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, TestNetworkModule::class, RetrofitTestModule::class, NetworkConfigProdModule::class, NetworkConnectionUtilDebugModule::class, - NetworkConnectionDebugUtilModule::class + NetworkConnectionDebugUtilModule::class, AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/test/java/org/oppia/android/app/testing/ProfileChooserSpanTest.kt b/app/src/test/java/org/oppia/android/app/testing/ProfileChooserSpanTest.kt index eaf8ceafeb4..cccc242142f 100644 --- a/app/src/test/java/org/oppia/android/app/testing/ProfileChooserSpanTest.kt +++ b/app/src/test/java/org/oppia/android/app/testing/ProfileChooserSpanTest.kt @@ -56,6 +56,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -369,6 +370,7 @@ class ProfileChooserSpanTest { DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, HintsAndSolutionProdModule::class, NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class, NetworkConfigProdModule::class ] ) diff --git a/app/src/test/java/org/oppia/android/app/testing/ProfileProgressSpanCount.kt b/app/src/test/java/org/oppia/android/app/testing/ProfileProgressSpanCount.kt index ca0fdc0cc11..2c1fb5d5334 100644 --- a/app/src/test/java/org/oppia/android/app/testing/ProfileProgressSpanCount.kt +++ b/app/src/test/java/org/oppia/android/app/testing/ProfileProgressSpanCount.kt @@ -56,6 +56,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -152,6 +153,7 @@ class ProfileProgressSpanCount { DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, HintsAndSolutionProdModule::class, NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class, NetworkConfigProdModule::class ] ) diff --git a/app/src/test/java/org/oppia/android/app/testing/RecentlyPlayedSpanTest.kt b/app/src/test/java/org/oppia/android/app/testing/RecentlyPlayedSpanTest.kt index 55774636509..c918092cc3c 100644 --- a/app/src/test/java/org/oppia/android/app/testing/RecentlyPlayedSpanTest.kt +++ b/app/src/test/java/org/oppia/android/app/testing/RecentlyPlayedSpanTest.kt @@ -61,6 +61,7 @@ import org.oppia.android.testing.threading.TestDispatcherModule import org.oppia.android.testing.time.FakeOppiaClock import org.oppia.android.testing.time.FakeOppiaClockModule import org.oppia.android.util.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -288,6 +289,7 @@ class RecentlyPlayedSpanTest { DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, HintsAndSolutionProdModule::class, NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class, NetworkConfigProdModule::class ] ) diff --git a/app/src/test/java/org/oppia/android/app/testing/TopicRevisionSpanTest.kt b/app/src/test/java/org/oppia/android/app/testing/TopicRevisionSpanTest.kt index af72cb962f2..5c8f51823b9 100644 --- a/app/src/test/java/org/oppia/android/app/testing/TopicRevisionSpanTest.kt +++ b/app/src/test/java/org/oppia/android/app/testing/TopicRevisionSpanTest.kt @@ -55,6 +55,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -153,6 +154,7 @@ class TopicRevisionSpanTest { DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, HintsAndSolutionProdModule::class, NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class, NetworkConfigProdModule::class ] ) diff --git a/app/src/test/java/org/oppia/android/app/testing/administratorcontrols/AdministratorControlsFragmentTest.kt b/app/src/test/java/org/oppia/android/app/testing/administratorcontrols/AdministratorControlsFragmentTest.kt index dcf522c7bcb..ea12303b4ac 100644 --- a/app/src/test/java/org/oppia/android/app/testing/administratorcontrols/AdministratorControlsFragmentTest.kt +++ b/app/src/test/java/org/oppia/android/app/testing/administratorcontrols/AdministratorControlsFragmentTest.kt @@ -67,6 +67,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -213,7 +214,8 @@ class AdministratorControlsFragmentTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/test/java/org/oppia/android/app/testing/options/OptionsFragmentTest.kt b/app/src/test/java/org/oppia/android/app/testing/options/OptionsFragmentTest.kt index 163acfa0737..f122ab908a9 100644 --- a/app/src/test/java/org/oppia/android/app/testing/options/OptionsFragmentTest.kt +++ b/app/src/test/java/org/oppia/android/app/testing/options/OptionsFragmentTest.kt @@ -60,6 +60,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -210,7 +211,8 @@ class OptionsFragmentTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, HintsAndSolutionProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/test/java/org/oppia/android/app/testing/player/split/PlayerSplitScreenTesting.kt b/app/src/test/java/org/oppia/android/app/testing/player/split/PlayerSplitScreenTesting.kt index 61ca0b0e135..3fa567d8737 100644 --- a/app/src/test/java/org/oppia/android/app/testing/player/split/PlayerSplitScreenTesting.kt +++ b/app/src/test/java/org/oppia/android/app/testing/player/split/PlayerSplitScreenTesting.kt @@ -53,6 +53,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -181,6 +182,7 @@ class PlayerSplitScreenTesting { DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, HintsAndSolutionProdModule::class, NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class, NetworkConfigProdModule::class ] ) diff --git a/app/src/test/java/org/oppia/android/app/testing/player/state/StateFragmentAccessibilityTest.kt b/app/src/test/java/org/oppia/android/app/testing/player/state/StateFragmentAccessibilityTest.kt index 1ee48a8f9fa..deefef26636 100644 --- a/app/src/test/java/org/oppia/android/app/testing/player/state/StateFragmentAccessibilityTest.kt +++ b/app/src/test/java/org/oppia/android/app/testing/player/state/StateFragmentAccessibilityTest.kt @@ -66,6 +66,7 @@ import org.oppia.android.testing.threading.TestDispatcherModule import org.oppia.android.testing.time.FakeOppiaClockModule import org.oppia.android.util.accessibility.AccessibilityTestModule import org.oppia.android.util.accessibility.FakeAccessibilityChecker +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -195,6 +196,7 @@ class StateFragmentAccessibilityTest { DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, HintsAndSolutionProdModule::class, NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class, NetworkConfigProdModule::class ] ) diff --git a/app/src/test/java/org/oppia/android/app/topic/info/TopicInfoFragmentLocalTest.kt b/app/src/test/java/org/oppia/android/app/topic/info/TopicInfoFragmentLocalTest.kt index 8b8f0917f09..86ae714076c 100644 --- a/app/src/test/java/org/oppia/android/app/topic/info/TopicInfoFragmentLocalTest.kt +++ b/app/src/test/java/org/oppia/android/app/topic/info/TopicInfoFragmentLocalTest.kt @@ -53,6 +53,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -136,7 +137,8 @@ class TopicInfoFragmentLocalTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/test/java/org/oppia/android/app/topic/lessons/TopicLessonsFragmentLocalTest.kt b/app/src/test/java/org/oppia/android/app/topic/lessons/TopicLessonsFragmentLocalTest.kt index 867884ecbf6..236bd0c33e8 100644 --- a/app/src/test/java/org/oppia/android/app/topic/lessons/TopicLessonsFragmentLocalTest.kt +++ b/app/src/test/java/org/oppia/android/app/topic/lessons/TopicLessonsFragmentLocalTest.kt @@ -52,6 +52,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -138,7 +139,8 @@ class TopicLessonsFragmentLocalTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, NetworkConfigProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/test/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerActivityLocalTest.kt b/app/src/test/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerActivityLocalTest.kt index b01dca3767f..5754c0ad09e 100644 --- a/app/src/test/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerActivityLocalTest.kt +++ b/app/src/test/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerActivityLocalTest.kt @@ -82,6 +82,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -401,7 +402,8 @@ class QuestionPlayerActivityLocalTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, HintsAndSolutionProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/test/java/org/oppia/android/app/topic/revisioncard/RevisionCardActivityLocalTest.kt b/app/src/test/java/org/oppia/android/app/topic/revisioncard/RevisionCardActivityLocalTest.kt index 12c49e48629..fe6fa8ca345 100644 --- a/app/src/test/java/org/oppia/android/app/topic/revisioncard/RevisionCardActivityLocalTest.kt +++ b/app/src/test/java/org/oppia/android/app/topic/revisioncard/RevisionCardActivityLocalTest.kt @@ -54,6 +54,7 @@ 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.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.LoggerModule @@ -128,7 +129,8 @@ class RevisionCardActivityLocalTest { FirebaseLogUploaderModule::class, FakeOppiaClockModule::class, PracticeTabModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, ExplorationStorageModule::class, NetworkModule::class, HintsAndSolutionProdModule::class, - NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class + NetworkConnectionUtilDebugModule::class, NetworkConnectionDebugUtilModule::class, + AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/app/src/test/java/org/oppia/android/app/utility/datetime/DateTimeUtilTest.kt b/app/src/test/java/org/oppia/android/app/utility/datetime/DateTimeUtilTest.kt index b5dc50a600f..a0c85976ea1 100644 --- a/app/src/test/java/org/oppia/android/app/utility/datetime/DateTimeUtilTest.kt +++ b/app/src/test/java/org/oppia/android/app/utility/datetime/DateTimeUtilTest.kt @@ -51,6 +51,7 @@ import org.oppia.android.testing.threading.TestDispatcherModule import org.oppia.android.testing.time.FakeOppiaClock import org.oppia.android.testing.time.FakeOppiaClockModule import org.oppia.android.util.accessibility.AccessibilityTestModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.gcsresource.GcsResourceModule import org.oppia.android.util.logging.EnableConsoleLog @@ -148,7 +149,7 @@ class DateTimeUtilTest { HintsAndSolutionConfigModule::class, ExpirationMetaDataRetrieverModule::class, GlideImageLoaderModule::class, PrimeTopicAssetsControllerModule::class, HtmlParserEntityTypeModule::class, NetworkConnectionDebugUtilModule::class, - DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class + DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, AssetModule::class ] ) interface TestApplicationComponent : ApplicationComponent { diff --git a/build.gradle b/build.gradle index 9730d7d0eb6..e0b2d5f2b98 100644 --- a/build.gradle +++ b/build.gradle @@ -10,7 +10,7 @@ buildscript { dependencies { classpath 'com.android.tools.build:gradle:3.6.1' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" - classpath 'com.google.protobuf:protobuf-gradle-plugin:0.8.12' + classpath 'com.google.protobuf:protobuf-gradle-plugin:0.8.17' classpath 'com.google.gms:google-services:4.3.3' classpath 'com.google.firebase:firebase-crashlytics-gradle:2.1.1' // NOTE: Do not place your application dependencies here; they belong diff --git a/config/config_proto_assets.bzl b/config/config_proto_assets.bzl new file mode 100644 index 00000000000..f3073c33163 --- /dev/null +++ b/config/config_proto_assets.bzl @@ -0,0 +1,30 @@ +""" +Macro for generating proto assets for app-wide configurations. +""" + +load("//model:text_proto_assets.bzl", "generate_proto_binary_assets") + +def generate_supported_languages_configuration_from_text_proto( + name, + supported_language_text_proto_file_name): + """ + Converts multiple lists of text proto assets to binary. + + Args: + name: str. The name of this generation instance. This will be a prefix for derived targets. + supported_language_text_proto_file_name: target. The target corresponding to the text proto + defining the list of supported languages in the app. + + Returns: + list of str. The list of new proto binary asset files that were generated. + """ + return generate_proto_binary_assets( + name = name, + names = [supported_language_text_proto_file_name], + proto_dep_name = "languages", + proto_type_name = "SupportedLanguages", + name_prefix = name, + asset_dir = "languages", + proto_dep_bazel_target_prefix = "//model", + proto_package = "model", + ) diff --git a/config/src/java/org/oppia/android/config/AndroidManifest.xml b/config/src/java/org/oppia/android/config/AndroidManifest.xml new file mode 100644 index 00000000000..54ecb1d7e7e --- /dev/null +++ b/config/src/java/org/oppia/android/config/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + diff --git a/config/src/java/org/oppia/android/config/BUILD.bazel b/config/src/java/org/oppia/android/config/BUILD.bazel new file mode 100644 index 00000000000..36079416378 --- /dev/null +++ b/config/src/java/org/oppia/android/config/BUILD.bazel @@ -0,0 +1,38 @@ +# TODO(#1532): Rename file to 'BUILD' post-Gradle. +""" +This package contains configuration libraries for defining & tweaking app-wide behavior. +""" + +load("//model:text_proto_assets.bzl", "generate_proto_binary_assets") + +_SUPPORTED_LANGUAGES_CONFIG_ASSETS = generate_proto_binary_assets( + name = "supported_languages_config_assets", + asset_dir = "languages", + name_prefix = "supported_languages_config_assets", + names = ["supported_languages"], + proto_dep_bazel_target_prefix = "//model", + proto_dep_name = "languages", + proto_package = "model", + proto_type_name = "SupportedLanguages", +) + +_SUPPORTED_REGIONS_CONFIG_ASSETS = generate_proto_binary_assets( + name = "supported_regions_config_assets", + asset_dir = "languages", + name_prefix = "supported_regions_config_assets", + names = ["supported_regions"], + proto_dep_bazel_target_prefix = "//model", + proto_dep_name = "languages", + proto_package = "model", + proto_type_name = "SupportedRegions", +) + +android_library( + name = "languages_config", + assets = _SUPPORTED_LANGUAGES_CONFIG_ASSETS + _SUPPORTED_REGIONS_CONFIG_ASSETS, + assets_dir = "languages/", + manifest = "AndroidManifest.xml", + visibility = [ + "//domain/src/main/java/org/oppia/android/domain/locale:__pkg__", + ], +) diff --git a/config/src/java/org/oppia/android/config/languages/supported_languages.textproto b/config/src/java/org/oppia/android/config/languages/supported_languages.textproto new file mode 100644 index 00000000000..4e6d3f35138 --- /dev/null +++ b/config/src/java/org/oppia/android/config/languages/supported_languages.textproto @@ -0,0 +1,109 @@ +language_definitions { + language: ARABIC + min_android_sdk_version: 1 + app_string_id { + ietf_bcp47_id { + ietf_language_tag: "ar" + } + android_resources_language_id { + language_code: "ar" + } + } + content_string_id { + ietf_bcp47_id { + ietf_language_tag: "ar" + } + } + audio_translation_id { + ietf_bcp47_id { + ietf_language_tag: "ar" + } + } +} +language_definitions { + language: ENGLISH + min_android_sdk_version: 1 + app_string_id { + ietf_bcp47_id { + ietf_language_tag: "en" + } + android_resources_language_id { + language_code: "en" + } + } + content_string_id { + ietf_bcp47_id { + ietf_language_tag: "en" + } + } + audio_translation_id { + ietf_bcp47_id { + ietf_language_tag: "en" + } + } +} +language_definitions { + language: HINDI + min_android_sdk_version: 1 + app_string_id { + ietf_bcp47_id { + ietf_language_tag: "hi" + } + android_resources_language_id { + language_code: "hi" + } + } + content_string_id { + ietf_bcp47_id { + ietf_language_tag: "hi" + } + } + audio_translation_id { + ietf_bcp47_id { + ietf_language_tag: "hi" + } + } +} +language_definitions { + language: HINGLISH + fallback_macro_language: ENGLISH + min_android_sdk_version: 1 + content_string_id { + macaronic_id { + combined_language_code: "hi-en" + } + } + audio_translation_id { + macaronic_id { + combined_language_code: "hi-en" + } + } +} +language_definitions { + language: PORTUGUESE + min_android_sdk_version: 1 +} +language_definitions { + language: BRAZILIAN_PORTUGUESE + fallback_macro_language: PORTUGUESE + min_android_sdk_version: 1 + app_string_id { + ietf_bcp47_id { + ietf_language_tag: "pt-BR" + } + android_resources_language_id { + language_code: "pt" + region_code: "BR" + } + } + content_string_id { + ietf_bcp47_id { + ietf_language_tag: "pt-BR" + } + } + audio_translation_id { + ietf_bcp47_id { + ietf_language_tag: "pt-BR" + } + } +} diff --git a/config/src/java/org/oppia/android/config/languages/supported_regions.textproto b/config/src/java/org/oppia/android/config/languages/supported_regions.textproto new file mode 100644 index 00000000000..14e223355ad --- /dev/null +++ b/config/src/java/org/oppia/android/config/languages/supported_regions.textproto @@ -0,0 +1,23 @@ +region_definitions { + region: BRAZIL + region_id { + ietf_region_tag: "BR" + } + languages: PORTUGUESE + languages: BRAZILIAN_PORTUGUESE +} +region_definitions { + region: INDIA + region_id { + ietf_region_tag: "IN" + } + languages: HINDI + languages: HINGLISH +} +region_definitions { + region: UNITED_STATES + region_id { + ietf_region_tag: "US" + } + languages: ENGLISH +} diff --git a/data/build.gradle b/data/build.gradle index 33aea201ef4..245404d1cd7 100644 --- a/data/build.gradle +++ b/data/build.gradle @@ -66,7 +66,7 @@ dependencies { 'androidx.appcompat:appcompat:1.0.2', 'com.android.support:multidex:1.0.3', 'com.google.dagger:dagger:2.24', - 'com.google.protobuf:protobuf-lite:3.0.0', + 'com.google.protobuf:protobuf-javalite:3.17.3', 'com.squareup.moshi:moshi-kotlin:1.11.0', 'com.squareup.okhttp3:okhttp:4.1.0', 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.2.2', diff --git a/domain/BUILD.bazel b/domain/BUILD.bazel index b291178ff02..f481758ff28 100755 --- a/domain/BUILD.bazel +++ b/domain/BUILD.bazel @@ -8,6 +8,20 @@ load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_android_library") load("//domain:domain_assets.bzl", "generate_assets_list_from_text_protos") load("//domain:domain_test.bzl", "domain_test") +filegroup( + name = "test_manifest", + srcs = ["src/test/AndroidManifest.xml"], + visibility = ["//:oppia_testing_visibility"], +) + +# Visibility for migrated domain tests. +package_group( + name = "domain_testing_visibility", + packages = [ + "//domain/src/test/...", + ], +) + # Source files that have been migrated to their own package-based libraries. Files added to this # list will be excluded automatically from the top-level utility library. It is recommended to use # globs here to ensure that new files added to migrated packages don't accidentally get included in the @@ -116,6 +130,15 @@ kt_android_library( ], ) +android_library( + name = "test_resources", + testonly = True, + custom_package = "org.oppia.android.domain", + manifest = ":test_manifest", + resource_files = glob(["src/test/res/**"]), + visibility = [":domain_testing_visibility"], +) + # TODO(#2143): Move InteractionObjectTestBuilder to a testing package outside the test folder. kt_android_library( name = "interaction_object_test_builder", @@ -157,6 +180,7 @@ TEST_DEPS = [ "//third_party:org_jetbrains_kotlin_kotlin-reflect", "//third_party:org_jetbrains_kotlin_kotlin-test-junit", "//third_party:robolectric_android-all", + "//utility/src/main/java/org/oppia/android/util/caching:asset_prod_module", "//utility/src/main/java/org/oppia/android/util/caching/testing:caching_test_module", "//utility/src/main/java/org/oppia/android/util/extensions:context_extensions", "//utility/src/main/java/org/oppia/android/util/networking:debug_module", diff --git a/domain/build.gradle b/domain/build.gradle index 1acc9a9798d..e27fe01d501 100644 --- a/domain/build.gradle +++ b/domain/build.gradle @@ -61,6 +61,26 @@ android { } } +// These tests aren't supported in Gradle since they make use of test resources & AGP doesn't +// support merging resources for test builds, or they rely on a compile-time built proto +// configuration file which the current Gradle configuration doesn't support. This test runs +// correctly for Bazel & is included in the CI workflow that runs Bazel tests. +// https://stackoverflow.com/a/69141612 seems like the only solution that actually works (versus +// trying to exclude via sourceSets), so the following is an adapted version that ensures all +// generated sources that may reference the test also don't exist (such as Dagger running to +// generate a test application component). Note that this must exist in tandem with the sourceSet +// exclusion in order to properly work. +def filesToExclude = [ + '**/*DisplayLocaleImplTest*.kt', + '**/*LanguageConfigRetrieverTest*.kt', + '**/*LocaleControllerTest*.kt', + '**/*TranslationControllerTest*.kt' +] +tasks.withType(org.gradle.api.tasks.SourceTask.class).configureEach { + it.exclude(filesToExclude) +} +android.sourceSets.test.kotlin.exclude(filesToExclude) + dependencies { implementation fileTree(dir: 'libs', include: ['*.jar']) implementation( @@ -72,6 +92,7 @@ dependencies { 'com.google.firebase:firebase-analytics-ktx:17.5.0', 'com.google.firebase:firebase-crashlytics:17.0.0', 'com.google.guava:guava:28.1-android', + 'com.google.protobuf:protobuf-javalite:3.17.3', "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" ) compileOnly( diff --git a/domain/src/main/java/org/oppia/android/domain/locale/AndroidLocaleFactory.kt b/domain/src/main/java/org/oppia/android/domain/locale/AndroidLocaleFactory.kt new file mode 100644 index 00000000000..c66235c4d30 --- /dev/null +++ b/domain/src/main/java/org/oppia/android/domain/locale/AndroidLocaleFactory.kt @@ -0,0 +1,151 @@ +package org.oppia.android.domain.locale + +import android.os.Build +import org.oppia.android.app.model.LanguageSupportDefinition +import org.oppia.android.app.model.LanguageSupportDefinition.LanguageId +import org.oppia.android.app.model.OppiaLocaleContext +import org.oppia.android.app.model.RegionSupportDefinition +import org.oppia.android.util.locale.AndroidLocaleProfile +import org.oppia.android.util.locale.OppiaLocale +import org.oppia.android.util.locale.getFallbackLanguageId +import org.oppia.android.util.locale.getLanguageId +import java.util.Locale +import javax.inject.Inject + +/** + * Factory for creating new Android [Locale]s. This is meant only to be used within the locale + * domain package. + */ +class AndroidLocaleFactory @Inject constructor( + private val machineLocale: OppiaLocale.MachineLocale +) { + /** + * Returns a new [Locale] that matches the given [OppiaLocaleContext]. Note this will + * automatically fail over to the context's backup fallback language if the primary language + * doesn't match any available locales on the device. Further, if no locale can be found, the + * returned [Locale] will be forced to match the specified context (which will result in some + * default/root locale behavior in Android). + */ + fun createAndroidLocale(localeContext: OppiaLocaleContext): Locale { + val languageId = localeContext.getLanguageId() + val fallbackLanguageId = localeContext.getFallbackLanguageId() + + // Locale is always computed based on the Android resource app string identifier if that's + // defined. If it isn't, the routine falls back to app language & region country codes (which + // also provides interoperability with system-derived contexts). Note that if either identifier + // is missing for the primary language, the fallback is used instead (if available), except that + // IETF BCP 47 tags from the primary language are used before Android resource codes from the + // fallback. Thus, the order of this list is important. Finally, a basic check is done here to + // make sure this version of Android can actually render the target language. + val potentialProfiles = + computePotentialLanguageProfiles(localeContext, languageId) + + computePotentialFallbackLanguageProfiles(localeContext, fallbackLanguageId) + + // Either find the first supported profile or force the locale to use the exact definition + // values, depending on whether to fail over to a forced locale. + val firstSupportedProfile = potentialProfiles.findFirstSupported() + val selectedProfile = firstSupportedProfile + ?: languageId.computeForcedProfile(localeContext.regionDefinition) + return Locale(selectedProfile.languageCode, selectedProfile.getNonWildcardRegionCode()) + } + + private fun computePotentialLanguageProfiles( + localeContext: OppiaLocaleContext, + languageId: LanguageId + ): List = + computeLanguageProfiles(localeContext, localeContext.languageDefinition, languageId) + + private fun computePotentialFallbackLanguageProfiles( + localeContext: OppiaLocaleContext, + fallbackLanguageId: LanguageId + ): List { + return computeLanguageProfiles( + localeContext, localeContext.fallbackLanguageDefinition, fallbackLanguageId + ) + } + + private fun computeLanguageProfiles( + localeContext: OppiaLocaleContext, + definition: LanguageSupportDefinition, + languageId: LanguageId + ): List { + return if (definition.minAndroidSdkVersion <= Build.VERSION.SDK_INT) { + listOfNotNull( + languageId.computeLocaleProfileFromAndroidId(), + AndroidLocaleProfile.createFromIetfDefinitions(languageId, localeContext.regionDefinition), + AndroidLocaleProfile.createFromMacaronicLanguage(languageId) + ) + } else listOf() + } + + private fun LanguageId.computeLocaleProfileFromAndroidId(): AndroidLocaleProfile? { + return if (hasAndroidResourcesLanguageId()) { + androidResourcesLanguageId.run { + // 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). + maybeConstructProfileWithWildcardSupport(languageCode, regionCode) + } + } else null + } + + /** + * Returns an [AndroidLocaleProfile] for this [LanguageId] and the specified + * [RegionSupportDefinition] based on the language's & region's IETF BCP 47 codes regardless of + * whether they're defined (i.e. it's fine to default to empty string here since that will + * leverage Android's own root locale behavior). + */ + private fun LanguageId.computeForcedProfile( + regionDefinition: RegionSupportDefinition + ): AndroidLocaleProfile { + if (hasAndroidResourcesLanguageId()) { + // Create a locale exactly matching the Android ID profile. + return AndroidLocaleProfile( + androidResourcesLanguageId.languageCode, androidResourcesLanguageId.regionCode + ) + } + return when (languageTypeCase) { + LanguageId.LanguageTypeCase.IETF_BCP47_ID -> { + AndroidLocaleProfile( + ietfBcp47Id.ietfLanguageTag, regionDefinition.regionId.ietfRegionTag + ) + } + LanguageId.LanguageTypeCase.MACARONIC_ID -> { + AndroidLocaleProfile.createFromMacaronicLanguage(this) + ?: error("Invalid macaronic ID: ${macaronicId.combinedLanguageCode}") + } + LanguageId.LanguageTypeCase.LANGUAGETYPE_NOT_SET, null -> + error("Invalid language case: $languageTypeCase") + } + } + + private fun maybeConstructProfileWithWildcardSupport( + languageCode: String, + regionCode: String + ): AndroidLocaleProfile? { + return if (languageCode.isNotEmpty()) { + val adjustedRegionCode = if (regionCode.isEmpty()) { + AndroidLocaleProfile.REGION_WILDCARD + } else regionCode + AndroidLocaleProfile(languageCode, adjustedRegionCode) + } else null + } + + private fun List.findFirstSupported(): AndroidLocaleProfile? = find { + availableLocaleProfiles.any { availableProfile -> + availableProfile.matches(machineLocale, it) + } + } + + private companion object { + private val availableLocaleProfiles by lazy { + Locale.getAvailableLocales().map(AndroidLocaleProfile::createFrom) + } + + private fun AndroidLocaleProfile.getNonWildcardRegionCode(): String { + return if (regionCode != AndroidLocaleProfile.REGION_WILDCARD) { + regionCode + } else "" + } + } +} diff --git a/domain/src/main/java/org/oppia/android/domain/locale/BUILD.bazel b/domain/src/main/java/org/oppia/android/domain/locale/BUILD.bazel new file mode 100644 index 00000000000..6fe929306cc --- /dev/null +++ b/domain/src/main/java/org/oppia/android/domain/locale/BUILD.bazel @@ -0,0 +1,89 @@ +""" +Domain definitions for managing languages & locales. +""" + +load("@dagger//:workspace_defs.bzl", "dagger_rules") +load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_android_library") + +kt_android_library( + name = "locale_controller", + srcs = [ + "LocaleController.kt", + ], + visibility = ["//:oppia_api_visibility"], + deps = [ + ":content_locale_impl", + ":dagger", + ":display_locale_impl", + ":language_config_retriever", + "//domain", + "//model:languages_java_proto_lite", + "//utility/src/main/java/org/oppia/android/util/data:async_result", + "//utility/src/main/java/org/oppia/android/util/data:data_provider", + "//utility/src/main/java/org/oppia/android/util/locale:oppia_locale", + ], +) + +kt_android_library( + name = "android_locale_factory", + srcs = [ + "AndroidLocaleFactory.kt", + ], + visibility = [ + "//domain:domain_testing_visibility", + ], + deps = [ + ":dagger", + "//utility/src/main/java/org/oppia/android/util/locale:android_locale_profile", + ], +) + +kt_android_library( + name = "display_locale_impl", + srcs = [ + "DisplayLocaleImpl.kt", + ], + visibility = [ + "//domain:domain_testing_visibility", + ], + deps = [ + ":android_locale_factory", + "//third_party:androidx_core_core", + "//utility/src/main/java/org/oppia/android/util/data:data_providers", + "//utility/src/main/java/org/oppia/android/util/locale:oppia_bidi_formatter", + "//utility/src/main/java/org/oppia/android/util/locale:oppia_locale", + ], +) + +kt_android_library( + name = "content_locale_impl", + srcs = [ + "ContentLocaleImpl.kt", + ], + visibility = [ + "//domain:domain_testing_visibility", + ], + deps = [ + "//model:languages_java_proto_lite", + "//utility/src/main/java/org/oppia/android/util/locale:oppia_locale", + ], +) + +kt_android_library( + name = "language_config_retriever", + srcs = [ + "LanguageConfigRetriever.kt", + ], + visibility = [ + "//domain:domain_testing_visibility", + ], + deps = [ + ":dagger", + "//config/src/java/org/oppia/android/config:languages_config", + "//model:languages_java_proto_lite", + "//utility/src/main/java/org/oppia/android/util/caching:annotations", + "//utility/src/main/java/org/oppia/android/util/caching:asset_repository", + ], +) + +dagger_rules() diff --git a/domain/src/main/java/org/oppia/android/domain/locale/ContentLocaleImpl.kt b/domain/src/main/java/org/oppia/android/domain/locale/ContentLocaleImpl.kt new file mode 100644 index 00000000000..7944c245d51 --- /dev/null +++ b/domain/src/main/java/org/oppia/android/domain/locale/ContentLocaleImpl.kt @@ -0,0 +1,13 @@ +package org.oppia.android.domain.locale + +import org.oppia.android.app.model.OppiaLocaleContext +import org.oppia.android.util.locale.OppiaLocale + +/** + * A data class implementation of [OppiaLocale.ContentLocale]. + * + * @property oppiaLocaleContext the [OppiaLocaleContext] corresponding to this locale + */ +data class ContentLocaleImpl( + val oppiaLocaleContext: OppiaLocaleContext +) : OppiaLocale.ContentLocale(oppiaLocaleContext) diff --git a/domain/src/main/java/org/oppia/android/domain/locale/DisplayLocaleImpl.kt b/domain/src/main/java/org/oppia/android/domain/locale/DisplayLocaleImpl.kt new file mode 100644 index 00000000000..39b231cf4ac --- /dev/null +++ b/domain/src/main/java/org/oppia/android/domain/locale/DisplayLocaleImpl.kt @@ -0,0 +1,120 @@ +package org.oppia.android.domain.locale + +import android.content.res.Configuration +import android.content.res.Resources +import androidx.annotation.ArrayRes +import androidx.annotation.StringRes +import androidx.core.text.TextUtilsCompat +import org.oppia.android.app.model.OppiaLocaleContext +import org.oppia.android.util.locale.OppiaBidiFormatter +import org.oppia.android.util.locale.OppiaLocale +import java.text.DateFormat +import java.util.Date +import java.util.Locale +import java.util.Objects + +// TODO(#3766): Restrict to be 'internal'. +/** Implementation of [OppiaLocale.DisplayLocale]. */ +class DisplayLocaleImpl( + localeContext: OppiaLocaleContext, + 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) + } + private val timeFormat by lazy { + DateFormat.getTimeInstance(TIME_FORMAT_LENGTH, formattingLocale) + } + private val dateTimeFormat by lazy { + DateFormat.getDateTimeInstance(DATE_FORMAT_LENGTH, TIME_FORMAT_LENGTH, formattingLocale) + } + private val bidiFormatter by lazy { formatterFactory.createFormatter(formattingLocale) } + + // TODO(#3766): Restrict to be 'internal'. + /** + * Updates the specified [Configuration] to reference the formatting locale backing this display + * locale. Note that this may not be sufficient for actually updating the configuration--it may + * need to be done during activity initialization and in some cases an activity recreation may be + * necessary. + */ + fun setAsDefault(configuration: Configuration) { + configuration.setLocale(formattingLocale) + } + + override fun computeDateString(timestampMillis: Long): String = + dateFormat.format(Date(timestampMillis)) + + override fun computeTimeString(timestampMillis: Long): String = + timeFormat.format(Date(timestampMillis)) + + override fun computeDateTimeString(timestampMillis: Long): String = + dateTimeFormat.format(Date(timestampMillis)) + + override fun getLayoutDirection(): Int { + return TextUtilsCompat.getLayoutDirectionFromLocale(formattingLocale) + } + + override fun String.formatInLocaleWithWrapping(vararg args: CharSequence): String { + return formatInLocaleWithoutWrapping( + *args.map { arg -> bidiFormatter.wrapText(arg) }.toTypedArray() + ) + } + + override fun String.formatInLocaleWithoutWrapping(vararg args: CharSequence): String = + format(formattingLocale, *args) + + override fun String.capitalizeForHumans(): String = capitalize(formattingLocale) + + override fun Resources.getStringInLocale(@StringRes id: Int): String = getString(id) + + override fun Resources.getStringInLocaleWithWrapping( + id: Int, + vararg formatArgs: CharSequence + ): String = getStringInLocale(id).formatInLocaleWithWrapping(*formatArgs) + + override fun Resources.getStringInLocaleWithoutWrapping( + id: Int, + vararg formatArgs: CharSequence + ): String = getStringInLocale(id).formatInLocaleWithoutWrapping(*formatArgs) + + override fun Resources.getStringArrayInLocale(@ArrayRes id: Int): List = + getStringArray(id).toList() + + override fun Resources.getQuantityStringInLocale(id: Int, quantity: Int): String = + getQuantityTextInLocale(id, quantity).toString() + + override fun Resources.getQuantityStringInLocaleWithWrapping( + id: Int, + quantity: Int, + vararg formatArgs: CharSequence + ): String = getQuantityStringInLocale(id, quantity).formatInLocaleWithWrapping(*formatArgs) + + override fun Resources.getQuantityStringInLocaleWithoutWrapping( + id: Int, + quantity: Int, + vararg formatArgs: CharSequence + ): String = getQuantityStringInLocale(id, quantity).formatInLocaleWithoutWrapping(*formatArgs) + + override fun Resources.getQuantityTextInLocale(id: Int, quantity: Int): CharSequence = + getQuantityText(id, quantity) + + override fun toString(): String = "DisplayLocaleImpl[context=$localeContext]" + + override fun equals(other: Any?): Boolean { + return (other as? DisplayLocaleImpl)?.let { locale -> + localeContext == locale.localeContext && machineLocale == locale.machineLocale + } ?: false + } + + override fun hashCode(): Int = Objects.hash(localeContext, machineLocale) + + private companion object { + private const val DATE_FORMAT_LENGTH = DateFormat.LONG + private const val TIME_FORMAT_LENGTH = DateFormat.SHORT + } +} diff --git a/domain/src/main/java/org/oppia/android/domain/locale/LanguageConfigRetriever.kt b/domain/src/main/java/org/oppia/android/domain/locale/LanguageConfigRetriever.kt new file mode 100644 index 00000000000..ca9df28fcbd --- /dev/null +++ b/domain/src/main/java/org/oppia/android/domain/locale/LanguageConfigRetriever.kt @@ -0,0 +1,29 @@ +package org.oppia.android.domain.locale + +import org.oppia.android.app.model.SupportedLanguages +import org.oppia.android.app.model.SupportedRegions +import org.oppia.android.util.caching.AssetRepository +import javax.inject.Inject + +/** + * Retriever for language configurations from the app's embedded assets. + * + * Note that this implementation is expected to no-op on Gradle builds since they don't include the + * necessary configuration files. The rest of the locale & translation systems are expected to + * gracefully fail in this case. + */ +class LanguageConfigRetriever @Inject constructor(private val assetRepository: AssetRepository) { + /** Returns the [SupportedLanguages] configuration for the app, or default instance if none. */ + fun loadSupportedLanguages(): SupportedLanguages { + return assetRepository.tryLoadProtoFromLocalAssets( + "supported_languages", SupportedLanguages.getDefaultInstance() + ) + } + + /** Returns the [SupportedRegions] configuration for the app, or default instance if none. */ + fun loadSupportedRegions(): SupportedRegions { + return assetRepository.tryLoadProtoFromLocalAssets( + "supported_regions", SupportedRegions.getDefaultInstance() + ) + } +} 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 new file mode 100644 index 00000000000..351eee9da50 --- /dev/null +++ b/domain/src/main/java/org/oppia/android/domain/locale/LocaleController.kt @@ -0,0 +1,468 @@ +package org.oppia.android.domain.locale + +import android.annotation.SuppressLint +import android.annotation.TargetApi +import android.content.Context +import android.content.res.Configuration +import android.os.Build +import org.oppia.android.app.model.LanguageSupportDefinition +import org.oppia.android.app.model.OppiaLanguage +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.app.model.OppiaLocaleContext.LanguageUsageMode.AUDIO_TRANSLATIONS +import org.oppia.android.app.model.OppiaLocaleContext.LanguageUsageMode.CONTENT_STRINGS +import org.oppia.android.app.model.OppiaLocaleContext.LanguageUsageMode.UNRECOGNIZED +import org.oppia.android.app.model.OppiaLocaleContext.LanguageUsageMode.USAGE_MODE_UNSPECIFIED +import org.oppia.android.app.model.OppiaRegion +import org.oppia.android.app.model.RegionSupportDefinition +import org.oppia.android.app.model.SupportedLanguages +import org.oppia.android.app.model.SupportedRegions +import org.oppia.android.domain.oppialogger.OppiaLogger +import org.oppia.android.util.data.AsyncDataSubscriptionManager +import org.oppia.android.util.data.AsyncResult +import org.oppia.android.util.data.DataProvider +import org.oppia.android.util.data.DataProviders +import org.oppia.android.util.data.DataProviders.Companion.transformAsync +import org.oppia.android.util.locale.AndroidLocaleProfile +import org.oppia.android.util.locale.OppiaBidiFormatter +import org.oppia.android.util.locale.OppiaLocale +import org.oppia.android.util.locale.OppiaLocale.ContentLocale +import org.oppia.android.util.locale.OppiaLocale.DisplayLocale +import org.oppia.android.util.locale.OppiaLocale.MachineLocale +import java.util.Locale +import java.util.concurrent.locks.ReentrantLock +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.concurrent.withLock + +private const val ANDROID_SYSTEM_LOCALE_DATA_PROVIDER_ID = "android_locale" +private const val APP_STRING_LOCALE_DATA_BASE_PROVIDER_ID = "app_string_locale." +private const val WRITTEN_TRANSLATION_LOCALE_BASE_DATA_PROVIDER_ID = "written_translation_locale." +private const val AUDIO_TRANSLATIONS_LOCALE_BASE_DATA_PROVIDER_ID = "audio_translations_locale." +private const val SYSTEM_LANGUAGE_DATA_PROVIDER_ID = "system_language" + +/** Controller for creating & retrieving user-specified [OppiaLocale]s. */ +@Singleton +class LocaleController @Inject constructor( + private val applicationContext: Context, + private val dataProviders: DataProviders, + private val languageConfigRetriever: LanguageConfigRetriever, + private val oppiaLogger: OppiaLogger, + private val asyncDataSubscriptionManager: AsyncDataSubscriptionManager, + private val machineLocale: MachineLocale, + private val androidLocaleFactory: AndroidLocaleFactory, + private val formatterFactory: OppiaBidiFormatter.Factory +) { + private val definitionsLock = ReentrantLock() + private lateinit var supportedLanguages: SupportedLanguages + private lateinit var supportedRegions: SupportedRegions + + /** + * Returns the [OppiaLocaleContext] which provides a reasonable default context when building a + * locale for app strings. Generally this should be used in conjunction with + * [reconstituteDisplayLocale], and only during locale bootstrapping. App layer classes should + * rely on a consistent locale fetched via one of the data providers below rather than creating an + * immediate locale using these methods. + * + * Note that the returned locale cannot be assumed to follow any particular locale, only that it's + * likely to correspond to a valid Android locale when setting up an activity's configuration for + * string resource selection and layout arrangement. + */ + fun getLikelyDefaultAppStringLocaleContext(): OppiaLocaleContext { + return OppiaLocaleContext.newBuilder().apply { + // Assume English for the default language since it has the highest chance of being + // successful. Note that this theoretically could differ from the language definitions + // since it's hardcoded, but that should be fine. Also, only assume app language + // support. + languageDefinition = LanguageSupportDefinition.newBuilder().apply { + language = OppiaLanguage.ENGLISH + minAndroidSdkVersion = 1 + appStringId = LanguageSupportDefinition.LanguageId.newBuilder().apply { + ietfBcp47Id = LanguageSupportDefinition.IetfBcp47LanguageId.newBuilder().apply { + ietfLanguageTag = "en" + }.build() + }.build() + }.build() + regionDefinition = RegionSupportDefinition.newBuilder().apply { + region = OppiaRegion.UNITED_STATES + regionId = RegionSupportDefinition.IetfBcp47RegionId.newBuilder().apply { + ietfRegionTag = "US" + }.build() + addLanguages(OppiaLanguage.ENGLISH) + }.build() + usageMode = APP_STRINGS + }.build() + } + + // TODO(#3800): Utilize this method to build in resilience for low-memory process deaths resulting + // in crashes upon the app be foregrounded. + /** + * Returns a new [DisplayLocale] corresponding to the specified [OppiaLocaleContext]. This is + * meant to be used in cases when a context needs to be saved (e.g. in a bundle) and later + * restored without needing to synchronously re-fetch the locale via a data provider. + * + * [DisplayLocale] implementations guarantee functional equivalence between different instances so + * long as they have the same context, so the fetch is only needed for eventual consistency (i.e. + * for cases in which the user changed their selected language). + */ + fun reconstituteDisplayLocale(oppiaLocaleContext: OppiaLocaleContext): DisplayLocale { + return DisplayLocaleImpl( + oppiaLocaleContext, machineLocale, androidLocaleFactory, formatterFactory + ) + } + + /** + * Notifies that the default Android locale may have changed. + * + * Since there's no way to reliably get notified when the default system locale is changed, it's + * the expected responsibility of calling infrastructure to ensure detected changes to the system + * locale (such as per an activity configuration change) result in downstream data providers being + * recomputed in case they rely on the system locale & the locale has indeed changed. See + * [retrieveSystemLanguage] & other provider methods in this class. + * + * This generally shouldn't be called broadly. + */ + fun notifyPotentialLocaleChange() { + // There may not actually be a change in the default locale, but assume there is & trigger an + // update for data providers. + asyncDataSubscriptionManager.notifyChangeAsync(ANDROID_SYSTEM_LOCALE_DATA_PROVIDER_ID) + } + + /** + * Returns the [DisplayLocale] corresponding to the specified [OppiaLanguage], or potentially a + * best-match locale that should be compatible with both the supported languages of the app and + * the system (though this isn't guaranteed). Cases in which no match can be determined will + * result in a failed result. + * + * Note that the returned [DataProvider] may be dependent on the system locale in which case its + * subscribers may be notified whenever callers notify this controller of potential system locale + * changes (i.e. via [notifyPotentialLocaleChange]. + * + * The returned [OppiaLocale] will have an [OppiaLocaleContext] tied to app strings. Further, no + * assumptions can be made about the region information within [OppiaLocaleContext]. This + * controller makes little attempt to actually determine the region the user is in, and oftentimes + * will default to an unspecified region. This is largely because the main signal the app has for + * determining the user's region is the system locale (which is configurable by the user and not + * at all dependent on their geolocation). Other measures could be taken to try and get the user's + * location (such as using the telephony services), but these are limited (require cellular + * functionality & connectivity) and generally aren't needed for any decision making in the app + * (language is sufficient). Finally, it's possible for the region to change based on selecting + * different languages since the app may actually force the system locale into a specific region + * for properly app string localization (such as Brazil for Brazilian Portuguese). + */ + fun retrieveAppStringDisplayLocale(language: OppiaLanguage): DataProvider { + val providerId = "$APP_STRING_LOCALE_DATA_BASE_PROVIDER_ID.${language.name}" + return getSystemLocaleProfile().transformAsync(providerId) { systemLocaleProfile -> + computeLocaleResult(language, systemLocaleProfile, APP_STRINGS) + } + } + + /** + * Returns the [ContentLocale] corresponding to the specified [OppiaLanguage] (which is + * guaranteed to be supported by the app) to be used for written translations, or a failure if for + * some reason that's not possible (such as if the loaded language configuration doesn't include + * the specified language). + * + * The returned [DataProvider] has the same notification caveat as + * [retrieveAppStringDisplayLocale]. + * + * The returned [OppiaLocale] will have an [OppiaLocaleContext] tied to content strings. The + * returned locale has the same region caveats as [retrieveAppStringDisplayLocale]. + */ + fun retrieveWrittenTranslationsLocale(language: OppiaLanguage): DataProvider { + val providerId = "$WRITTEN_TRANSLATION_LOCALE_BASE_DATA_PROVIDER_ID.${language.name}" + return getSystemLocaleProfile().transformAsync(providerId) { systemLocaleProfile -> + computeLocaleResult(language, systemLocaleProfile, CONTENT_STRINGS) + } + } + + /** + * Returns the [ContentLocale] corresponding to the specified [OppiaLanguage] for audio + * translations with the same failure stipulation as [retrieveWrittenTranslationsLocale]. + * + * The returned [DataProvider] has the same notification caveat as + * [retrieveAppStringDisplayLocale]. + * + * The returned [OppiaLocale] will have an [OppiaLocaleContext] tied to audio translations. The + * returned locale has the same region caveats as [retrieveAppStringDisplayLocale]. + */ + fun retrieveAudioTranslationsLocale(language: OppiaLanguage): DataProvider { + val providerId = "$AUDIO_TRANSLATIONS_LOCALE_BASE_DATA_PROVIDER_ID.${language.name}" + return getSystemLocaleProfile().transformAsync(providerId) { systemLocaleProfile -> + computeLocaleResult(language, systemLocaleProfile, AUDIO_TRANSLATIONS) + } + } + + /** + * Returns the [OppiaLanguage] best matching the current system locale (based on the list of + * supported languages by the app), or [OppiaLanguage.LANGUAGE_UNSPECIFIED] if the current system + * locale does not correspond to a supported language. + * + * Note that the system locale is only ever matched against app language definitions, never + * written or audio content translations. + * + * The returned [DataProvider]'s subscribers may be notified upon calls to + * [notifyPotentialLocaleChange] if there's actually a change in the system locale. + */ + fun retrieveSystemLanguage(): DataProvider { + val providerId = SYSTEM_LANGUAGE_DATA_PROVIDER_ID + return getSystemLocaleProfile().transformAsync(providerId) { systemLocaleProfile -> + AsyncResult.success( + retrieveLanguageDefinitionFromSystemCode(systemLocaleProfile)?.language + ?: OppiaLanguage.LANGUAGE_UNSPECIFIED + ) + } + } + + /** + * Updates both the system and the specified [Configuration] to use the [DisplayLocale] as the + * default locale for the current app process (which will affect string resource retrieval). + * + * Note that this may result in data providers returned by this class being notified of changes if + * any depend on the current system locale, and will likely change the result of the data provider + * returned by [retrieveSystemLanguage]. + */ + fun setAsDefault(displayLocale: DisplayLocale, configuration: Configuration) { + (displayLocale as? DisplayLocaleImpl)?.let { locale -> + locale.setAsDefault(configuration) + + // Note that this seemingly causes an infinite loop since notification happens in response to + // the upstream locale data provider changing, but it should terminate since the + // DataProvider->LiveData bridge only notifies if data changes (i.e. if the locale is actually + // new). Note also that this controller intentionally doesn't cache the system locale since + // there's no way to actually observe changes to it, so the controller aims to have eventual + // consistency by always retrieving the latest state when requested. This does mean locale + // changes can be missed if they aren't accompanied by a configuration change or activity + // recreation. + Locale.setDefault(locale.formattingLocale) + + // Ensure that the application context is also using the new locale (which it should by + // default since Android's responsible for setting it, but this is done for assurance since + // other code in this controller relies on the application context's locale rather than the + // system default). Note also that this has the side effect of overriding user-selected + // locales for fallback languages (which is probably fine since the app relies on its own + // fallback mechanism when choosing the primary locale). + applicationContext.resources.configuration.setLocale(locale.formattingLocale) + + notifyPotentialLocaleChange() + } ?: error("Invalid display locale type passed in: $displayLocale") + } + + private fun getSystemLocaleProfile(): DataProvider { + return dataProviders.createInMemoryDataProvider(ANDROID_SYSTEM_LOCALE_DATA_PROVIDER_ID) { + AndroidLocaleProfile.createFrom(getSystemLocale()) + } + } + + /** + * Returns the current system [Locale], as specified by the user or system. Note that this + * generally prefers pulling from the application context since the app overwrites the static + * singleton Locale for the app. + */ + @SuppressLint("ObsoleteSdkInt") // Incorrect warning since the app has a lower min sdk. + private fun getSystemLocale(): Locale { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + getDefaultLocaleApi24(applicationContext.resources.configuration) + } else getDefaultLocale(applicationContext.resources.configuration) + } + + @TargetApi(Build.VERSION_CODES.N) + private fun getDefaultLocaleApi24(configuration: Configuration): Locale { + val locales = configuration.locales + // Note that if this ever defaults to Locale.getDefault() it will break language switching when + // the user indicates that the app should use the system language (without restarting the app). + // Also, this only matches against the first locale. In the future, some effort could be made to + // try and pick the best matching system locale (per the user's preferences) rather than the + // "first or nothing" currently implemented here. + return if (locales.isEmpty) { + oppiaLogger.e( + "LocaleController", + "No locales defined for application context. Defaulting to default Locale." + ) + Locale.getDefault() + } else locales[0] + } + + @Suppress("DEPRECATION") // Old API is needed for SDK versions < N. + private fun getDefaultLocale(configuration: Configuration): Locale = configuration.locale + + private suspend fun computeLocaleResult( + language: OppiaLanguage, + systemLocaleProfile: AndroidLocaleProfile, + usageMode: LanguageUsageMode + ): AsyncResult { + // The safe-cast here is meant to ensure a strongly typed public API with robustness against + // 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) + } ?: AsyncResult.failed( + IllegalStateException( + "Language $language for usage $usageMode doesn't match supported language definitions" + ) + ) + } + + private suspend fun computeLocale( + language: OppiaLanguage, + systemLocaleProfile: AndroidLocaleProfile, + usageMode: LanguageUsageMode + ): OppiaLocale? { + val localeContext = OppiaLocaleContext.newBuilder().apply { + languageDefinition = + computeLanguageDefinition(language, systemLocaleProfile, usageMode) ?: return null + retrieveLanguageDefinition(languageDefinition.fallbackMacroLanguage)?.let { + fallbackLanguageDefinition = it + } + regionDefinition = retrieveRegionDefinition(systemLocaleProfile.regionCode) + this.usageMode = usageMode + }.build() + + // Check whether the selected language is actually expected for the user's region (it might not + // be, but the app should generally still behave correctly). + val selectedLanguage = localeContext.languageDefinition.language + val matchedRegion = localeContext.regionDefinition + if (selectedLanguage !in matchedRegion.languagesList) { + oppiaLogger.w( + "LocaleController", + "Notice: selected language $selectedLanguage is not part of the corresponding region" + + " matched to this locale: ${matchedRegion.region} (ID:" + + " ${matchedRegion.regionId.ietfRegionTag}) (supported languages:" + + " ${matchedRegion.languagesList}" + ) + } + + return when (usageMode) { + APP_STRINGS -> + DisplayLocaleImpl(localeContext, machineLocale, androidLocaleFactory, formatterFactory) + CONTENT_STRINGS, AUDIO_TRANSLATIONS -> ContentLocaleImpl(localeContext) + USAGE_MODE_UNSPECIFIED, UNRECOGNIZED -> null + } + } + + private suspend fun computeLanguageDefinition( + language: OppiaLanguage, + systemLocaleProfile: AndroidLocaleProfile, + usageMode: LanguageUsageMode + ): LanguageSupportDefinition? { + // Matching behaves as follows (for app strings): + // 1. Try to find a matching definition directly for the language. + // 2. If that fails, try falling back to the current system language. + // 3. If that fails, create a basic definition to represent the system language. + // Content strings & audio translations only perform step 1 since there's no reasonable + // fallback. + val matchedDefinition = retrieveLanguageDefinition(language) + return if (usageMode == APP_STRINGS) { + matchedDefinition + ?: retrieveLanguageDefinitionFromSystemCode(systemLocaleProfile) + ?: computeDefaultLanguageDefinitionForSystemLanguage(systemLocaleProfile.languageCode) + } else matchedDefinition + } + + /** + * Returns the [LanguageSupportDefinition] corresponding to the specified language, if it exists. + * In general, a definition should always exist unless the language is unspecified. + */ + private suspend fun retrieveLanguageDefinition( + language: OppiaLanguage + ): LanguageSupportDefinition? { + val definitions = retrieveAllLanguageDefinitions() + return definitions.languageDefinitionsList.find { + it.language == language + }.also { + if (it == null) { + oppiaLogger.w("LocaleController", "Encountered unmatched language: $language") + } + } + } + + /** + * Returns the [LanguageSupportDefinition] corresponding to the specified locale profile, or null + * if none match. + * + * This only matches against app string IDs since content & audio translations never fall back to + * system languages. + */ + private suspend fun retrieveLanguageDefinitionFromSystemCode( + localeProfile: AndroidLocaleProfile + ): LanguageSupportDefinition? { + val definitions = retrieveAllLanguageDefinitions() + // Attempt to find a matching definition. Note that while Locale's language code is expected to + // be an ISO 639-1/2/3 code, it not necessarily match the IETF BCP 47 tag defined for this + // language. If a language is unknown, return a definition that attempts to be interoperable + // with Android. Note that a sequence is used here to avoid computing extra profiles when not + // needed. + return definitions.languageDefinitionsList.asSequence().mapNotNull { definition -> + return@mapNotNull definition.retrieveAppLanguageProfile()?.let { profile -> + profile to definition + } + }.find { (profile, _) -> + localeProfile.matches(machineLocale, profile) + }?.let { (_, definition) -> definition } + } + + private suspend fun retrieveRegionDefinition(countryCode: String): RegionSupportDefinition { + val definitions = retrieveAllRegionDefinitions() + // Attempt to find a matching definition. Note that while Locale's country code can either be + // an ISO 3166 alpha-2 or UN M.49 numeric-3 code, that may not necessarily match the IETF BCP + // 47 tag defined for this region. If a region doesn't match, return unknown & just use the + // country code directly for the formatting locale. + return definitions.regionDefinitionsList.find { + machineLocale.run { + it.regionId.ietfRegionTag.equalsIgnoreCase(countryCode) + } + } ?: RegionSupportDefinition.newBuilder().apply { + region = OppiaRegion.REGION_UNSPECIFIED + regionId = RegionSupportDefinition.IetfBcp47RegionId.newBuilder().apply { + ietfRegionTag = countryCode + }.build() + }.build() + } + + @Suppress("RedundantSuspendModifier") // Keep to force calls to background threads. + private suspend fun retrieveAllLanguageDefinitions() = definitionsLock.withLock { + if (!::supportedLanguages.isInitialized) { + supportedLanguages = languageConfigRetriever.loadSupportedLanguages() + } + return@withLock supportedLanguages + } + + @Suppress("RedundantSuspendModifier") // Keep to force calls to background threads. + private suspend fun retrieveAllRegionDefinitions() = definitionsLock.withLock { + if (!::supportedRegions.isInitialized) { + supportedRegions = languageConfigRetriever.loadSupportedRegions() + } + return@withLock supportedRegions + } + + private fun LanguageSupportDefinition.retrieveAppLanguageProfile(): AndroidLocaleProfile? { + return when (appStringId.languageTypeCase) { + LanguageSupportDefinition.LanguageId.LanguageTypeCase.IETF_BCP47_ID -> + AndroidLocaleProfile.createFromIetfDefinitions(appStringId, regionDefinition = null) + LanguageSupportDefinition.LanguageId.LanguageTypeCase.MACARONIC_ID -> { + // Likely won't match against system languages. + AndroidLocaleProfile.createFromMacaronicLanguage(appStringId) + } + LanguageSupportDefinition.LanguageId.LanguageTypeCase.LANGUAGETYPE_NOT_SET, null -> null + } + } + + private fun computeDefaultLanguageDefinitionForSystemLanguage( + languageCode: String + ) = LanguageSupportDefinition.newBuilder().apply { + language = OppiaLanguage.LANGUAGE_UNSPECIFIED + minAndroidSdkVersion = 1 // Assume it's supported on the current version. + // Only app strings can be supported since this is a system language. Content & audio languages + // 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 = languageCode + }.build() + }.build() + }.build() +} diff --git a/domain/src/main/java/org/oppia/android/domain/translation/BUILD.bazel b/domain/src/main/java/org/oppia/android/domain/translation/BUILD.bazel new file mode 100644 index 00000000000..b03063bfffd --- /dev/null +++ b/domain/src/main/java/org/oppia/android/domain/translation/BUILD.bazel @@ -0,0 +1,28 @@ +""" +Domain definitions for managing translations. +""" + +load("@dagger//:workspace_defs.bzl", "dagger_rules") +load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_android_library") + +kt_android_library( + name = "translation_controller", + srcs = [ + "TranslationController.kt", + ], + visibility = ["//:oppia_api_visibility"], + deps = [ + "//domain/src/main/java/org/oppia/android/domain/locale:locale_controller", + "//model:languages_java_proto_lite", + "//model:profile_java_proto_lite", + "//model:subtitled_html_java_proto_lite", + "//model:subtitled_unicode_java_proto_lite", + "//model:translation_java_proto_lite", + "//utility/src/main/java/org/oppia/android/util/data:async_result", + "//utility/src/main/java/org/oppia/android/util/data:data_provider", + "//utility/src/main/java/org/oppia/android/util/data:data_providers", + "//utility/src/main/java/org/oppia/android/util/locale:oppia_locale", + ], +) + +dagger_rules() diff --git a/domain/src/main/java/org/oppia/android/domain/translation/TranslationController.kt b/domain/src/main/java/org/oppia/android/domain/translation/TranslationController.kt new file mode 100644 index 00000000000..537dd846b7e --- /dev/null +++ b/domain/src/main/java/org/oppia/android/domain/translation/TranslationController.kt @@ -0,0 +1,340 @@ +package org.oppia.android.domain.translation + +import org.oppia.android.app.model.AppLanguageSelection +import org.oppia.android.app.model.AudioTranslationLanguageSelection +import org.oppia.android.app.model.OppiaLanguage +import org.oppia.android.app.model.ProfileId +import org.oppia.android.app.model.SubtitledHtml +import org.oppia.android.app.model.SubtitledUnicode +import org.oppia.android.app.model.WrittenTranslationContext +import org.oppia.android.app.model.WrittenTranslationLanguageSelection +import org.oppia.android.domain.locale.LocaleController +import org.oppia.android.util.data.AsyncDataSubscriptionManager +import org.oppia.android.util.data.AsyncResult +import org.oppia.android.util.data.DataProvider +import org.oppia.android.util.data.DataProviders +import org.oppia.android.util.data.DataProviders.Companion.transform +import org.oppia.android.util.data.DataProviders.Companion.transformAsync +import org.oppia.android.util.locale.OppiaLocale +import java.util.concurrent.locks.ReentrantLock +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.concurrent.withLock + +private const val SYSTEM_LANGUAGE_LOCALE_DATA_PROVIDER_ID = "system_language_locale" +private const val APP_LANGUAGE_DATA_PROVIDER_ID = "app_language" +private const val APP_LANGUAGE_LOCALE_DATA_PROVIDER_ID = "app_language_locale" +private const val UPDATE_APP_LANGUAGE_DATA_PROVIDER_ID = "update_app_language" +private const val WRITTEN_TRANSLATION_CONTENT_DATA_PROVIDER_ID = "written_translation_content" +private const val WRITTEN_TRANSLATION_CONTENT_LOCALE_DATA_PROVIDER_ID = + "written_translation_content_locale" +private const val UPDATE_WRITTEN_TRANSLATION_CONTENT_DATA_PROVIDER_ID = + "update_written_translation_content" +private const val AUDIO_TRANSLATION_CONTENT_DATA_PROVIDER_ID = "audio_translation_content" +private const val AUDIO_TRANSLATION_CONTENT_LOCALE_DATA_PROVIDER_ID = + "audio_translation_content_locale" +private const val UPDATE_AUDIO_TRANSLATION_CONTENT_DATA_PROVIDER_ID = + "update_audio_translation_content" + +/** + * Domain controller for performing operations corresponding to translations. + * + * This controller is often used instead of [LocaleController] since it provides additional + * functionality which simplifies interacting with the locales needed for various translation + * scenarios, but it relies on locale controller as its source of truth. + */ +@Singleton +class TranslationController @Inject constructor( + private val dataProviders: DataProviders, + private val localeController: LocaleController, + private val asyncDataSubscriptionManager: AsyncDataSubscriptionManager +) { + // TODO(#52): Finish this implementation. The implementation below doesn't actually save/restore + // settings from the local filesystem since the UI has been currently disabled as part of #20. + // Also, there should be a proper default locale for pre-profile selection (either a default + // app-wide setting determined by the administrator, or the last locale used by a profile)--more + // product & UX thought is needed here. Further, extra work is likely needed to handle the case + // when the user switches between a system & non-system language since the existing system + // defaulting behavior breaks down when the system locale is overwritten (such as when a + // different language is selected). + + private val dataLock = ReentrantLock() + private val appLanguageSettings = mutableMapOf() + private val writtenTranslationLanguageSettings = + mutableMapOf() + private val audioVoiceoverLanguageSettings = + mutableMapOf() + + /** + * Returns a data provider for an app string [OppiaLocale.DisplayLocale] corresponding to the + * current user-selected system language. + */ + fun getSystemLanguageLocale(): DataProvider { + return getSystemLanguage().transformAsync(SYSTEM_LANGUAGE_LOCALE_DATA_PROVIDER_ID) { language -> + localeController.retrieveAppStringDisplayLocale(language).retrieveData() + } + } + + /** + * Returns a data provider for the current [OppiaLanguage] selected for app strings for the + * specified user (per their [profileId]). + * + * This language can be updated via [updateAppLanguage]. + */ + fun getAppLanguage(profileId: ProfileId): DataProvider { + return getAppLanguageLocale(profileId).transform(APP_LANGUAGE_DATA_PROVIDER_ID) { locale -> + locale.getCurrentLanguage() + } + } + + /** + * Returns a data provider for a [OppiaLocale.DisplayLocale] corresponding to the user's selected + * language for app strings (see [getAppLanguage]). + */ + fun getAppLanguageLocale(profileId: ProfileId): DataProvider { + val providerId = APP_LANGUAGE_LOCALE_DATA_PROVIDER_ID + return getSystemLanguage().transformAsync(providerId) { systemLanguage -> + val language = computeAppLanguage(profileId, systemLanguage) + return@transformAsync localeController.retrieveAppStringDisplayLocale(language).retrieveData() + } + } + + /** + * Updates the language to be used by the specified user for app string translations. Note that + * the provided [AppLanguageSelection] provides the user with the option of either selecting a + * specific supported language for app strings, or to fall back to the system default. + * + * The app guarantees app language compatibility for any non-system language selected, and a + * best-effort basis for translating strings for system languages (generally if the system + * language matches a supported language, otherwise the app defaults to English). + * + * @return a [DataProvider] which succeeds only if the update succeeds, otherwise fails (only one + * result is ever provided) + */ + fun updateAppLanguage(profileId: ProfileId, selection: AppLanguageSelection): DataProvider { + return dataProviders.createInMemoryDataProviderAsync(UPDATE_APP_LANGUAGE_DATA_PROVIDER_ID) { + updateAppLanguageSelection(profileId, selection) + return@createInMemoryDataProviderAsync AsyncResult.success(Unit) + } + } + + /** + * Returns a data provider for the current [OppiaLanguage] selected for written content strings + * for the specified user (per their [profileId]). + * + * This language can be updated via [updateWrittenTranslationContentLanguage]. + */ + fun getWrittenTranslationContentLanguage(profileId: ProfileId): DataProvider { + val providerId = WRITTEN_TRANSLATION_CONTENT_DATA_PROVIDER_ID + return getWrittenTranslationContentLocale(profileId).transform(providerId) { locale -> + locale.getCurrentLanguage() + } + } + + /** + * Returns a data provider for a [OppiaLocale.ContentLocale] corresponding to the user's selected + * language for written content strings (see [getWrittenTranslationContentLanguage]). + */ + fun getWrittenTranslationContentLocale( + profileId: ProfileId + ): DataProvider { + val providerId = WRITTEN_TRANSLATION_CONTENT_LOCALE_DATA_PROVIDER_ID + return getSystemLanguage().transformAsync(providerId) { systemLanguage -> + val language = computeWrittenTranslationContentLanguage(profileId, systemLanguage) + val writtenTranslationLocale = localeController.retrieveWrittenTranslationsLocale(language) + return@transformAsync writtenTranslationLocale.retrieveData() + } + } + + /** + * Updates the language to be used by the specified user for written content string translations. + * Note that the provided [WrittenTranslationLanguageSelection] provides the user with the option + * of either selecting a specific supported language for content strings, or to fall back to + * whatever the app language selection is (which may be the system default). + * + * Note that the app guarantees a list of languages to support for written translations as a + * superset. The actual availability for a particular language is topic-dependent. + * + * @return a [DataProvider] which succeeds only if the update succeeds, otherwise fails (only one + * result is ever provided) + */ + fun updateWrittenTranslationContentLanguage( + profileId: ProfileId, + selection: WrittenTranslationLanguageSelection + ): DataProvider { + val providerId = UPDATE_WRITTEN_TRANSLATION_CONTENT_DATA_PROVIDER_ID + return dataProviders.createInMemoryDataProviderAsync(providerId) { + updateWrittenTranslationContentLanguageSelection(profileId, selection) + return@createInMemoryDataProviderAsync AsyncResult.success(Unit) + } + } + + /** + * Returns a data provider for the current [OppiaLanguage] selected for audio voiceovers for the + * specified user (per their [profileId]). + * + * This language can be updated via [updateAudioTranslationContentLanguage]. + */ + fun getAudioTranslationContentLanguage(profileId: ProfileId): DataProvider { + val providerId = AUDIO_TRANSLATION_CONTENT_DATA_PROVIDER_ID + return getAudioTranslationContentLocale(profileId).transform(providerId) { locale -> + locale.getCurrentLanguage() + } + } + + /** + * Returns a data provider for a [OppiaLocale.ContentLocale] corresponding to the user's selected + * language for audio voiceovers (see [getAudioTranslationContentLanguage]). + */ + fun getAudioTranslationContentLocale( + profileId: ProfileId + ): DataProvider { + val providerId = AUDIO_TRANSLATION_CONTENT_LOCALE_DATA_PROVIDER_ID + return getSystemLanguage().transformAsync(providerId) { systemLanguage -> + val language = computeAudioTranslationContentLanguage(profileId, systemLanguage) + val audioTranslationLocale = localeController.retrieveAudioTranslationsLocale(language) + return@transformAsync audioTranslationLocale.retrieveData() + } + } + + /** + * Updates the language to be used by the specified user for audio voiceover selection. Note that + * the provided [AudioTranslationLanguageSelection] provides the user with the option of either + * selecting a specific supported language for audio voiceovers, or to fall back to whatever the + * app language selection is (which may be the system default). + * + * Note that the app guarantees a list of languages to support for audio voiceovers as a superset. + * The actual availability for a particular language is topic-dependent. + * + * @return a [DataProvider] which succeeds only if the update succeeds, otherwise fails (only one + * result is ever provided) + */ + fun updateAudioTranslationContentLanguage( + profileId: ProfileId, + selection: AudioTranslationLanguageSelection + ): DataProvider { + val providerId = UPDATE_AUDIO_TRANSLATION_CONTENT_DATA_PROVIDER_ID + return dataProviders.createInMemoryDataProviderAsync(providerId) { + updateAudioTranslationContentLanguageSelection(profileId, selection) + return@createInMemoryDataProviderAsync AsyncResult.success(Unit) + } + } + + /** + * Returns a potentially translated HTML string for the given [SubtitledHtml] to display to the + * user, considering the specified [WrittenTranslationContext]. + * + * Generally, this method will attempt to return the translated string corresponding to this + * [SubtitledHtml] given the translation context, but will fall back to the default string + * contents if no translation exists for the subtitle. + */ + fun extractString(html: SubtitledHtml, context: WrittenTranslationContext): String { + return context.translationsMap[html.contentId]?.html ?: html.html + } + + /** + * Returns a potentially translated Unicode string for the given [SubtitledUnicode] to display to + * the user, with the same considerations and behavior as the [extractString] ([SubtitledHtml] + * variant). + */ + fun extractString(unicode: SubtitledUnicode, context: WrittenTranslationContext): String { + return context.translationsMap[unicode.contentId]?.html ?: unicode.unicodeStr + } + + private fun computeAppLanguage( + profileId: ProfileId, + systemLanguage: OppiaLanguage + ): OppiaLanguage { + val languageSelection = retrieveAppLanguageSelection(profileId) + return when (languageSelection.selectionTypeCase) { + AppLanguageSelection.SelectionTypeCase.SELECTED_LANGUAGE -> languageSelection.selectedLanguage + AppLanguageSelection.SelectionTypeCase.USE_SYSTEM_LANGUAGE_OR_APP_DEFAULT, + AppLanguageSelection.SelectionTypeCase.SELECTIONTYPE_NOT_SET, null -> systemLanguage + } + } + + private fun computeWrittenTranslationContentLanguage( + profileId: ProfileId, + systemLanguage: OppiaLanguage + ): OppiaLanguage { + val languageSelection = retrieveWrittenTranslationContentLanguageSelection(profileId) + return when (languageSelection.selectionTypeCase) { + WrittenTranslationLanguageSelection.SelectionTypeCase.SELECTED_LANGUAGE -> + languageSelection.selectedLanguage + WrittenTranslationLanguageSelection.SelectionTypeCase.USE_APP_LANGUAGE, + WrittenTranslationLanguageSelection.SelectionTypeCase.SELECTIONTYPE_NOT_SET, null -> + computeAppLanguage(profileId, systemLanguage) + } + } + + private fun computeAudioTranslationContentLanguage( + profileId: ProfileId, + systemLanguage: OppiaLanguage + ): OppiaLanguage { + val languageSelection = retrieveAudioTranslationContentLanguageSelection(profileId) + return when (languageSelection.selectionTypeCase) { + AudioTranslationLanguageSelection.SelectionTypeCase.SELECTED_LANGUAGE -> + languageSelection.selectedLanguage + AudioTranslationLanguageSelection.SelectionTypeCase.USE_APP_LANGUAGE, + AudioTranslationLanguageSelection.SelectionTypeCase.SELECTIONTYPE_NOT_SET, null -> + computeAppLanguage(profileId, systemLanguage) + } + } + + private fun retrieveAppLanguageSelection(profileId: ProfileId): AppLanguageSelection { + return dataLock.withLock { + appLanguageSettings[profileId] ?: AppLanguageSelection.getDefaultInstance() + } + } + + private suspend fun updateAppLanguageSelection( + profileId: ProfileId, + selection: AppLanguageSelection + ) { + dataLock.withLock { + appLanguageSettings[profileId] = selection + } + asyncDataSubscriptionManager.notifyChange(APP_LANGUAGE_LOCALE_DATA_PROVIDER_ID) + } + + private fun retrieveWrittenTranslationContentLanguageSelection( + profileId: ProfileId + ): WrittenTranslationLanguageSelection { + return dataLock.withLock { + writtenTranslationLanguageSettings[profileId] + ?: WrittenTranslationLanguageSelection.getDefaultInstance() + } + } + + private suspend fun updateWrittenTranslationContentLanguageSelection( + profileId: ProfileId, + selection: WrittenTranslationLanguageSelection + ) { + dataLock.withLock { + writtenTranslationLanguageSettings[profileId] = selection + } + asyncDataSubscriptionManager.notifyChange(WRITTEN_TRANSLATION_CONTENT_LOCALE_DATA_PROVIDER_ID) + } + + private fun retrieveAudioTranslationContentLanguageSelection( + profileId: ProfileId + ): AudioTranslationLanguageSelection { + return dataLock.withLock { + audioVoiceoverLanguageSettings[profileId] + ?: AudioTranslationLanguageSelection.getDefaultInstance() + } + } + + private suspend fun updateAudioTranslationContentLanguageSelection( + profileId: ProfileId, + selection: AudioTranslationLanguageSelection + ) { + dataLock.withLock { + audioVoiceoverLanguageSettings[profileId] = selection + } + asyncDataSubscriptionManager.notifyChange(AUDIO_TRANSLATION_CONTENT_LOCALE_DATA_PROVIDER_ID) + } + + private fun getSystemLanguage(): DataProvider = + localeController.retrieveSystemLanguage() +} diff --git a/domain/src/main/java/org/oppia/android/domain/util/BUILD.bazel b/domain/src/main/java/org/oppia/android/domain/util/BUILD.bazel index eeebbe8a1ed..677fa6b98a6 100644 --- a/domain/src/main/java/org/oppia/android/domain/util/BUILD.bazel +++ b/domain/src/main/java/org/oppia/android/domain/util/BUILD.bazel @@ -12,7 +12,8 @@ kt_android_library( visibility = ["//domain:__subpackages__"], deps = [ "//third_party:javax_inject_javax_inject", - "//utility/src/main/java/org/oppia/android/util/caching:assets", + "//utility/src/main/java/org/oppia/android/util/caching:annotations", + "//utility/src/main/java/org/oppia/android/util/caching:asset_repository", ], ) diff --git a/domain/src/test/java/org/oppia/android/domain/audio/AudioPlayerControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/audio/AudioPlayerControllerTest.kt index 738c5ea7059..461a05d32f5 100644 --- a/domain/src/test/java/org/oppia/android/domain/audio/AudioPlayerControllerTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/audio/AudioPlayerControllerTest.kt @@ -32,6 +32,7 @@ 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.caching.AssetModule import org.oppia.android.util.caching.CacheAssetsLocally import org.oppia.android.util.data.AsyncResult import org.oppia.android.util.logging.EnableConsoleLog @@ -482,7 +483,7 @@ class AudioPlayerControllerTest { modules = [ TestModule::class, TestLogReportingModule::class, LogStorageModule::class, TestDispatcherModule::class, RobolectricModule::class, FakeOppiaClockModule::class, - NetworkConnectionUtilDebugModule::class + NetworkConnectionUtilDebugModule::class, AssetModule::class ] ) interface TestApplicationComponent { diff --git a/domain/src/test/java/org/oppia/android/domain/devoptions/ModifyLessonProgressControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/devoptions/ModifyLessonProgressControllerTest.kt index 3598bfc2210..cf57e2ba696 100644 --- a/domain/src/test/java/org/oppia/android/domain/devoptions/ModifyLessonProgressControllerTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/devoptions/ModifyLessonProgressControllerTest.kt @@ -33,6 +33,7 @@ import org.oppia.android.testing.threading.TestCoroutineDispatchers import org.oppia.android.testing.threading.TestDispatcherModule import org.oppia.android.testing.time.FakeOppiaClock import org.oppia.android.testing.time.FakeOppiaClockModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.CacheAssetsLocally import org.oppia.android.util.caching.LoadLessonProtosFromAssets import org.oppia.android.util.data.AsyncResult @@ -450,7 +451,7 @@ class ModifyLessonProgressControllerTest { modules = [ TestModule::class, TestLogReportingModule::class, LogStorageModule::class, TestDispatcherModule::class, RobolectricModule::class, FakeOppiaClockModule::class, - NetworkConnectionUtilDebugModule::class + NetworkConnectionUtilDebugModule::class, AssetModule::class ] ) interface TestApplicationComponent : DataProvidersInjector { diff --git a/domain/src/test/java/org/oppia/android/domain/exploration/ExplorationDataControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/exploration/ExplorationDataControllerTest.kt index c4fbb23fb35..084097b5958 100644 --- a/domain/src/test/java/org/oppia/android/domain/exploration/ExplorationDataControllerTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/exploration/ExplorationDataControllerTest.kt @@ -59,6 +59,7 @@ 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.caching.AssetModule import org.oppia.android.util.caching.CacheAssetsLocally import org.oppia.android.util.caching.LoadLessonProtosFromAssets import org.oppia.android.util.caching.TopicListToCache @@ -73,7 +74,6 @@ import org.oppia.android.util.logging.LogLevel import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule import org.robolectric.annotation.Config import org.robolectric.annotation.LooperMode -import java.io.FileNotFoundException import javax.inject.Inject import javax.inject.Singleton @@ -227,8 +227,8 @@ class ExplorationDataControllerTest { verify(mockExplorationObserver).onChanged(explorationResultCaptor.capture()) assertThat(explorationResultCaptor.value.isFailure()).isTrue() val exception = fakeExceptionLogger.getMostRecentException() - assertThat(exception).isInstanceOf(FileNotFoundException::class.java) - assertThat(exception).hasMessageThat().contains("NON_EXISTENT_TEST") + assertThat(exception).isInstanceOf(IllegalStateException::class.java) + assertThat(exception).hasMessageThat().contains("Asset doesn't exist: NON_EXISTENT_TEST") } @Test @@ -241,8 +241,8 @@ class ExplorationDataControllerTest { verify(mockExplorationObserver).onChanged(explorationResultCaptor.capture()) assertThat(explorationResultCaptor.value.isFailure()).isTrue() val exception = fakeExceptionLogger.getMostRecentException() - assertThat(exception).isInstanceOf(FileNotFoundException::class.java) - assertThat(exception).hasMessageThat().contains("NON_EXISTENT_TEST") + assertThat(exception).isInstanceOf(IllegalStateException::class.java) + assertThat(exception).hasMessageThat().contains("Asset doesn't exist: NON_EXISTENT_TEST") } @Test @@ -349,7 +349,7 @@ class ExplorationDataControllerTest { ImageClickInputModule::class, LogStorageModule::class, TestDispatcherModule::class, RatioInputModule::class, RobolectricModule::class, FakeOppiaClockModule::class, TestExplorationStorageModule::class, HintsAndSolutionConfigModule::class, - HintsAndSolutionProdModule::class, NetworkConnectionUtilDebugModule::class + HintsAndSolutionProdModule::class, NetworkConnectionUtilDebugModule::class, AssetModule::class ] ) interface TestApplicationComponent : DataProvidersInjector { diff --git a/domain/src/test/java/org/oppia/android/domain/exploration/ExplorationProgressControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/exploration/ExplorationProgressControllerTest.kt index 1f10228c6d6..e067fe285b6 100644 --- a/domain/src/test/java/org/oppia/android/domain/exploration/ExplorationProgressControllerTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/exploration/ExplorationProgressControllerTest.kt @@ -77,6 +77,7 @@ import org.oppia.android.testing.threading.TestCoroutineDispatchers import org.oppia.android.testing.threading.TestDispatcherModule import org.oppia.android.testing.time.FakeOppiaClock import org.oppia.android.testing.time.FakeOppiaClockModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.CacheAssetsLocally import org.oppia.android.util.caching.LoadLessonProtosFromAssets import org.oppia.android.util.caching.TopicListToCache @@ -91,7 +92,6 @@ import org.oppia.android.util.logging.LogLevel import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule import org.robolectric.annotation.Config import org.robolectric.annotation.LooperMode -import java.io.FileNotFoundException import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton @@ -2068,8 +2068,8 @@ class ExplorationProgressControllerTest { ) val exception = fakeExceptionLogger.getMostRecentException() - assertThat(exception).isInstanceOf(FileNotFoundException::class.java) - assertThat(exception).hasMessageThat().contains(INVALID_EXPLORATION_ID) + assertThat(exception).isInstanceOf(IllegalStateException::class.java) + assertThat(exception).hasMessageThat().contains("Asset doesn't exist: $INVALID_EXPLORATION_ID") } @Test @@ -3843,7 +3843,7 @@ class ExplorationProgressControllerTest { ImageClickInputModule::class, LogStorageModule::class, TestDispatcherModule::class, RatioInputModule::class, RobolectricModule::class, FakeOppiaClockModule::class, TestExplorationStorageModule::class, HintsAndSolutionConfigModule::class, - HintsAndSolutionProdModule::class, NetworkConnectionUtilDebugModule::class + HintsAndSolutionProdModule::class, NetworkConnectionUtilDebugModule::class, AssetModule::class ] ) interface TestApplicationComponent : DataProvidersInjector { diff --git a/domain/src/test/java/org/oppia/android/domain/exploration/lightweightcheckpointing/ExplorationCheckpointControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/exploration/lightweightcheckpointing/ExplorationCheckpointControllerTest.kt index 387975d0071..93e6ea7cbc4 100644 --- a/domain/src/test/java/org/oppia/android/domain/exploration/lightweightcheckpointing/ExplorationCheckpointControllerTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/exploration/lightweightcheckpointing/ExplorationCheckpointControllerTest.kt @@ -41,6 +41,7 @@ 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.FakeOppiaClock +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.CacheAssetsLocally import org.oppia.android.util.caching.LoadLessonProtosFromAssets import org.oppia.android.util.caching.TopicListToCache @@ -527,7 +528,7 @@ class ExplorationCheckpointControllerTest { modules = [ TestModule::class, TestLogReportingModule::class, TestExplorationStorageModule::class, TestDispatcherModule::class, RobolectricModule::class, - LogStorageModule::class, NetworkConnectionUtilDebugModule::class + LogStorageModule::class, NetworkConnectionUtilDebugModule::class, AssetModule::class ] ) interface TestApplicationComponent : DataProvidersInjector { diff --git a/domain/src/test/java/org/oppia/android/domain/exploration/lightweightcheckpointing/ExplorationStorageModuleTest.kt b/domain/src/test/java/org/oppia/android/domain/exploration/lightweightcheckpointing/ExplorationStorageModuleTest.kt index 51e98fdb3de..e195d5e2caf 100644 --- a/domain/src/test/java/org/oppia/android/domain/exploration/lightweightcheckpointing/ExplorationStorageModuleTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/exploration/lightweightcheckpointing/ExplorationStorageModuleTest.kt @@ -21,6 +21,7 @@ import org.oppia.android.testing.environment.TestEnvironmentConfig 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.util.caching.AssetModule import org.oppia.android.util.caching.CacheAssetsLocally import org.oppia.android.util.caching.LoadLessonProtosFromAssets import org.oppia.android.util.data.DataProvidersInjector @@ -108,7 +109,7 @@ class ExplorationStorageModuleTest { modules = [ TestModule::class, TestLogReportingModule::class, ExplorationStorageModule::class, TestDispatcherModule::class, RobolectricModule::class, - LogStorageModule::class, NetworkConnectionUtilDebugModule::class + LogStorageModule::class, NetworkConnectionUtilDebugModule::class, AssetModule::class ] ) interface TestApplicationComponent : DataProvidersInjector { diff --git a/domain/src/test/java/org/oppia/android/domain/hintsandsolution/HintHandlerDebugImplTest.kt b/domain/src/test/java/org/oppia/android/domain/hintsandsolution/HintHandlerDebugImplTest.kt index 26a6abd5a76..1964751248c 100644 --- a/domain/src/test/java/org/oppia/android/domain/hintsandsolution/HintHandlerDebugImplTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/hintsandsolution/HintHandlerDebugImplTest.kt @@ -30,6 +30,7 @@ import org.oppia.android.testing.environment.TestEnvironmentConfig 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.util.caching.AssetModule import org.oppia.android.util.caching.LoadLessonProtosFromAssets import org.oppia.android.util.data.DataProvidersInjector import org.oppia.android.util.data.DataProvidersInjectorProvider @@ -376,7 +377,7 @@ class HintHandlerDebugImplTest { modules = [ TestModule::class, HintsAndSolutionDebugModule::class, HintsAndSolutionConfigModule::class, TestLogReportingModule::class, TestDispatcherModule::class, RobolectricModule::class, - LoggerModule::class + LoggerModule::class, AssetModule::class ] ) interface TestApplicationComponent : DataProvidersInjector { diff --git a/domain/src/test/java/org/oppia/android/domain/hintsandsolution/HintHandlerProdImplTest.kt b/domain/src/test/java/org/oppia/android/domain/hintsandsolution/HintHandlerProdImplTest.kt index d75479d56b6..6340c2cf4eb 100644 --- a/domain/src/test/java/org/oppia/android/domain/hintsandsolution/HintHandlerProdImplTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/hintsandsolution/HintHandlerProdImplTest.kt @@ -30,6 +30,7 @@ import org.oppia.android.testing.environment.TestEnvironmentConfig 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.util.caching.AssetModule import org.oppia.android.util.caching.LoadLessonProtosFromAssets import org.oppia.android.util.data.DataProvidersInjector import org.oppia.android.util.data.DataProvidersInjectorProvider @@ -1970,7 +1971,7 @@ class HintHandlerProdImplTest { modules = [ TestModule::class, HintsAndSolutionProdModule::class, HintsAndSolutionConfigModule::class, TestLogReportingModule::class, TestDispatcherModule::class, RobolectricModule::class, - LoggerModule::class + LoggerModule::class, AssetModule::class ] ) interface TestApplicationComponent : DataProvidersInjector { diff --git a/domain/src/test/java/org/oppia/android/domain/locale/AndroidLocaleFactoryTest.kt b/domain/src/test/java/org/oppia/android/domain/locale/AndroidLocaleFactoryTest.kt new file mode 100644 index 00000000000..99d29c39e61 --- /dev/null +++ b/domain/src/test/java/org/oppia/android/domain/locale/AndroidLocaleFactoryTest.kt @@ -0,0 +1,1629 @@ +package org.oppia.android.domain.locale + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import dagger.BindsInstance +import dagger.Component +import dagger.Module +import dagger.Provides +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.oppia.android.app.model.LanguageSupportDefinition +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 +import org.oppia.android.app.model.OppiaLanguage +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.time.FakeOppiaClockModule +import org.oppia.android.util.locale.LocaleProdModule +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Tests for [AndroidLocaleFactory]. + * + * Note that these tests depend on real locales being present in the local environment + * (Robolectric). + */ +// FunctionName: test names are conventionally named with underscores. +// SameParameterValue: tests should have specific context included/excluded for readability. +@Suppress("FunctionName", "SameParameterValue") +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +@Config(manifest = Config.NONE) +class AndroidLocaleFactoryTest { + @Inject + lateinit var androidLocaleFactory: AndroidLocaleFactory + + @Before + fun setUp() { + setUpTestApplicationComponent() + } + + @Test + fun testCreateLocale_default_throwsException() { + val exception = assertThrows(IllegalStateException::class) { + androidLocaleFactory.createAndroidLocale(OppiaLocaleContext.getDefaultInstance()) + } + + // The operation should fail since there's no language type defined. + assertThat(exception).hasMessageThat().contains("Invalid language case") + } + + /* Tests for app strings. */ + + @Test + fun testCreateLocale_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.createAndroidLocale(context) + + // The context should be matched to a valid locale. + assertThat(locale.language).isEqualTo("pt") + assertThat(locale.country).isEqualTo("BR") + } + + @Test + fun testCreateLocale_appStrings_withIetfId_compatible_returnsIetfLocale() { + val context = + createAppStringsContext( + language = OppiaLanguage.BRAZILIAN_PORTUGUESE, + appStringId = createLanguageId(ietfBcp47LanguageId = PT_IETF_LANGUAGE_ID), + regionDefinition = REGION_BRAZIL + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // The context should be matched to a valid locale. + assertThat(locale.language).isEqualTo("pt") + assertThat(locale.country).isEqualTo("BR") + } + + @Test + fun testCreateLocale_appStrings_withRegionIetfId_compatible_returnsIetfLocale() { + val context = + createAppStringsContext( + language = OppiaLanguage.BRAZILIAN_PORTUGUESE, + appStringId = createLanguageId(ietfBcp47LanguageId = PT_BR_IETF_LANGUAGE_ID), + regionDefinition = REGION_US + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // The context should be matched to a valid locale. Note that BR is matched since the IETF + // language tag includes the region. + assertThat(locale.language).isEqualTo("pt") + assertThat(locale.country).isEqualTo("BR") + } + + @Test + fun testCreateLocale_appStrings_withMacaronic_compatible_returnsMacaronicLocale() { + val context = + createAppStringsContext( + language = OppiaLanguage.BRAZILIAN_PORTUGUESE, + appStringId = createLanguageId(macaronicLanguageId = PT_BR_MACARONIC_LANGUAGE_ID), + regionDefinition = REGION_BRAZIL + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // The context should be matched to a valid locale. + assertThat(locale.language).isEqualTo("pt") + assertThat(locale.country).isEqualTo("BR") + } + + @Test + fun testCreateLocale_appStrings_androidIdAndIetf_returnsAndroidIdLocale() { + val context = + createAppStringsContext( + language = OppiaLanguage.ENGLISH, + appStringId = createLanguageId( + androidLanguageId = EN_ANDROID_LANGUAGE_ID, + ietfBcp47LanguageId = PT_IETF_LANGUAGE_ID, + ), + regionDefinition = REGION_BRAZIL + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // The Android is preferred when both are present. Note no region is provided since the Android + // language is missing a region definition. + assertThat(locale.language).isEqualTo("en") + assertThat(locale.country).isEmpty() + } + + @Test + fun testCreateLocale_appStrings_androidIdAndMacaronic_returnsAndroidIdLocale() { + val context = + createAppStringsContext( + language = OppiaLanguage.ENGLISH, + appStringId = createLanguageId( + androidLanguageId = EN_ANDROID_LANGUAGE_ID, + macaronicLanguageId = PT_BR_MACARONIC_LANGUAGE_ID + ), + regionDefinition = REGION_BRAZIL + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // The Android is preferred when both are present. Note no region is provided since the Android + // language is missing a region definition. + assertThat(locale.language).isEqualTo("en") + assertThat(locale.country).isEmpty() + } + + @Test + fun testCreateLocale_appStrings_withAndroidId_incompatible_returnsFallbackAndroidIdLocale() { + val context = + createAppStringsContext( + language = OppiaLanguage.LANGUAGE_UNSPECIFIED, + appStringId = createLanguageId(androidLanguageId = QQ_ANDROID_LANGUAGE_ID), + fallbackAppStringId = createLanguageId(androidLanguageId = PT_BR_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_BRAZIL + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // pt-BR should be picked because the primary language doesn't match a real locale. + assertThat(locale.language).isEqualTo("pt") + assertThat(locale.country).isEqualTo("BR") + } + + @Test + fun testCreateLocale_appStrings_withIetfId_incompatible_returnsFallbackAndroidIdLocale() { + val context = + createAppStringsContext( + language = OppiaLanguage.LANGUAGE_UNSPECIFIED, + appStringId = createLanguageId(ietfBcp47LanguageId = QQ_ZZ_IETF_LANGUAGE_ID), + fallbackAppStringId = createLanguageId(androidLanguageId = PT_BR_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_BRAZIL + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // pt-BR should be picked because the primary language doesn't match a real locale. + assertThat(locale.language).isEqualTo("pt") + assertThat(locale.country).isEqualTo("BR") + } + + @Test + fun testCreateLocale_appStrings_withRegionIetfId_incompat_returnsFallbackAndroidIdLocale() { + val context = + createAppStringsContext( + language = OppiaLanguage.BRAZILIAN_PORTUGUESE, + appStringId = createLanguageId(ietfBcp47LanguageId = PT_ZZ_IETF_LANGUAGE_ID), + fallbackAppStringId = createLanguageId(androidLanguageId = PT_BR_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_BRAZIL + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // pt-BR should be picked because the primary language's region doesn't match a real locale. + assertThat(locale.language).isEqualTo("pt") + assertThat(locale.country).isEqualTo("BR") + } + + @Test + fun testCreateLocale_appStrings_withIetfId_incompRegion_returnsFallbackAndroidIdLocale() { + val context = + createAppStringsContext( + language = OppiaLanguage.BRAZILIAN_PORTUGUESE, + appStringId = createLanguageId(ietfBcp47LanguageId = PT_IETF_LANGUAGE_ID), + fallbackAppStringId = createLanguageId(androidLanguageId = PT_BR_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_ZZ + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // pt-BR should be picked because the supplied region doesn't match a real locale. + assertThat(locale.language).isEqualTo("pt") + assertThat(locale.country).isEqualTo("BR") + } + + @Test + fun testCreateLocale_appStrings_withMacaronicId_incompat_returnsFallbackAndroidIdLocale() { + val context = + createAppStringsContext( + language = OppiaLanguage.LANGUAGE_UNSPECIFIED, + appStringId = createLanguageId(macaronicLanguageId = QQ_ZZ_MACARONIC_LANGUAGE_ID), + fallbackAppStringId = createLanguageId(androidLanguageId = PT_BR_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_BRAZIL + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // pt-BR should be picked because the primary language doesn't match a real locale. + assertThat(locale.language).isEqualTo("pt") + assertThat(locale.country).isEqualTo("BR") + } + + @Test + fun testCreateLocale_appStrings_withMacaronicId_invalid_returnsFallbackAndroidIdLocale() { + val context = + createAppStringsContext( + language = OppiaLanguage.LANGUAGE_UNSPECIFIED, + appStringId = createLanguageId(macaronicLanguageId = INVALID_MACARONIC_LANGUAGE_ID), + fallbackAppStringId = createLanguageId(androidLanguageId = PT_BR_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_BRAZIL + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // pt-BR should be picked because the primary language is invalid. + assertThat(locale.language).isEqualTo("pt") + assertThat(locale.country).isEqualTo("BR") + } + + @Test + fun testCreateLocale_appStrings_withSdkIncompat_returnsFallbackAndroidIdLocale() { + val context = + createAppStringsContext( + language = OppiaLanguage.ENGLISH, + appStringId = createLanguageId(androidLanguageId = EN_ANDROID_LANGUAGE_ID), + primaryMinSdkVersion = 99, + fallbackAppStringId = createLanguageId(androidLanguageId = PT_BR_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_BRAZIL + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // pt-BR should be picked because the primary language isn't compatible with the current SDK. + assertThat(locale.language).isEqualTo("pt") + assertThat(locale.country).isEqualTo("BR") + } + + @Test + fun testCreateLocale_appStrings_incompat_androidAndIetfFallback_returnsAndroidIdLocale() { + val context = + createAppStringsContext( + language = OppiaLanguage.LANGUAGE_UNSPECIFIED, + appStringId = createLanguageId(androidLanguageId = QQ_ANDROID_LANGUAGE_ID), + fallbackAppStringId = createLanguageId( + androidLanguageId = PT_BR_ANDROID_LANGUAGE_ID, + ietfBcp47LanguageId = HI_IETF_LANGUAGE_ID + ), + regionDefinition = REGION_INDIA + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // pt-BR should be picked since Android IDs take precedence among multiple fallback options. + assertThat(locale.language).isEqualTo("pt") + assertThat(locale.country).isEqualTo("BR") + } + + @Test + fun testCreateLocale_appStrings_incompat_androidMacaronicFallbacks_returnsAndroidIdLocale() { + val context = + createAppStringsContext( + language = OppiaLanguage.LANGUAGE_UNSPECIFIED, + appStringId = createLanguageId(androidLanguageId = QQ_ANDROID_LANGUAGE_ID), + fallbackAppStringId = createLanguageId( + androidLanguageId = PT_BR_ANDROID_LANGUAGE_ID, + macaronicLanguageId = HI_IN_MACARONIC_LANGUAGE_ID + ), + regionDefinition = REGION_INDIA + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // pt-BR should be picked since Android IDs take precedence among multiple fallback options. + assertThat(locale.language).isEqualTo("pt") + assertThat(locale.country).isEqualTo("BR") + } + + @Test + fun testCreateLocale_appStrings_androidId_allIncompat_returnsForcedAndroidIdLocale() { + val context = + createAppStringsContext( + language = OppiaLanguage.LANGUAGE_UNSPECIFIED, + appStringId = createLanguageId(androidLanguageId = QQ_ANDROID_LANGUAGE_ID), + fallbackAppStringId = createLanguageId(androidLanguageId = QQ_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_INDIA + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // 'qq' is the exact locale being requested. + assertThat(locale.language).isEqualTo("qq") + assertThat(locale.country).isEmpty() + } + + @Test + fun testCreateLocale_appStrings_androidId_ietf_allIncompat_returnsForcedAndroidIdLocale() { + val context = + createAppStringsContext( + language = OppiaLanguage.LANGUAGE_UNSPECIFIED, + appStringId = createLanguageId( + androidLanguageId = QQ_ANDROID_LANGUAGE_ID, + ietfBcp47LanguageId = QQ_IETF_LANGUAGE_ID + ), + fallbackAppStringId = createLanguageId(androidLanguageId = QQ_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_INDIA + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // 'qq' takes precedence over the IETF language since Android IDs are picked first when + // creating a forced locale. + assertThat(locale.language).isEqualTo("qq") + assertThat(locale.country).isEmpty() + } + + @Test + fun testCreateLocale_appStrings_androidId_mac_allIncompat_returnsForcedAndroidIdLocale() { + val context = + createAppStringsContext( + language = OppiaLanguage.LANGUAGE_UNSPECIFIED, + appStringId = createLanguageId( + androidLanguageId = QQ_ANDROID_LANGUAGE_ID, + macaronicLanguageId = HI_EN_MACARONIC_LANGUAGE_ID + ), + fallbackAppStringId = createLanguageId(androidLanguageId = QQ_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_INDIA + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // 'qq' takes precedence over the macaronic language since Android IDs are picked first when + // creating a forced locale. + assertThat(locale.language).isEqualTo("qq") + assertThat(locale.country).isEmpty() + } + + @Test + fun testCreateLocale_appStrings_ietf_allIncompat_returnsForcedIetfLocale() { + val context = + createAppStringsContext( + language = OppiaLanguage.LANGUAGE_UNSPECIFIED, + appStringId = createLanguageId(ietfBcp47LanguageId = QQ_IETF_LANGUAGE_ID), + fallbackAppStringId = createLanguageId(androidLanguageId = QQ_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_INDIA + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // The IETF language ID is used for the forced locale (note that fallback languages are ignored + // when computing the forced locale). + assertThat(locale.language).isEqualTo("qq") + assertThat(locale.country).isEqualTo("IN") + } + + @Test + fun testCreateLocale_appStrings_mac_allIncompat_returnsForcedMacaronicLocale() { + val context = + createAppStringsContext( + language = OppiaLanguage.LANGUAGE_UNSPECIFIED, + appStringId = createLanguageId(macaronicLanguageId = HI_EN_MACARONIC_LANGUAGE_ID), + fallbackAppStringId = createLanguageId(androidLanguageId = QQ_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_INDIA + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // The Hinglish macaronic language ID is used for the forced locale (note that fallback + // languages are ignored when computing the forced locale). + assertThat(locale.language).isEqualTo("hi") + assertThat(locale.country).isEqualTo("EN") + } + + @Test + fun testCreateLocale_appStrings_mac_allIncompat_invalidMac_throwsException() { + val context = + createAppStringsContext( + language = OppiaLanguage.ENGLISH, + appStringId = createLanguageId(macaronicLanguageId = INVALID_MACARONIC_LANGUAGE_ID), + fallbackAppStringId = createLanguageId(androidLanguageId = QQ_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_INDIA + ) + + val exception = assertThrows(IllegalStateException::class) { + androidLocaleFactory.createAndroidLocale(context) + } + + assertThat(exception).hasMessageThat().contains("Invalid macaronic ID") + } + + @Test + fun testCreateLocale_appStrings_primaryAndFallbackSdkIncompat_returnsForcedLocale() { + val context = + createAppStringsContext( + language = OppiaLanguage.LANGUAGE_UNSPECIFIED, + appStringId = createLanguageId(androidLanguageId = QQ_ANDROID_LANGUAGE_ID), + primaryMinSdkVersion = 99, + fallbackAppStringId = createLanguageId(androidLanguageId = EN_ANDROID_LANGUAGE_ID), + fallbackMinSdkVersion = 99, + regionDefinition = REGION_INDIA + ) + + val locale = androidLocaleFactory.createAndroidLocale(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). + assertThat(locale.language).isEqualTo("qq") + assertThat(locale.country).isEmpty() + } + + @Test + fun testCreateLocale_appStrings_forMacroAndroidIdLanguage_returnsLocale() { + val context = + createAppStringsContext( + language = OppiaLanguage.ENGLISH, + appStringId = createLanguageId(androidLanguageId = EN_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_INDIA + ) + + val locale = androidLocaleFactory.createAndroidLocale(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). + assertThat(locale.language).isEqualTo("en") + assertThat(locale.country).isEmpty() + } + + @Test + fun testCreateLocale_appStrings_allIncompat_invalidLangType_throwsException() { + val context = + createAppStringsContext( + language = OppiaLanguage.ENGLISH, + appStringId = LanguageId.getDefaultInstance(), + regionDefinition = REGION_INDIA + ) + + val exception = assertThrows(IllegalStateException::class) { + androidLocaleFactory.createAndroidLocale(context) + } + + assertThat(exception).hasMessageThat().contains("Invalid language case") + } + + /* Tests for written content strings. */ + + @Test + fun testCreateLocale_contentStrings_withAndroidId_compatible_returnsAndroidIdLocale() { + val context = + createContentStringsContext( + language = OppiaLanguage.BRAZILIAN_PORTUGUESE, + contentStringId = createLanguageId(androidLanguageId = PT_BR_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_BRAZIL + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // The context should be matched to a valid locale. + assertThat(locale.language).isEqualTo("pt") + assertThat(locale.country).isEqualTo("BR") + } + + @Test + fun testCreateLocale_contentStrings_withIetfId_compatible_returnsIetfLocale() { + val context = + createContentStringsContext( + language = OppiaLanguage.BRAZILIAN_PORTUGUESE, + contentStringId = createLanguageId(ietfBcp47LanguageId = PT_IETF_LANGUAGE_ID), + regionDefinition = REGION_BRAZIL + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // The context should be matched to a valid locale. + assertThat(locale.language).isEqualTo("pt") + assertThat(locale.country).isEqualTo("BR") + } + + @Test + fun testCreateLocale_contentStrings_withRegionIetfId_compatible_returnsIetfLocale() { + val context = + createContentStringsContext( + language = OppiaLanguage.BRAZILIAN_PORTUGUESE, + contentStringId = createLanguageId(ietfBcp47LanguageId = PT_BR_IETF_LANGUAGE_ID), + regionDefinition = REGION_US + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // The context should be matched to a valid locale. Note that BR is matched since the IETF + // language tag includes the region. + assertThat(locale.language).isEqualTo("pt") + assertThat(locale.country).isEqualTo("BR") + } + + @Test + fun testCreateLocale_contentStrings_withMacaronic_compatible_returnsMacaronicLocale() { + val context = + createContentStringsContext( + language = OppiaLanguage.BRAZILIAN_PORTUGUESE, + contentStringId = createLanguageId(macaronicLanguageId = PT_BR_MACARONIC_LANGUAGE_ID), + regionDefinition = REGION_BRAZIL + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // The context should be matched to a valid locale. + assertThat(locale.language).isEqualTo("pt") + assertThat(locale.country).isEqualTo("BR") + } + + @Test + fun testCreateLocale_contentStrings_androidIdAndIetf_returnsAndroidIdLocale() { + val context = + createContentStringsContext( + language = OppiaLanguage.ENGLISH, + contentStringId = createLanguageId( + androidLanguageId = EN_ANDROID_LANGUAGE_ID, + ietfBcp47LanguageId = PT_IETF_LANGUAGE_ID, + ), + regionDefinition = REGION_BRAZIL + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // The Android is preferred when both are present. Note no region is provided since the Android + // language is missing a region definition. + assertThat(locale.language).isEqualTo("en") + assertThat(locale.country).isEmpty() + } + + @Test + fun testCreateLocale_contentStrings_androidIdAndMacaronic_returnsAndroidIdLocale() { + val context = + createContentStringsContext( + language = OppiaLanguage.ENGLISH, + contentStringId = createLanguageId( + androidLanguageId = EN_ANDROID_LANGUAGE_ID, + macaronicLanguageId = PT_BR_MACARONIC_LANGUAGE_ID + ), + regionDefinition = REGION_BRAZIL + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // The Android is preferred when both are present. Note no region is provided since the Android + // language is missing a region definition. + assertThat(locale.language).isEqualTo("en") + assertThat(locale.country).isEmpty() + } + + @Test + fun testCreateLocale_contentStrings_withAndroidId_incompatible_returnsFallbackAndroidIdLocale() { + val context = + createContentStringsContext( + language = OppiaLanguage.LANGUAGE_UNSPECIFIED, + contentStringId = createLanguageId(androidLanguageId = QQ_ANDROID_LANGUAGE_ID), + fallbackContentStringId = createLanguageId(androidLanguageId = PT_BR_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_BRAZIL + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // pt-BR should be picked because the primary language doesn't match a real locale. + assertThat(locale.language).isEqualTo("pt") + assertThat(locale.country).isEqualTo("BR") + } + + @Test + fun testCreateLocale_contentStrings_withIetfId_incompatible_returnsFallbackAndroidIdLocale() { + val context = + createContentStringsContext( + language = OppiaLanguage.LANGUAGE_UNSPECIFIED, + contentStringId = createLanguageId(ietfBcp47LanguageId = QQ_ZZ_IETF_LANGUAGE_ID), + fallbackContentStringId = createLanguageId(androidLanguageId = PT_BR_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_BRAZIL + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // pt-BR should be picked because the primary language doesn't match a real locale. + assertThat(locale.language).isEqualTo("pt") + assertThat(locale.country).isEqualTo("BR") + } + + @Test + fun testCreateLocale_contentStrings_withRegionIetfId_incompat_returnsFallbackAndroidIdLocale() { + val context = + createContentStringsContext( + language = OppiaLanguage.BRAZILIAN_PORTUGUESE, + contentStringId = createLanguageId(ietfBcp47LanguageId = PT_ZZ_IETF_LANGUAGE_ID), + fallbackContentStringId = createLanguageId(androidLanguageId = PT_BR_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_BRAZIL + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // pt-BR should be picked because the primary language's region doesn't match a real locale. + assertThat(locale.language).isEqualTo("pt") + assertThat(locale.country).isEqualTo("BR") + } + + @Test + fun testCreateLocale_contentStrings_withIetfId_incompRegion_returnsFallbackAndroidIdLocale() { + val context = + createContentStringsContext( + language = OppiaLanguage.BRAZILIAN_PORTUGUESE, + contentStringId = createLanguageId(ietfBcp47LanguageId = PT_IETF_LANGUAGE_ID), + fallbackContentStringId = createLanguageId(androidLanguageId = PT_BR_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_ZZ + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // pt-BR should be picked because the supplied region doesn't match a real locale. + assertThat(locale.language).isEqualTo("pt") + assertThat(locale.country).isEqualTo("BR") + } + + @Test + fun testCreateLocale_contentStrings_withMacaronicId_incompat_returnsFallbackAndroidIdLocale() { + val context = + createContentStringsContext( + language = OppiaLanguage.LANGUAGE_UNSPECIFIED, + contentStringId = createLanguageId(macaronicLanguageId = QQ_ZZ_MACARONIC_LANGUAGE_ID), + fallbackContentStringId = createLanguageId(androidLanguageId = PT_BR_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_BRAZIL + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // pt-BR should be picked because the primary language doesn't match a real locale. + assertThat(locale.language).isEqualTo("pt") + assertThat(locale.country).isEqualTo("BR") + } + + @Test + fun testCreateLocale_contentStrings_withMacaronicId_invalid_returnsFallbackAndroidIdLocale() { + val context = + createContentStringsContext( + language = OppiaLanguage.LANGUAGE_UNSPECIFIED, + contentStringId = createLanguageId(macaronicLanguageId = INVALID_MACARONIC_LANGUAGE_ID), + fallbackContentStringId = createLanguageId(androidLanguageId = PT_BR_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_BRAZIL + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // pt-BR should be picked because the primary language is invalid. + assertThat(locale.language).isEqualTo("pt") + assertThat(locale.country).isEqualTo("BR") + } + + @Test + fun testCreateLocale_contentStrings_withSdkIncompat_returnsFallbackAndroidIdLocale() { + val context = + createContentStringsContext( + language = OppiaLanguage.ENGLISH, + contentStringId = createLanguageId(androidLanguageId = EN_ANDROID_LANGUAGE_ID), + primaryMinSdkVersion = 99, + fallbackContentStringId = createLanguageId(androidLanguageId = PT_BR_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_BRAZIL + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // pt-BR should be picked because the primary language isn't compatible with the current SDK. + assertThat(locale.language).isEqualTo("pt") + assertThat(locale.country).isEqualTo("BR") + } + + @Test + fun testCreateLocale_contentStrings_incompat_androidAndIetfFallback_returnsAndroidIdLocale() { + val context = + createContentStringsContext( + language = OppiaLanguage.LANGUAGE_UNSPECIFIED, + contentStringId = createLanguageId(androidLanguageId = QQ_ANDROID_LANGUAGE_ID), + fallbackContentStringId = createLanguageId( + androidLanguageId = PT_BR_ANDROID_LANGUAGE_ID, + ietfBcp47LanguageId = HI_IETF_LANGUAGE_ID + ), + regionDefinition = REGION_INDIA + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // pt-BR should be picked since Android IDs take precedence among multiple fallback options. + assertThat(locale.language).isEqualTo("pt") + assertThat(locale.country).isEqualTo("BR") + } + + @Test + fun testCreateLocale_contentStrings_incompat_androidMacaronicFallbacks_returnsAndroidIdLocale() { + val context = + createContentStringsContext( + language = OppiaLanguage.LANGUAGE_UNSPECIFIED, + contentStringId = createLanguageId(androidLanguageId = QQ_ANDROID_LANGUAGE_ID), + fallbackContentStringId = createLanguageId( + androidLanguageId = PT_BR_ANDROID_LANGUAGE_ID, + macaronicLanguageId = HI_IN_MACARONIC_LANGUAGE_ID + ), + regionDefinition = REGION_INDIA + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // pt-BR should be picked since Android IDs take precedence among multiple fallback options. + assertThat(locale.language).isEqualTo("pt") + assertThat(locale.country).isEqualTo("BR") + } + + @Test + fun testCreateLocale_contentStrings_androidId_allIncompat_returnsForcedAndroidIdLocale() { + val context = + createContentStringsContext( + language = OppiaLanguage.LANGUAGE_UNSPECIFIED, + contentStringId = createLanguageId(androidLanguageId = QQ_ANDROID_LANGUAGE_ID), + fallbackContentStringId = createLanguageId(androidLanguageId = QQ_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_INDIA + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // 'qq' is the exact locale being requested. + assertThat(locale.language).isEqualTo("qq") + assertThat(locale.country).isEmpty() + } + + @Test + fun testCreateLocale_contentStrings_androidId_ietf_allIncompat_returnsForcedAndroidIdLocale() { + val context = + createContentStringsContext( + language = OppiaLanguage.LANGUAGE_UNSPECIFIED, + contentStringId = createLanguageId( + androidLanguageId = QQ_ANDROID_LANGUAGE_ID, + ietfBcp47LanguageId = QQ_IETF_LANGUAGE_ID + ), + fallbackContentStringId = createLanguageId(androidLanguageId = QQ_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_INDIA + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // 'qq' takes precedence over the IETF language since Android IDs are picked first when + // creating a forced locale. + assertThat(locale.language).isEqualTo("qq") + assertThat(locale.country).isEmpty() + } + + @Test + fun testCreateLocale_contentStrings_androidId_mac_allIncompat_returnsForcedAndroidIdLocale() { + val context = + createContentStringsContext( + language = OppiaLanguage.LANGUAGE_UNSPECIFIED, + contentStringId = createLanguageId( + androidLanguageId = QQ_ANDROID_LANGUAGE_ID, + macaronicLanguageId = HI_EN_MACARONIC_LANGUAGE_ID + ), + fallbackContentStringId = createLanguageId(androidLanguageId = QQ_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_INDIA + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // 'qq' takes precedence over the macaronic language since Android IDs are picked first when + // creating a forced locale. + assertThat(locale.language).isEqualTo("qq") + assertThat(locale.country).isEmpty() + } + + @Test + fun testCreateLocale_contentStrings_ietf_allIncompat_returnsForcedIetfLocale() { + val context = + createContentStringsContext( + language = OppiaLanguage.LANGUAGE_UNSPECIFIED, + contentStringId = createLanguageId(ietfBcp47LanguageId = QQ_IETF_LANGUAGE_ID), + fallbackContentStringId = createLanguageId(androidLanguageId = QQ_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_INDIA + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // The IETF language ID is used for the forced locale (note that fallback languages are ignored + // when computing the forced locale). + assertThat(locale.language).isEqualTo("qq") + assertThat(locale.country).isEqualTo("IN") + } + + @Test + fun testCreateLocale_contentStrings_mac_allIncompat_returnsForcedMacaronicLocale() { + val context = + createContentStringsContext( + language = OppiaLanguage.LANGUAGE_UNSPECIFIED, + contentStringId = createLanguageId(macaronicLanguageId = HI_EN_MACARONIC_LANGUAGE_ID), + fallbackContentStringId = createLanguageId(androidLanguageId = QQ_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_INDIA + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // The Hinglish macaronic language ID is used for the forced locale (note that fallback + // languages are ignored when computing the forced locale). + assertThat(locale.language).isEqualTo("hi") + assertThat(locale.country).isEqualTo("EN") + } + + @Test + fun testCreateLocale_contentStrings_mac_allIncompat_invalidMac_throwsException() { + val context = + createContentStringsContext( + language = OppiaLanguage.ENGLISH, + contentStringId = createLanguageId(macaronicLanguageId = INVALID_MACARONIC_LANGUAGE_ID), + fallbackContentStringId = createLanguageId(androidLanguageId = QQ_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_INDIA + ) + + val exception = assertThrows(IllegalStateException::class) { + androidLocaleFactory.createAndroidLocale(context) + } + + assertThat(exception).hasMessageThat().contains("Invalid macaronic ID") + } + + @Test + fun testCreateLocale_contentStrings_primaryAndFallbackSdkIncompat_returnsForcedLocale() { + val context = + createContentStringsContext( + language = OppiaLanguage.LANGUAGE_UNSPECIFIED, + contentStringId = createLanguageId(androidLanguageId = QQ_ANDROID_LANGUAGE_ID), + primaryMinSdkVersion = 99, + fallbackContentStringId = createLanguageId(androidLanguageId = EN_ANDROID_LANGUAGE_ID), + fallbackMinSdkVersion = 99, + regionDefinition = REGION_INDIA + ) + + val locale = androidLocaleFactory.createAndroidLocale(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). + assertThat(locale.language).isEqualTo("qq") + assertThat(locale.country).isEmpty() + } + + @Test + fun testCreateLocale_contentStrings_forMacroAndroidIdLanguage_returnsLocale() { + val context = + createContentStringsContext( + language = OppiaLanguage.ENGLISH, + contentStringId = createLanguageId(androidLanguageId = EN_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_INDIA + ) + + val locale = androidLocaleFactory.createAndroidLocale(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). + assertThat(locale.language).isEqualTo("en") + assertThat(locale.country).isEmpty() + } + + @Test + fun testCreateLocale_contentStrings_allIncompat_invalidLangType_throwsException() { + val context = + createContentStringsContext( + language = OppiaLanguage.ENGLISH, + contentStringId = LanguageId.getDefaultInstance(), + regionDefinition = REGION_INDIA + ) + + val exception = assertThrows(IllegalStateException::class) { + androidLocaleFactory.createAndroidLocale(context) + } + + assertThat(exception).hasMessageThat().contains("Invalid language case") + } + + /* Tests for audio translations. */ + + @Test + fun testCreateLocale_audioSubs_withAndroidId_compatible_returnsAndroidIdLocale() { + val context = + createAudioTranslationContext( + language = OppiaLanguage.BRAZILIAN_PORTUGUESE, + audioTranslationId = createLanguageId(androidLanguageId = PT_BR_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_BRAZIL + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // The context should be matched to a valid locale. + assertThat(locale.language).isEqualTo("pt") + assertThat(locale.country).isEqualTo("BR") + } + + @Test + fun testCreateLocale_audioSubs_withIetfId_compatible_returnsIetfLocale() { + val context = + createAudioTranslationContext( + language = OppiaLanguage.BRAZILIAN_PORTUGUESE, + audioTranslationId = createLanguageId(ietfBcp47LanguageId = PT_IETF_LANGUAGE_ID), + regionDefinition = REGION_BRAZIL + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // The context should be matched to a valid locale. + assertThat(locale.language).isEqualTo("pt") + assertThat(locale.country).isEqualTo("BR") + } + + @Test + fun testCreateLocale_audioSubs_withRegionIetfId_compatible_returnsIetfLocale() { + val context = + createAudioTranslationContext( + language = OppiaLanguage.BRAZILIAN_PORTUGUESE, + audioTranslationId = createLanguageId(ietfBcp47LanguageId = PT_BR_IETF_LANGUAGE_ID), + regionDefinition = REGION_US + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // The context should be matched to a valid locale. Note that BR is matched since the IETF + // language tag includes the region. + assertThat(locale.language).isEqualTo("pt") + assertThat(locale.country).isEqualTo("BR") + } + + @Test + fun testCreateLocale_audioSubs_withMacaronic_compatible_returnsMacaronicLocale() { + val context = + createAudioTranslationContext( + language = OppiaLanguage.BRAZILIAN_PORTUGUESE, + audioTranslationId = createLanguageId(macaronicLanguageId = PT_BR_MACARONIC_LANGUAGE_ID), + regionDefinition = REGION_BRAZIL + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // The context should be matched to a valid locale. + assertThat(locale.language).isEqualTo("pt") + assertThat(locale.country).isEqualTo("BR") + } + + @Test + fun testCreateLocale_audioSubs_androidIdAndIetf_returnsAndroidIdLocale() { + val context = + createAudioTranslationContext( + language = OppiaLanguage.ENGLISH, + audioTranslationId = createLanguageId( + androidLanguageId = EN_ANDROID_LANGUAGE_ID, + ietfBcp47LanguageId = PT_IETF_LANGUAGE_ID, + ), + regionDefinition = REGION_BRAZIL + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // The Android is preferred when both are present. Note no region is provided since the Android + // language is missing a region definition. + assertThat(locale.language).isEqualTo("en") + assertThat(locale.country).isEmpty() + } + + @Test + fun testCreateLocale_audioSubs_androidIdAndMacaronic_returnsAndroidIdLocale() { + val context = + createAudioTranslationContext( + language = OppiaLanguage.ENGLISH, + audioTranslationId = createLanguageId( + androidLanguageId = EN_ANDROID_LANGUAGE_ID, + macaronicLanguageId = PT_BR_MACARONIC_LANGUAGE_ID + ), + regionDefinition = REGION_BRAZIL + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // The Android is preferred when both are present. Note no region is provided since the Android + // language is missing a region definition. + assertThat(locale.language).isEqualTo("en") + assertThat(locale.country).isEmpty() + } + + @Test + fun testCreateLocale_audioSubs_withAndroidId_incompatible_returnsFallbackAndroidIdLocale() { + val context = + createAudioTranslationContext( + language = OppiaLanguage.LANGUAGE_UNSPECIFIED, + audioXlationId = createLanguageId(androidLanguageId = QQ_ANDROID_LANGUAGE_ID), + fallbackAudioXlationId = createLanguageId(androidLanguageId = PT_BR_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_BRAZIL + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // pt-BR should be picked because the primary language doesn't match a real locale. + assertThat(locale.language).isEqualTo("pt") + assertThat(locale.country).isEqualTo("BR") + } + + @Test + fun testCreateLocale_audioSubs_withIetfId_incompatible_returnsFallbackAndroidIdLocale() { + val context = + createAudioTranslationContext( + language = OppiaLanguage.LANGUAGE_UNSPECIFIED, + audioXlationId = createLanguageId(ietfBcp47LanguageId = QQ_ZZ_IETF_LANGUAGE_ID), + fallbackAudioXlationId = createLanguageId(androidLanguageId = PT_BR_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_BRAZIL + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // pt-BR should be picked because the primary language doesn't match a real locale. + assertThat(locale.language).isEqualTo("pt") + assertThat(locale.country).isEqualTo("BR") + } + + @Test + fun testCreateLocale_audioSubs_withRegionIetfId_incompat_returnsFallbackAndroidIdLocale() { + val context = + createAudioTranslationContext( + language = OppiaLanguage.BRAZILIAN_PORTUGUESE, + audioXlationId = createLanguageId(ietfBcp47LanguageId = PT_ZZ_IETF_LANGUAGE_ID), + fallbackAudioXlationId = createLanguageId(androidLanguageId = PT_BR_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_BRAZIL + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // pt-BR should be picked because the primary language's region doesn't match a real locale. + assertThat(locale.language).isEqualTo("pt") + assertThat(locale.country).isEqualTo("BR") + } + + @Test + fun testCreateLocale_audioSubs_withIetfId_incompRegion_returnsFallbackAndroidIdLocale() { + val context = + createAudioTranslationContext( + language = OppiaLanguage.BRAZILIAN_PORTUGUESE, + audioXlationId = createLanguageId(ietfBcp47LanguageId = PT_IETF_LANGUAGE_ID), + fallbackAudioXlationId = createLanguageId(androidLanguageId = PT_BR_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_ZZ + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // pt-BR should be picked because the supplied region doesn't match a real locale. + assertThat(locale.language).isEqualTo("pt") + assertThat(locale.country).isEqualTo("BR") + } + + @Test + fun testCreateLocale_audioSubs_withMacaronicId_incompat_returnsFallbackAndroidIdLocale() { + val context = + createAudioTranslationContext( + language = OppiaLanguage.LANGUAGE_UNSPECIFIED, + audioXlationId = createLanguageId(macaronicLanguageId = QQ_ZZ_MACARONIC_LANGUAGE_ID), + fallbackAudioXlationId = createLanguageId(androidLanguageId = PT_BR_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_BRAZIL + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // pt-BR should be picked because the primary language doesn't match a real locale. + assertThat(locale.language).isEqualTo("pt") + assertThat(locale.country).isEqualTo("BR") + } + + @Test + fun testCreateLocale_audioSubs_withMacaronicId_invalid_returnsFallbackAndroidIdLocale() { + val context = + createAudioTranslationContext( + language = OppiaLanguage.LANGUAGE_UNSPECIFIED, + audioXlationId = createLanguageId(macaronicLanguageId = INVALID_MACARONIC_LANGUAGE_ID), + fallbackAudioXlationId = createLanguageId(androidLanguageId = PT_BR_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_BRAZIL + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // pt-BR should be picked because the primary language is invalid. + assertThat(locale.language).isEqualTo("pt") + assertThat(locale.country).isEqualTo("BR") + } + + @Test + fun testCreateLocale_audioSubs_withSdkIncompat_returnsFallbackAndroidIdLocale() { + val context = + createAudioTranslationContext( + language = OppiaLanguage.ENGLISH, + audioXlationId = createLanguageId(androidLanguageId = EN_ANDROID_LANGUAGE_ID), + primaryMinSdkVersion = 99, + fallbackAudioXlationId = createLanguageId(androidLanguageId = PT_BR_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_BRAZIL + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // pt-BR should be picked because the primary language isn't compatible with the current SDK. + assertThat(locale.language).isEqualTo("pt") + assertThat(locale.country).isEqualTo("BR") + } + + @Test + fun testCreateLocale_audioSubs_incompat_androidAndIetfFallback_returnsAndroidIdLocale() { + val context = + createAudioTranslationContext( + language = OppiaLanguage.LANGUAGE_UNSPECIFIED, + audioXlationId = createLanguageId(androidLanguageId = QQ_ANDROID_LANGUAGE_ID), + fallbackAudioXlationId = createLanguageId( + androidLanguageId = PT_BR_ANDROID_LANGUAGE_ID, + ietfBcp47LanguageId = HI_IETF_LANGUAGE_ID + ), + regionDefinition = REGION_INDIA + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // pt-BR should be picked since Android IDs take precedence among multiple fallback options. + assertThat(locale.language).isEqualTo("pt") + assertThat(locale.country).isEqualTo("BR") + } + + @Test + fun testCreateLocale_audioSubs_incompat_androidMacaronicFallbacks_returnsAndroidIdLocale() { + val context = + createAudioTranslationContext( + language = OppiaLanguage.LANGUAGE_UNSPECIFIED, + audioXlationId = createLanguageId(androidLanguageId = QQ_ANDROID_LANGUAGE_ID), + fallbackAudioXlationId = createLanguageId( + androidLanguageId = PT_BR_ANDROID_LANGUAGE_ID, + macaronicLanguageId = HI_IN_MACARONIC_LANGUAGE_ID + ), + regionDefinition = REGION_INDIA + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // pt-BR should be picked since Android IDs take precedence among multiple fallback options. + assertThat(locale.language).isEqualTo("pt") + assertThat(locale.country).isEqualTo("BR") + } + + @Test + fun testCreateLocale_audioSubs_androidId_allIncompat_returnsForcedAndroidIdLocale() { + val context = + createAudioTranslationContext( + language = OppiaLanguage.LANGUAGE_UNSPECIFIED, + audioXlationId = createLanguageId(androidLanguageId = QQ_ANDROID_LANGUAGE_ID), + fallbackAudioXlationId = createLanguageId(androidLanguageId = QQ_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_INDIA + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // 'qq' is the exact locale being requested. + assertThat(locale.language).isEqualTo("qq") + assertThat(locale.country).isEmpty() + } + + @Test + fun testCreateLocale_audioSubs_androidId_ietf_allIncompat_returnsForcedAndroidIdLocale() { + val context = + createAudioTranslationContext( + language = OppiaLanguage.LANGUAGE_UNSPECIFIED, + audioXlationId = createLanguageId( + androidLanguageId = QQ_ANDROID_LANGUAGE_ID, + ietfBcp47LanguageId = QQ_IETF_LANGUAGE_ID + ), + fallbackAudioXlationId = createLanguageId(androidLanguageId = QQ_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_INDIA + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // 'qq' takes precedence over the IETF language since Android IDs are picked first when + // creating a forced locale. + assertThat(locale.language).isEqualTo("qq") + assertThat(locale.country).isEmpty() + } + + @Test + fun testCreateLocale_audioSubs_androidId_mac_allIncompat_returnsForcedAndroidIdLocale() { + val context = + createAudioTranslationContext( + language = OppiaLanguage.LANGUAGE_UNSPECIFIED, + audioXlationId = createLanguageId( + androidLanguageId = QQ_ANDROID_LANGUAGE_ID, + macaronicLanguageId = HI_EN_MACARONIC_LANGUAGE_ID + ), + fallbackAudioXlationId = createLanguageId(androidLanguageId = QQ_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_INDIA + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // 'qq' takes precedence over the macaronic language since Android IDs are picked first when + // creating a forced locale. + assertThat(locale.language).isEqualTo("qq") + assertThat(locale.country).isEmpty() + } + + @Test + fun testCreateLocale_audioSubs_ietf_allIncompat_returnsForcedIetfLocale() { + val context = + createAudioTranslationContext( + language = OppiaLanguage.LANGUAGE_UNSPECIFIED, + audioXlationId = createLanguageId(ietfBcp47LanguageId = QQ_IETF_LANGUAGE_ID), + fallbackAudioXlationId = createLanguageId(androidLanguageId = QQ_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_INDIA + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // The IETF language ID is used for the forced locale (note that fallback languages are ignored + // when computing the forced locale). + assertThat(locale.language).isEqualTo("qq") + assertThat(locale.country).isEqualTo("IN") + } + + @Test + fun testCreateLocale_audioSubs_mac_allIncompat_returnsForcedMacaronicLocale() { + val context = + createAudioTranslationContext( + language = OppiaLanguage.LANGUAGE_UNSPECIFIED, + audioXlationId = createLanguageId(macaronicLanguageId = HI_EN_MACARONIC_LANGUAGE_ID), + fallbackAudioXlationId = createLanguageId(androidLanguageId = QQ_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_INDIA + ) + + val locale = androidLocaleFactory.createAndroidLocale(context) + + // The Hinglish macaronic language ID is used for the forced locale (note that fallback + // languages are ignored when computing the forced locale). + assertThat(locale.language).isEqualTo("hi") + assertThat(locale.country).isEqualTo("EN") + } + + @Test + fun testCreateLocale_audioSubs_mac_allIncompat_invalidMac_throwsException() { + val context = + createAudioTranslationContext( + language = OppiaLanguage.ENGLISH, + audioXlationId = createLanguageId(macaronicLanguageId = INVALID_MACARONIC_LANGUAGE_ID), + fallbackAudioXlationId = createLanguageId(androidLanguageId = QQ_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_INDIA + ) + + val exception = assertThrows(IllegalStateException::class) { + androidLocaleFactory.createAndroidLocale(context) + } + + assertThat(exception).hasMessageThat().contains("Invalid macaronic ID") + } + + @Test + fun testCreateLocale_audioSubs_primaryAndFallbackSdkIncompat_returnsForcedLocale() { + val context = + createAudioTranslationContext( + language = OppiaLanguage.LANGUAGE_UNSPECIFIED, + audioXlationId = createLanguageId(androidLanguageId = QQ_ANDROID_LANGUAGE_ID), + primaryMinSdkVersion = 99, + fallbackAudioXlationId = createLanguageId(androidLanguageId = EN_ANDROID_LANGUAGE_ID), + fallbackMinSdkVersion = 99, + regionDefinition = REGION_INDIA + ) + + val locale = androidLocaleFactory.createAndroidLocale(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). + assertThat(locale.language).isEqualTo("qq") + assertThat(locale.country).isEmpty() + } + + @Test + fun testCreateLocale_audioSubs_forMacroAndroidIdLanguage_returnsLocale() { + val context = + createAudioTranslationContext( + language = OppiaLanguage.ENGLISH, + audioTranslationId = createLanguageId(androidLanguageId = EN_ANDROID_LANGUAGE_ID), + regionDefinition = REGION_INDIA + ) + + val locale = androidLocaleFactory.createAndroidLocale(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). + assertThat(locale.language).isEqualTo("en") + assertThat(locale.country).isEmpty() + } + + @Test + fun testCreateLocale_audioSubs_allIncompat_invalidLangType_throwsException() { + val context = + createAudioTranslationContext( + language = OppiaLanguage.ENGLISH, + audioTranslationId = LanguageId.getDefaultInstance(), + regionDefinition = REGION_INDIA + ) + + val exception = assertThrows(IllegalStateException::class) { + androidLocaleFactory.createAndroidLocale(context) + } + + assertThat(exception).hasMessageThat().contains("Invalid language case") + } + + private fun createLanguageId(androidLanguageId: AndroidLanguageId): LanguageId { + return LanguageId.newBuilder().apply { + androidResourcesLanguageId = androidLanguageId + }.build() + } + + private fun createLanguageId(ietfBcp47LanguageId: IetfBcp47LanguageId): LanguageId { + return LanguageId.newBuilder().apply { + ietfBcp47Id = ietfBcp47LanguageId + }.build() + } + + private fun createLanguageId( + androidLanguageId: AndroidLanguageId, + ietfBcp47LanguageId: IetfBcp47LanguageId + ): LanguageId { + return createLanguageId(androidLanguageId).toBuilder() + .mergeFrom(createLanguageId(ietfBcp47LanguageId)) + .build() + } + + private fun createLanguageId(macaronicLanguageId: MacaronicLanguageId): LanguageId { + return LanguageId.newBuilder().apply { + macaronicId = macaronicLanguageId + }.build() + } + + private fun createLanguageId( + androidLanguageId: AndroidLanguageId, + macaronicLanguageId: MacaronicLanguageId + ): LanguageId { + return createLanguageId(androidLanguageId).toBuilder() + .mergeFrom(createLanguageId(macaronicLanguageId)) + .build() + } + + private fun createAppStringsContext( + language: OppiaLanguage, + appStringId: LanguageId, + minSdkVersion: Int = 1, + regionDefinition: RegionSupportDefinition + ): OppiaLocaleContext { + return OppiaLocaleContext.newBuilder().apply { + languageDefinition = LanguageSupportDefinition.newBuilder().apply { + this.language = language + minAndroidSdkVersion = minSdkVersion + this.appStringId = appStringId + }.build() + this.regionDefinition = regionDefinition + usageMode = OppiaLocaleContext.LanguageUsageMode.APP_STRINGS + }.build() + } + + private fun createAppStringsContext( + language: OppiaLanguage, + appStringId: LanguageId, + primaryMinSdkVersion: Int = 1, + fallbackAppStringId: LanguageId, + fallbackMinSdkVersion: Int = 1, + regionDefinition: RegionSupportDefinition + ): OppiaLocaleContext { + return createAppStringsContext( + language, appStringId, primaryMinSdkVersion, regionDefinition + ).toBuilder().apply { + fallbackLanguageDefinition = LanguageSupportDefinition.newBuilder().apply { + this.language = language + minAndroidSdkVersion = fallbackMinSdkVersion + this.appStringId = fallbackAppStringId + }.build() + }.build() + } + + private fun createContentStringsContext( + language: OppiaLanguage, + contentStringId: LanguageId, + minSdkVersion: Int = 1, + regionDefinition: RegionSupportDefinition + ): OppiaLocaleContext { + return OppiaLocaleContext.newBuilder().apply { + languageDefinition = LanguageSupportDefinition.newBuilder().apply { + this.language = language + minAndroidSdkVersion = minSdkVersion + this.contentStringId = contentStringId + }.build() + this.regionDefinition = regionDefinition + usageMode = OppiaLocaleContext.LanguageUsageMode.CONTENT_STRINGS + }.build() + } + + private fun createContentStringsContext( + language: OppiaLanguage, + contentStringId: LanguageId, + primaryMinSdkVersion: Int = 1, + fallbackContentStringId: LanguageId, + fallbackMinSdkVersion: Int = 1, + regionDefinition: RegionSupportDefinition + ): OppiaLocaleContext { + return createContentStringsContext( + language, contentStringId, primaryMinSdkVersion, regionDefinition + ).toBuilder().apply { + fallbackLanguageDefinition = LanguageSupportDefinition.newBuilder().apply { + this.language = language + minAndroidSdkVersion = fallbackMinSdkVersion + this.contentStringId = fallbackContentStringId + }.build() + }.build() + } + + private fun createAudioTranslationContext( + language: OppiaLanguage, + audioTranslationId: LanguageId, + minSdkVersion: Int = 1, + regionDefinition: RegionSupportDefinition + ): OppiaLocaleContext { + return OppiaLocaleContext.newBuilder().apply { + languageDefinition = LanguageSupportDefinition.newBuilder().apply { + this.language = language + minAndroidSdkVersion = minSdkVersion + this.audioTranslationId = audioTranslationId + }.build() + this.regionDefinition = regionDefinition + usageMode = OppiaLocaleContext.LanguageUsageMode.AUDIO_TRANSLATIONS + }.build() + } + + private fun createAudioTranslationContext( + language: OppiaLanguage, + audioXlationId: LanguageId, + primaryMinSdkVersion: Int = 1, + fallbackAudioXlationId: LanguageId, + fallbackMinSdkVersion: Int = 1, + regionDefinition: RegionSupportDefinition + ): OppiaLocaleContext { + return createAudioTranslationContext( + language, audioXlationId, primaryMinSdkVersion, regionDefinition + ).toBuilder().apply { + fallbackLanguageDefinition = LanguageSupportDefinition.newBuilder().apply { + this.language = language + minAndroidSdkVersion = fallbackMinSdkVersion + this.audioTranslationId = fallbackAudioXlationId + }.build() + }.build() + } + + private fun setUpTestApplicationComponent() { + DaggerAndroidLocaleFactoryTest_TestApplicationComponent.builder() + .setApplication(ApplicationProvider.getApplicationContext()) + .build() + .inject(this) + } + + // TODO(#89): Move this to a common test application component. + @Module + class TestModule { + @Provides + @Singleton + fun provideContext(application: Application): Context { + return application + } + } + + // TODO(#89): Move this to a common test application component. + @Singleton + @Component( + modules = [ + TestModule::class, LocaleProdModule::class, FakeOppiaClockModule::class + ] + ) + interface TestApplicationComponent { + @Component.Builder + interface Builder { + @BindsInstance + fun setApplication(application: Application): Builder + + fun build(): TestApplicationComponent + } + + fun inject(androidLocaleFactoryTest: AndroidLocaleFactoryTest) + } + + private companion object { + private val REGION_BRAZIL = RegionSupportDefinition.newBuilder().apply { + region = OppiaRegion.BRAZIL + regionId = RegionSupportDefinition.IetfBcp47RegionId.newBuilder().apply { + ietfRegionTag = "BR" + }.build() + }.build() + + private val REGION_US = RegionSupportDefinition.newBuilder().apply { + region = OppiaRegion.UNITED_STATES + regionId = RegionSupportDefinition.IetfBcp47RegionId.newBuilder().apply { + ietfRegionTag = "US" + }.build() + }.build() + + private val REGION_INDIA = RegionSupportDefinition.newBuilder().apply { + region = OppiaRegion.INDIA + regionId = RegionSupportDefinition.IetfBcp47RegionId.newBuilder().apply { + ietfRegionTag = "IN" + }.build() + }.build() + + private val REGION_ZZ = RegionSupportDefinition.newBuilder().apply { + region = OppiaRegion.REGION_UNSPECIFIED + regionId = RegionSupportDefinition.IetfBcp47RegionId.newBuilder().apply { + ietfRegionTag = "ZZ" + }.build() + }.build() + + private val EN_ANDROID_LANGUAGE_ID = AndroidLanguageId.newBuilder().apply { + languageCode = "en" + }.build() + + private val PT_BR_ANDROID_LANGUAGE_ID = AndroidLanguageId.newBuilder().apply { + languageCode = "pt" + regionCode = "BR" + }.build() + + private val QQ_ANDROID_LANGUAGE_ID = AndroidLanguageId.newBuilder().apply { + languageCode = "qq" + }.build() + + private val PT_IETF_LANGUAGE_ID = IetfBcp47LanguageId.newBuilder().apply { + ietfLanguageTag = "pt" + }.build() + + private val HI_IETF_LANGUAGE_ID = IetfBcp47LanguageId.newBuilder().apply { + ietfLanguageTag = "hi" + }.build() + + private val PT_BR_IETF_LANGUAGE_ID = IetfBcp47LanguageId.newBuilder().apply { + ietfLanguageTag = "pt-BR" + }.build() + + // Has a valid language, but unsupported region. + private val PT_ZZ_IETF_LANGUAGE_ID = IetfBcp47LanguageId.newBuilder().apply { + ietfLanguageTag = "pt-ZZ" + }.build() + + private val QQ_IETF_LANGUAGE_ID = IetfBcp47LanguageId.newBuilder().apply { + ietfLanguageTag = "qq" + }.build() + + private val QQ_ZZ_IETF_LANGUAGE_ID = IetfBcp47LanguageId.newBuilder().apply { + ietfLanguageTag = "qq-ZZ" + }.build() + + private val PT_BR_MACARONIC_LANGUAGE_ID = MacaronicLanguageId.newBuilder().apply { + // This is a loose definition for macaronic language that's being done to test compatible + // cases (though in reality macaronic languages aren't expected to ever match with system + // locales). + combinedLanguageCode = "pt-br" + }.build() + + private val HI_IN_MACARONIC_LANGUAGE_ID = MacaronicLanguageId.newBuilder().apply { + combinedLanguageCode = "hi-IN" + }.build() + + private val HI_EN_MACARONIC_LANGUAGE_ID = MacaronicLanguageId.newBuilder().apply { + combinedLanguageCode = "hi-EN" + }.build() + + private val QQ_ZZ_MACARONIC_LANGUAGE_ID = MacaronicLanguageId.newBuilder().apply { + combinedLanguageCode = "qq-zz" + }.build() + + private val INVALID_MACARONIC_LANGUAGE_ID = MacaronicLanguageId.newBuilder().apply { + combinedLanguageCode = "languagewithoutregion" + }.build() + } +} diff --git a/domain/src/test/java/org/oppia/android/domain/locale/BUILD.bazel b/domain/src/test/java/org/oppia/android/domain/locale/BUILD.bazel new file mode 100644 index 00000000000..c64d6948ba6 --- /dev/null +++ b/domain/src/test/java/org/oppia/android/domain/locale/BUILD.bazel @@ -0,0 +1,141 @@ +""" +Tests for language & locale domain components. +""" + +load("@dagger//:workspace_defs.bzl", "dagger_rules") +load("//:oppia_android_test.bzl", "oppia_android_test") + +oppia_android_test( + name = "AndroidLocaleFactoryTest", + srcs = ["AndroidLocaleFactoryTest.kt"], + custom_package = "org.oppia.android.domain.locale", + test_class = "org.oppia.android.domain.locale.AndroidLocaleFactoryTest", + test_manifest = "//domain:test_manifest", + deps = [ + ":dagger", + "//domain/src/main/java/org/oppia/android/domain/locale:android_locale_factory", + "//testing", + "//testing/src/main/java/org/oppia/android/testing/time:test_module", + "//third_party:androidx_test_ext_junit", + "//third_party:com_google_truth_truth", + "//third_party:junit_junit", + "//third_party:org_robolectric_robolectric", + "//third_party:robolectric_android-all", + "//utility/src/main/java/org/oppia/android/util/locale:prod_module", + ], +) + +oppia_android_test( + name = "ContentLocaleImplTest", + srcs = ["ContentLocaleImplTest.kt"], + custom_package = "org.oppia.android.domain.locale", + test_class = "org.oppia.android.domain.locale.ContentLocaleImplTest", + test_manifest = "//domain:test_manifest", + deps = [ + ":dagger", + "//domain/src/main/java/org/oppia/android/domain/locale:content_locale_impl", + "//model:languages_java_proto_lite", + "//third_party:androidx_test_ext_junit", + "//third_party:com_google_truth_extensions_truth-liteproto-extension", + "//third_party:com_google_truth_truth", + "//third_party:junit_junit", + "//third_party:org_robolectric_robolectric", + "//third_party:robolectric_android-all", + ], +) + +oppia_android_test( + name = "DisplayLocaleImplTest", + srcs = ["DisplayLocaleImplTest.kt"], + custom_package = "org.oppia.android.domain.locale", + test_class = "org.oppia.android.domain.locale.DisplayLocaleImplTest", + test_manifest = "//domain:test_manifest", + deps = [ + ":dagger", + "//domain:test_resources", + "//domain/src/main/java/org/oppia/android/domain/locale:display_locale_impl", + "//model:languages_java_proto_lite", + "//testing", + "//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", + "//third_party:com_google_truth_truth", + "//third_party:junit_junit", + "//third_party:org_robolectric_robolectric", + "//third_party:robolectric_android-all", + "//utility/src/main/java/org/oppia/android/util/locale/testing:test_module", + ], +) + +oppia_android_test( + name = "LanguageConfigRetrieverTest", + srcs = ["LanguageConfigRetrieverTest.kt"], + custom_package = "org.oppia.android.domain.locale", + test_class = "org.oppia.android.domain.locale.LanguageConfigRetrieverTest", + test_manifest = "//domain:test_manifest", + deps = [ + ":dagger", + "//domain/src/main/java/org/oppia/android/domain/locale:language_config_retriever", + "//testing/src/main/java/org/oppia/android/testing/robolectric:test_module", + "//testing/src/main/java/org/oppia/android/testing/threading:test_module", + "//third_party:androidx_test_ext_junit", + "//third_party:com_google_truth_extensions_truth-liteproto-extension", + "//third_party:com_google_truth_truth", + "//third_party:junit_junit", + "//third_party:org_robolectric_robolectric", + "//third_party:robolectric_android-all", + "//utility/src/main/java/org/oppia/android/util/caching:asset_prod_module", + "//utility/src/main/java/org/oppia/android/util/logging:prod_module", + ], +) + +oppia_android_test( + name = "LanguageConfigRetrieverWithoutAssetsTest", + srcs = ["LanguageConfigRetrieverWithoutAssetsTest.kt"], + custom_package = "org.oppia.android.domain.locale", + test_class = "org.oppia.android.domain.locale.LanguageConfigRetrieverWithoutAssetsTest", + test_manifest = "//domain:test_manifest", + deps = [ + ":dagger", + "//domain/src/main/java/org/oppia/android/domain/locale:language_config_retriever", + "//testing/src/main/java/org/oppia/android/testing/robolectric:test_module", + "//testing/src/main/java/org/oppia/android/testing/threading:test_module", + "//third_party:androidx_test_ext_junit", + "//third_party:com_google_truth_extensions_truth-liteproto-extension", + "//third_party:junit_junit", + "//third_party:org_robolectric_robolectric", + "//third_party:robolectric_android-all", + "//utility/src/main/java/org/oppia/android/util/caching/testing:asset_test_no_op_module", + "//utility/src/main/java/org/oppia/android/util/logging:prod_module", + ], +) + +oppia_android_test( + name = "LocaleControllerTest", + srcs = ["LocaleControllerTest.kt"], + custom_package = "org.oppia.android.domain.locale", + test_class = "org.oppia.android.domain.locale.LocaleControllerTest", + test_manifest = "//domain:test_manifest", + deps = [ + ":dagger", + "//domain/src/main/java/org/oppia/android/domain/locale:locale_controller", + "//model:languages_java_proto_lite", + "//testing", + "//testing/src/main/java/org/oppia/android/testing/data:data_provider_test_monitor", + "//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", + "//third_party:com_google_truth_truth", + "//third_party:junit_junit", + "//third_party:org_mockito_mockito-core", + "//third_party:org_robolectric_robolectric", + "//third_party:robolectric_android-all", + "//utility/src/main/java/org/oppia/android/util/caching:asset_prod_module", + "//utility/src/main/java/org/oppia/android/util/locale:prod_module", + "//utility/src/main/java/org/oppia/android/util/networking:debug_module", + ], +) + +dagger_rules() diff --git a/domain/src/test/java/org/oppia/android/domain/locale/ContentLocaleImplTest.kt b/domain/src/test/java/org/oppia/android/domain/locale/ContentLocaleImplTest.kt new file mode 100644 index 00000000000..ad9ad675268 --- /dev/null +++ b/domain/src/test/java/org/oppia/android/domain/locale/ContentLocaleImplTest.kt @@ -0,0 +1,174 @@ +package org.oppia.android.domain.locale + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.extensions.proto.LiteProtoTruth.assertThat +import dagger.BindsInstance +import dagger.Component +import dagger.Module +import dagger.Provides +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.oppia.android.app.model.LanguageSupportDefinition +import org.oppia.android.app.model.OppiaLanguage +import org.oppia.android.app.model.OppiaLocaleContext +import org.oppia.android.app.model.OppiaRegion +import org.oppia.android.app.model.RegionSupportDefinition +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import javax.inject.Singleton + +/** + * Tests for [ContentLocaleImpl]. + * + * Note that the tests for verifying correct toString() & equals() were verified with a version of + * the implementation which didn't automatically implement them via a data class to verify that they + * fail as expected. + */ +// FunctionName: test names are conventionally named with underscores. +@Suppress("FunctionName") +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +@Config(manifest = Config.NONE) +class ContentLocaleImplTest { + @Before + fun setUp() { + setUpTestApplicationComponent() + } + + @Test + fun testCreateContentLocaleImpl_defaultInstance_hasDefaultInstanceContext() { + val impl = ContentLocaleImpl(OppiaLocaleContext.getDefaultInstance()) + + assertThat(impl.oppiaLocaleContext).isEqualToDefaultInstance() + } + + @Test + fun testCreateContentLocaleImpl_forProvidedContext_hasCorrectInstanceContext() { + val impl = ContentLocaleImpl(LOCALE_CONTEXT) + + assertThat(impl.oppiaLocaleContext).isEqualTo(LOCALE_CONTEXT) + } + + @Test + fun testToString_returnsNonDefaultString() { + val impl = ContentLocaleImpl(OppiaLocaleContext.getDefaultInstance()) + + val str = impl.toString() + + // Verify that the string includes some details about the implementation (this is a potentially + // fragile test). + assertThat(str).contains("OppiaLocaleContext") + } + + @Test + fun testEquals_withNullValue_returnsFalse() { + val impl = ContentLocaleImpl(OppiaLocaleContext.getDefaultInstance()) + + assertThat(impl).isNotEqualTo(null) + } + + @Test + fun testEquals_withSameObject_returnsTrue() { + val impl = ContentLocaleImpl(OppiaLocaleContext.getDefaultInstance()) + + assertThat(impl).isEqualTo(impl) + } + + @Test + fun testEquals_twoDifferentInstancesWithDifferentContexts_returnsFalse() { + val impl1 = ContentLocaleImpl(OppiaLocaleContext.getDefaultInstance()) + val impl2 = ContentLocaleImpl(LOCALE_CONTEXT) + + assertThat(impl1).isNotEqualTo(impl2) + } + + @Test + fun testEquals_twoDifferentInstancesWithDifferentContexts_reversed_returnsFalse() { + val impl1 = ContentLocaleImpl(OppiaLocaleContext.getDefaultInstance()) + val impl2 = ContentLocaleImpl(LOCALE_CONTEXT) + + assertThat(impl2).isNotEqualTo(impl1) + } + + @Test + fun testEquals_twoDifferentInstancesWithSameContexts_returnsTrue() { + val impl1 = ContentLocaleImpl(LOCALE_CONTEXT) + // Create a copy of the proto, too. + val impl2 = ContentLocaleImpl(LOCALE_CONTEXT.toBuilder().build()) + + // This is somewhat testing the implementation of data classes, but it's important to verify + // that the implementation correctly satisfies the contract outlined in OppiaLocale. + assertThat(impl1).isEqualTo(impl2) + } + + @Test + fun testEquals_twoDifferentInstancesWithSameContexts_reversed_returnsTrue() { + val impl1 = ContentLocaleImpl(LOCALE_CONTEXT) + val impl2 = ContentLocaleImpl(LOCALE_CONTEXT.toBuilder().build()) + + assertThat(impl2).isEqualTo(impl1) + } + + private fun setUpTestApplicationComponent() { + DaggerContentLocaleImplTest_TestApplicationComponent.builder() + .setApplication(ApplicationProvider.getApplicationContext()) + .build() + .inject(this) + } + + // TODO(#89): Move this to a common test application component. + @Module + class TestModule { + @Provides + @Singleton + fun provideContext(application: Application): Context { + return application + } + } + + // TODO(#89): Move this to a common test application component. + @Singleton + @Component( + modules = [ + TestModule::class + ] + ) + interface TestApplicationComponent { + @Component.Builder + interface Builder { + @BindsInstance + fun setApplication(application: Application): Builder + + fun build(): TestApplicationComponent + } + + fun inject(contentLocaleImplTest: ContentLocaleImplTest) + } + + private companion object { + private val LOCALE_CONTEXT = OppiaLocaleContext.newBuilder().apply { + usageMode = OppiaLocaleContext.LanguageUsageMode.CONTENT_STRINGS + languageDefinition = LanguageSupportDefinition.newBuilder().apply { + language = OppiaLanguage.HINGLISH + fallbackMacroLanguage = OppiaLanguage.ENGLISH + minAndroidSdkVersion = 1 + contentStringId = LanguageSupportDefinition.LanguageId.newBuilder().apply { + macaronicId = LanguageSupportDefinition.MacaronicLanguageId.newBuilder().apply { + combinedLanguageCode = "hi-en" + }.build() + }.build() + }.build() + regionDefinition = RegionSupportDefinition.newBuilder().apply { + region = OppiaRegion.INDIA + regionId = RegionSupportDefinition.IetfBcp47RegionId.newBuilder().apply { + ietfRegionTag = "IN" + }.build() + }.build() + }.build() + } +} diff --git a/domain/src/test/java/org/oppia/android/domain/locale/DisplayLocaleImplTest.kt b/domain/src/test/java/org/oppia/android/domain/locale/DisplayLocaleImplTest.kt new file mode 100644 index 00000000000..0ff73fdb1b8 --- /dev/null +++ b/domain/src/test/java/org/oppia/android/domain/locale/DisplayLocaleImplTest.kt @@ -0,0 +1,658 @@ +package org.oppia.android.domain.locale + +import android.app.Application +import android.content.Context +import android.content.res.Resources +import androidx.core.view.ViewCompat +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.extensions.proto.LiteProtoTruth.assertThat +import dagger.BindsInstance +import dagger.Component +import dagger.Module +import dagger.Provides +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.oppia.android.app.model.LanguageSupportDefinition +import org.oppia.android.app.model.OppiaLanguage +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.domain.R +import org.oppia.android.testing.assertThrows +import org.oppia.android.testing.time.FakeOppiaClockModule +import org.oppia.android.util.locale.OppiaBidiFormatter +import org.oppia.android.util.locale.OppiaLocale +import org.oppia.android.util.locale.testing.LocaleTestModule +import org.oppia.android.util.locale.testing.TestOppiaBidiFormatter +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import javax.inject.Inject +import javax.inject.Singleton + +/** Tests for [DisplayLocaleImpl]. */ +// FunctionName: test names are conventionally named with underscores. +@Suppress("FunctionName") +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +@Config(manifest = Config.NONE) +class DisplayLocaleImplTest { + @Inject + lateinit var machineLocale: OppiaLocale.MachineLocale + + @Inject + lateinit var androidLocaleFactory: AndroidLocaleFactory + + @Inject + lateinit var formatterFactory: OppiaBidiFormatter.Factory + + @Inject + lateinit var wrapperChecker: TestOppiaBidiFormatter.Checker + + @Inject + lateinit var context: Context + + @Before + fun setUp() { + 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) + + assertThat(impl.localeContext).isEqualTo(EGYPT_ARABIC_CONTEXT) + } + + @Test + fun testToString_returnsNonDefaultString() { + val impl = createDisplayLocaleImpl(OppiaLocaleContext.getDefaultInstance()) + + val str = impl.toString() + + // Verify that the string includes some details about the implementation (this is a potentially + // fragile test). + assertThat(str).contains("OppiaLocaleContext") + } + + @Test + fun testEquals_withNullValue_returnsFalse() { + val impl = createDisplayLocaleImpl(OppiaLocaleContext.getDefaultInstance()) + + assertThat(impl).isNotEqualTo(null) + } + + @Test + fun testEquals_withSameObject_returnsTrue() { + val impl = createDisplayLocaleImpl(OppiaLocaleContext.getDefaultInstance()) + + assertThat(impl).isEqualTo(impl) + } + + @Test + fun testEquals_twoDifferentInstancesWithDifferentContexts_returnsFalse() { + val impl1 = createDisplayLocaleImpl(EGYPT_ARABIC_CONTEXT) + val impl2 = createDisplayLocaleImpl(US_ENGLISH_CONTEXT) + + assertThat(impl1).isNotEqualTo(impl2) + } + + @Test + fun testEquals_twoDifferentInstancesWithDifferentContexts_reversed_returnsFalse() { + val impl1 = createDisplayLocaleImpl(EGYPT_ARABIC_CONTEXT) + val impl2 = createDisplayLocaleImpl(US_ENGLISH_CONTEXT) + + assertThat(impl2).isNotEqualTo(impl1) + } + + @Test + fun testEquals_twoDifferentInstancesWithSameContexts_returnsTrue() { + val impl1 = createDisplayLocaleImpl(EGYPT_ARABIC_CONTEXT) + // Create a copy of the proto, too. + val impl2 = createDisplayLocaleImpl(EGYPT_ARABIC_CONTEXT.toBuilder().build()) + + // This is somewhat testing the implementation of data classes, but it's important to verify + // that the implementation correctly satisfies the contract outlined in OppiaLocale. + assertThat(impl1).isEqualTo(impl2) + } + + @Test + fun testEquals_twoDifferentInstancesWithSameContexts_reversed_returnsTrue() { + val impl1 = createDisplayLocaleImpl(EGYPT_ARABIC_CONTEXT) + val impl2 = createDisplayLocaleImpl(EGYPT_ARABIC_CONTEXT.toBuilder().build()) + + assertThat(impl2).isEqualTo(impl1) + } + + @Test + fun testComputeDateString_forFixedTime_returnMonthDayYearParts() { + val impl = createDisplayLocaleImpl(US_ENGLISH_CONTEXT) + + val dateString = impl.computeDateString(MORNING_UTC_TIMESTAMP_MILLIS) + + assertThat(dateString.extractNumbers()).containsExactly("24", "2019") + assertThat(dateString).contains("Apr") + } + + @Test + fun testComputeTimeString_forFixedTime_returnMinuteHourParts() { + val impl = createDisplayLocaleImpl(US_ENGLISH_CONTEXT) + + val timeString = impl.computeTimeString(MORNING_UTC_TIMESTAMP_MILLIS) + + assertThat(timeString.extractNumbers()).containsExactly("22", "8") + } + + @Test + fun testComputeDateTimeString_forFixedTime_returnsMinHourMonthDayYearParts() { + val impl = createDisplayLocaleImpl(US_ENGLISH_CONTEXT) + + val dateTimeString = impl.computeDateTimeString(MORNING_UTC_TIMESTAMP_MILLIS) + + assertThat(dateTimeString.extractNumbers()).containsExactly("22", "8", "24", "2019") + assertThat(dateTimeString).contains("Apr") + } + + @Test + fun testGetLayoutDirection_englishContext_returnsLeftToRight() { + val impl = createDisplayLocaleImpl(US_ENGLISH_CONTEXT) + + val layoutDirection = impl.getLayoutDirection() + + assertThat(layoutDirection).isEqualTo(ViewCompat.LAYOUT_DIRECTION_LTR) + } + + @Test + fun testGetLayoutDirection_arabicContext_returnsRightToLeft() { + val impl = createDisplayLocaleImpl(EGYPT_ARABIC_CONTEXT) + + val layoutDirection = impl.getLayoutDirection() + + assertThat(layoutDirection).isEqualTo(ViewCompat.LAYOUT_DIRECTION_RTL) + } + + @Test + fun testFormatInLocaleWithWrapping_formatStringWithArgs_returnsCorrectlyFormattedString() { + val impl = createDisplayLocaleImpl(US_ENGLISH_CONTEXT) + + val formatted = impl.run { "Test with %s and %s".formatInLocaleWithWrapping("string", "11") } + + assertThat(formatted).isEqualTo("Test with string and 11") + } + + @Test + fun testFormatInLocaleWithWrapping_properlyWrapsArguments() { + val impl = createDisplayLocaleImpl(US_ENGLISH_CONTEXT) + + impl.run { "Test with %s and %s".formatInLocaleWithWrapping("string", "11") } + + // Verify that both arguments were wrapped. + assertThat(wrapperChecker.getAllWrappedUnicodeTexts()).containsExactly("string", "11") + } + + @Test + fun testFormatInLocaleWithoutWrapping_formatStringWithArgs_returnsCorrectlyFormattedString() { + val impl = createDisplayLocaleImpl(US_ENGLISH_CONTEXT) + + val formatted = impl.run { "Test with %s and %s".formatInLocaleWithoutWrapping("string", "11") } + + assertThat(formatted).isEqualTo("Test with string and 11") + } + + @Test + fun testFormatInLocaleWithoutWrapping_doesNotWrapArguments() { + val impl = createDisplayLocaleImpl(US_ENGLISH_CONTEXT) + + impl.run { "Test with %s and %s".formatInLocaleWithoutWrapping("string", "11") } + + // Verify that none of the arguments were wrapped. + assertThat(wrapperChecker.getAllWrappedUnicodeTexts()).isEmpty() + } + + @Test + fun testCapitalizeForHumans_capitalizedString_returnsSameString() { + val impl = createDisplayLocaleImpl(US_ENGLISH_CONTEXT) + + val capitalized = impl.run { "Title String".capitalizeForHumans() } + + assertThat(capitalized).isEqualTo("Title String") + } + + @Test + fun testCapitalizeForHumans_uncapitalizedString_returnsCapitalized() { + val impl = createDisplayLocaleImpl(US_ENGLISH_CONTEXT) + + val capitalized = impl.run { "lowercased string".capitalizeForHumans() } + + assertThat(capitalized).isEqualTo("Lowercased string") + } + + @Test + fun testCapitalizeForHumans_englishLocale_localeSensitiveCharAtStart_returnsConvertedCase() { + val impl = createDisplayLocaleImpl(US_ENGLISH_CONTEXT) + + val capitalized = impl.run { "igloo".capitalizeForHumans() } + + assertThat(capitalized).isEqualTo("Igloo") + } + + @Test + fun testCapitalizeForHumans_turkishLocale_localeSensitiveCharAtStart_returnsIncorrectCase() { + val impl = createDisplayLocaleImpl(TURKEY_TURKISH_CONTEXT) + + val capitalized = impl.run { "igloo".capitalizeForHumans() } + + // Note that the starting letter differs when being capitalized with a Turkish context (as + // compared with the English version of this test). See https://stackoverflow.com/a/11063161 for + // context on how casing behaviors differ based on Locales in Java. + assertThat(capitalized).isEqualTo("İgloo") + } + + @Test + fun testGetStringInLocale_validId_returnsResourceStringForId() { + val impl = createDisplayLocaleImpl(US_ENGLISH_CONTEXT) + val resources = context.resources + + val str = impl.run { resources.getStringInLocale(R.string.test_basic_string) } + + assertThat(str).isEqualTo("Basic string") + } + + @Test + fun testGetStringInLocale_nonExistentId_throwsException() { + val impl = createDisplayLocaleImpl(US_ENGLISH_CONTEXT) + val resources = context.resources + + assertThrows(Resources.NotFoundException::class) { + impl.run { resources.getStringInLocale(-1) } + } + } + + @Test + fun testGetStringInLocaleWithWrapping_formatStringResourceWithArgs_returnsFormattedString() { + val impl = createDisplayLocaleImpl(HEBREW_CONTEXT) + val resources = context.resources + + val str = impl.run { + resources.getStringInLocaleWithWrapping( + R.string.test_string_with_arg_hebrew, "123 Some Street, Mountain View, CA" + ) + } + + // This is based on the example here: + // https://developer.android.com/training/basics/supporting-devices/languages#FormatTextExplanationSolution. + assertThat(str) + .isEqualTo("האם התכוונת ל \u200F\u202A123 Some Street, Mountain View, CA\u202C\u200F") + } + + @Test + fun testGetStringInLocaleWithWrapping_properlyWrapsArguments() { + val impl = createDisplayLocaleImpl(HEBREW_CONTEXT) + val resources = context.resources + + impl.run { + resources.getStringInLocaleWithWrapping( + R.string.test_string_with_arg_hebrew, "123 Some Street, Mountain View, CA" + ) + } + + // Verify that the argument was wrapped. + assertThat(wrapperChecker.getAllWrappedUnicodeTexts()) + .containsExactly("123 Some Street, Mountain View, CA") + } + + @Test + fun testGetStringInLocaleWithWrapping_nonExistentId_throwsException() { + val impl = createDisplayLocaleImpl(US_ENGLISH_CONTEXT) + val resources = context.resources + + assertThrows(Resources.NotFoundException::class) { + impl.run { resources.getStringInLocaleWithWrapping(-1) } + } + } + + @Test + fun testGetStringInLocaleWithoutWrapping_formatStringResourceWithArgs_returnsFormattedString() { + val impl = createDisplayLocaleImpl(HEBREW_CONTEXT) + val resources = context.resources + + val str = impl.run { + resources.getStringInLocaleWithoutWrapping( + R.string.test_string_with_arg_hebrew, "123 Some Street, Mountain View, CA" + ) + } + + // This is based on the example here: + // https://developer.android.com/training/basics/supporting-devices/languages#FormatTextExplanationSolution. + // Note that the string is formatted, but due to no bidirectional wrapping the address ends up + // incorrectly formatted. + assertThat(str).isEqualTo("האם התכוונת ל 123 Some Street, Mountain View, CA") + } + + @Test + fun testGetStringInLocaleWithoutWrapping_doesNotWrapArguments() { + val impl = createDisplayLocaleImpl(HEBREW_CONTEXT) + val resources = context.resources + + impl.run { + resources.getStringInLocaleWithoutWrapping( + R.string.test_string_with_arg_hebrew, "123 Some Street, Mountain View, CA" + ) + } + + // Verify that no arguments were wrapped. + assertThat(wrapperChecker.getAllWrappedUnicodeTexts()).isEmpty() + } + + @Test + fun testGetStringInLocaleWithoutWrapping_nonExistentId_throwsException() { + val impl = createDisplayLocaleImpl(US_ENGLISH_CONTEXT) + val resources = context.resources + + assertThrows(Resources.NotFoundException::class) { + impl.run { resources.getStringInLocaleWithoutWrapping(-1) } + } + } + + @Test + fun testGetStringArrayInLocale_validId_returnsArrayAsStringList() { + val impl = createDisplayLocaleImpl(US_ENGLISH_CONTEXT) + val resources = context.resources + + val strList = impl.run { resources.getStringArrayInLocale(R.array.test_str_array) } + + assertThat(strList).containsExactly("Basic string", "Basic string2").inOrder() + } + + @Test + fun testGetStringArrayInLocale_nonExistentId_throwsException() { + val impl = createDisplayLocaleImpl(US_ENGLISH_CONTEXT) + val resources = context.resources + + assertThrows(Resources.NotFoundException::class) { + impl.run { resources.getStringArrayInLocale(-1) } + } + } + + @Test + fun testGetQuantityStringInLocale_validId_oneItem_returnsQuantityStringForSingleItem() { + val impl = createDisplayLocaleImpl(US_ENGLISH_CONTEXT) + val resources = context.resources + + val str = impl.run { + resources.getQuantityStringInLocale(R.plurals.test_plural_string_no_args, 1) + } + + assertThat(str).isEqualTo("1 item") + } + + @Test + fun testGetQuantityStringInLocale_validId_twoItems_returnsQuantityStringForMultipleItems() { + val impl = createDisplayLocaleImpl(US_ENGLISH_CONTEXT) + val resources = context.resources + + val str = impl.run { + resources.getQuantityStringInLocale(R.plurals.test_plural_string_no_args, 2) + } + + // Note that the 'other' case covers most scenarios in English (per + // https://issuetracker.google.com/issues/36917255). + assertThat(str).isEqualTo("2 items") + } + + @Test + fun testGetQuantityStringInLocale_nonExistentId_throwsException() { + val impl = createDisplayLocaleImpl(US_ENGLISH_CONTEXT) + val resources = context.resources + + assertThrows(Resources.NotFoundException::class) { + impl.run { resources.getQuantityStringInLocale(-1, 0) } + } + } + + @Test + fun testGetQuantityStringInLocaleWithWrapping_formatStrResourceWithArgs_returnsFormattedStr() { + val impl = createDisplayLocaleImpl(US_ENGLISH_CONTEXT) + val resources = context.resources + + val str = impl.run { + resources.getQuantityStringInLocaleWithWrapping( + R.plurals.test_plural_string_with_args, 2, "Two" + ) + } + + assertThat(str).isEqualTo("Two items") + } + + @Test + fun testGetQuantityStringInLocaleWithWrapping_properlyWrapsArguments() { + val impl = createDisplayLocaleImpl(US_ENGLISH_CONTEXT) + val resources = context.resources + + impl.run { + resources.getQuantityStringInLocaleWithWrapping( + R.plurals.test_plural_string_with_args, 2, "Two" + ) + } + + // Verify that the argument was wrapped. + assertThat(wrapperChecker.getAllWrappedUnicodeTexts()).containsExactly("Two") + } + + @Test + fun testGetQuantityStringInLocaleWithWrapping_nonExistentId_throwsException() { + val impl = createDisplayLocaleImpl(US_ENGLISH_CONTEXT) + val resources = context.resources + + assertThrows(Resources.NotFoundException::class) { + impl.run { resources.getQuantityStringInLocaleWithWrapping(-1, 0) } + } + } + + @Test + fun testGetQuantityStringInLocaleWithoutWrapping_formatStrResourceWithArgs_returnsFormattedStr() { + val impl = createDisplayLocaleImpl(US_ENGLISH_CONTEXT) + val resources = context.resources + + val str = impl.run { + resources.getQuantityStringInLocaleWithoutWrapping( + R.plurals.test_plural_string_with_args, 2, "Two" + ) + } + + assertThat(str).isEqualTo("Two items") + } + + @Test + fun testGetQuantityStringInLocaleWithoutWrapping_doesNotWrapArguments() { + val impl = createDisplayLocaleImpl(US_ENGLISH_CONTEXT) + val resources = context.resources + + impl.run { + resources.getQuantityStringInLocaleWithoutWrapping( + R.plurals.test_plural_string_with_args, 2, "Two" + ) + } + + // Verify that no arguments were wrapped. + assertThat(wrapperChecker.getAllWrappedUnicodeTexts()).isEmpty() + } + + @Test + fun testGetQuantityStringInLocaleWithoutWrapping_nonExistentId_throwsException() { + val impl = createDisplayLocaleImpl(US_ENGLISH_CONTEXT) + val resources = context.resources + + assertThrows(Resources.NotFoundException::class) { + impl.run { resources.getQuantityStringInLocaleWithoutWrapping(-1, 0) } + } + } + + @Test + fun testGetQuantityTextInLocale_validId_oneItem_returnsQuantityStringForSingleItem() { + val impl = createDisplayLocaleImpl(US_ENGLISH_CONTEXT) + val resources = context.resources + + val str = impl.run { + resources.getQuantityTextInLocale(R.plurals.test_plural_string_no_args, 1) + } + + assertThat(str).isEqualTo("1 item") + } + + @Test + fun testGetQuantityTextInLocale_validId_twoItems_returnsQuantityStringForMultipleItems() { + val impl = createDisplayLocaleImpl(US_ENGLISH_CONTEXT) + val resources = context.resources + + val str = impl.run { + resources.getQuantityTextInLocale(R.plurals.test_plural_string_no_args, 2) + } + + // Note that the 'other' case covers most scenarios in English (per + // https://issuetracker.google.com/issues/36917255). + assertThat(str).isEqualTo("2 items") + } + + @Test + fun testGetQuantityTextInLocale_nonExistentId_throwsException() { + val impl = createDisplayLocaleImpl(US_ENGLISH_CONTEXT) + val resources = context.resources + + assertThrows(Resources.NotFoundException::class) { + impl.run { resources.getQuantityTextInLocale(-1, 0) } + } + } + + private fun createDisplayLocaleImpl(context: OppiaLocaleContext): DisplayLocaleImpl = + DisplayLocaleImpl(context, machineLocale, androidLocaleFactory, formatterFactory) + + private fun setUpTestApplicationComponent() { + DaggerDisplayLocaleImplTest_TestApplicationComponent.builder() + .setApplication(ApplicationProvider.getApplicationContext()) + .build() + .inject(this) + } + + // TODO(#89): Move this to a common test application component. + @Module + class TestModule { + @Provides + @Singleton + fun provideContext(application: Application): Context { + return application + } + } + + // TODO(#89): Move this to a common test application component. + @Singleton + @Component( + modules = [ + TestModule::class, LocaleTestModule::class, FakeOppiaClockModule::class + ] + ) + interface TestApplicationComponent { + @Component.Builder + interface Builder { + @BindsInstance + fun setApplication(application: Application): Builder + + fun build(): TestApplicationComponent + } + + fun inject(displayLocaleImplTest: DisplayLocaleImplTest) + } + + private companion object { + // Date & time: Wed Apr 24 2019 08:22:03 GMT. + private const val MORNING_UTC_TIMESTAMP_MILLIS = 1556094123000 + + private val US_ENGLISH_CONTEXT = OppiaLocaleContext.newBuilder().apply { + usageMode = OppiaLocaleContext.LanguageUsageMode.APP_STRINGS + languageDefinition = LanguageSupportDefinition.newBuilder().apply { + language = OppiaLanguage.ENGLISH + minAndroidSdkVersion = 1 + appStringId = LanguageSupportDefinition.LanguageId.newBuilder().apply { + ietfBcp47Id = LanguageSupportDefinition.IetfBcp47LanguageId.newBuilder().apply { + ietfLanguageTag = "en" + }.build() + }.build() + }.build() + regionDefinition = RegionSupportDefinition.newBuilder().apply { + region = OppiaRegion.UNITED_STATES + regionId = RegionSupportDefinition.IetfBcp47RegionId.newBuilder().apply { + ietfRegionTag = "US" + }.build() + }.build() + }.build() + + private val EGYPT_ARABIC_CONTEXT = OppiaLocaleContext.newBuilder().apply { + usageMode = OppiaLocaleContext.LanguageUsageMode.APP_STRINGS + languageDefinition = LanguageSupportDefinition.newBuilder().apply { + language = OppiaLanguage.ARABIC + minAndroidSdkVersion = 1 + appStringId = LanguageSupportDefinition.LanguageId.newBuilder().apply { + ietfBcp47Id = LanguageSupportDefinition.IetfBcp47LanguageId.newBuilder().apply { + ietfLanguageTag = "ar" + }.build() + }.build() + }.build() + regionDefinition = RegionSupportDefinition.newBuilder().apply { + region = OppiaRegion.REGION_UNSPECIFIED + regionId = RegionSupportDefinition.IetfBcp47RegionId.newBuilder().apply { + ietfRegionTag = "EG" + }.build() + }.build() + }.build() + + private val TURKEY_TURKISH_CONTEXT = OppiaLocaleContext.newBuilder().apply { + usageMode = OppiaLocaleContext.LanguageUsageMode.APP_STRINGS + languageDefinition = LanguageSupportDefinition.newBuilder().apply { + language = OppiaLanguage.LANGUAGE_UNSPECIFIED + minAndroidSdkVersion = 1 + appStringId = LanguageSupportDefinition.LanguageId.newBuilder().apply { + ietfBcp47Id = LanguageSupportDefinition.IetfBcp47LanguageId.newBuilder().apply { + ietfLanguageTag = "tr" + }.build() + }.build() + }.build() + regionDefinition = RegionSupportDefinition.newBuilder().apply { + region = OppiaRegion.REGION_UNSPECIFIED + regionId = RegionSupportDefinition.IetfBcp47RegionId.newBuilder().apply { + ietfRegionTag = "TR" + }.build() + }.build() + }.build() + + private val HEBREW_CONTEXT = OppiaLocaleContext.newBuilder().apply { + usageMode = OppiaLocaleContext.LanguageUsageMode.APP_STRINGS + languageDefinition = LanguageSupportDefinition.newBuilder().apply { + language = OppiaLanguage.ARABIC + minAndroidSdkVersion = 1 + appStringId = LanguageSupportDefinition.LanguageId.newBuilder().apply { + ietfBcp47Id = LanguageSupportDefinition.IetfBcp47LanguageId.newBuilder().apply { + ietfLanguageTag = "he" + }.build() + }.build() + }.build() + regionDefinition = RegionSupportDefinition.newBuilder().apply { + region = OppiaRegion.UNITED_STATES + regionId = RegionSupportDefinition.IetfBcp47RegionId.newBuilder().apply { + ietfRegionTag = "US" + }.build() + }.build() + }.build() + + private fun String.extractNumbers(): List = + "\\d+".toRegex().findAll(this).flatMap { it.groupValues }.toList() + } +} diff --git a/domain/src/test/java/org/oppia/android/domain/locale/LanguageConfigRetrieverTest.kt b/domain/src/test/java/org/oppia/android/domain/locale/LanguageConfigRetrieverTest.kt new file mode 100644 index 00000000000..2de90f0971d --- /dev/null +++ b/domain/src/test/java/org/oppia/android/domain/locale/LanguageConfigRetrieverTest.kt @@ -0,0 +1,267 @@ +package org.oppia.android.domain.locale + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.extensions.proto.LiteProtoTruth.assertThat +import dagger.BindsInstance +import dagger.Component +import dagger.Module +import dagger.Provides +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.oppia.android.app.model.LanguageSupportDefinition +import org.oppia.android.app.model.OppiaLanguage +import org.oppia.android.app.model.OppiaRegion +import org.oppia.android.app.model.RegionSupportDefinition +import org.oppia.android.app.model.SupportedLanguages +import org.oppia.android.app.model.SupportedRegions +import org.oppia.android.testing.robolectric.RobolectricModule +import org.oppia.android.testing.threading.TestDispatcherModule +import org.oppia.android.util.caching.AssetModule +import org.oppia.android.util.logging.LoggerModule +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import javax.inject.Inject +import javax.inject.Singleton + +/** Tests for [LanguageConfigRetriever]. */ +// FunctionName: test names are conventionally named with underscores. +@Suppress("FunctionName") +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +@Config(manifest = Config.NONE) +class LanguageConfigRetrieverTest { + @Inject + lateinit var languageConfigRetriever: LanguageConfigRetriever + + @Before + fun setUp() { + setUpTestApplicationComponent() + } + + @Test + fun testOppiaLanguage_hasSupportForSixLanguages() { + // While it's a bit strange to test a proto, and particularly in this file, this suite is + // generally responsible for verifying language & region configuration sanity. Part of that + // requires verifying that all languages are tested below. Note that '8' is because the base + // 6 languages are supported + LANGUAGE_UNSPECIFIED and UNRECOGNIZED (auto-generated by + // Protobuf). Finally, note that the values themselves are not checked since it doesn't provide + // any benefit (being able to reference an enum constant without a compiler error is sufficient + // proof that constant is available). + assertThat(OppiaLanguage.values()).hasLength(8) + } + + @Test + fun testOppiaRegion_hasSupportForSixLanguages() { + // See above test for context on why this test is here & for why the number is correct. + assertThat(OppiaRegion.values()).hasLength(5) + } + + @Test + fun testLoadSupportedLanguages_loadsNonDefaultProtoFromAssets() { + val supportedLanguages = languageConfigRetriever.loadSupportedLanguages() + + assertThat(supportedLanguages).isNotEqualToDefaultInstance() + } + + @Test + fun testLoadSupportedLanguages_hasSixSupportedLanguages() { + val supportedLanguages = languageConfigRetriever.loadSupportedLanguages() + + // Change detector test to ensure changes to the configuration are reflected in tests since + // changes to the configuration can have a major impact on the app (and may require additional + // work to be done to support the changes). + assertThat(supportedLanguages.languageDefinitionsCount).isEqualTo(6) + } + + @Test + fun testLoadSupportedLanguages_arabic_isSupportedForAppContentAudioTranslations() { + val supportedLanguages = languageConfigRetriever.loadSupportedLanguages() + + val definition = supportedLanguages.lookUpLanguage(OppiaLanguage.ARABIC) + assertThat(definition.hasAppStringId()).isTrue() + assertThat(definition.hasContentStringId()).isTrue() + assertThat(definition.hasAudioTranslationId()).isTrue() + assertThat(definition.fallbackMacroLanguage).isEqualTo(OppiaLanguage.LANGUAGE_UNSPECIFIED) + assertThat(definition.appStringId.ietfBcp47Id.ietfLanguageTag).isEqualTo("ar") + assertThat(definition.appStringId.androidResourcesLanguageId.languageCode).isEqualTo("ar") + assertThat(definition.appStringId.androidResourcesLanguageId.regionCode).isEmpty() + assertThat(definition.contentStringId.ietfBcp47Id.ietfLanguageTag).isEqualTo("ar") + assertThat(definition.audioTranslationId.ietfBcp47Id.ietfLanguageTag).isEqualTo("ar") + } + + @Test + fun testLoadSupportedLanguages_english_isSupportedForAppContentAudioTranslations() { + val supportedLanguages = languageConfigRetriever.loadSupportedLanguages() + + val definition = supportedLanguages.lookUpLanguage(OppiaLanguage.ENGLISH) + assertThat(definition.hasAppStringId()).isTrue() + assertThat(definition.hasContentStringId()).isTrue() + assertThat(definition.hasAudioTranslationId()).isTrue() + assertThat(definition.fallbackMacroLanguage).isEqualTo(OppiaLanguage.LANGUAGE_UNSPECIFIED) + assertThat(definition.appStringId.ietfBcp47Id.ietfLanguageTag).isEqualTo("en") + assertThat(definition.appStringId.androidResourcesLanguageId.languageCode).isEqualTo("en") + assertThat(definition.appStringId.androidResourcesLanguageId.regionCode).isEmpty() + assertThat(definition.contentStringId.ietfBcp47Id.ietfLanguageTag).isEqualTo("en") + assertThat(definition.audioTranslationId.ietfBcp47Id.ietfLanguageTag).isEqualTo("en") + } + + @Test + fun testLoadSupportedLanguages_hindi_isSupportedForAppContentAudioTranslations() { + val supportedLanguages = languageConfigRetriever.loadSupportedLanguages() + + val definition = supportedLanguages.lookUpLanguage(OppiaLanguage.HINDI) + assertThat(definition.hasAppStringId()).isTrue() + assertThat(definition.hasContentStringId()).isTrue() + assertThat(definition.hasAudioTranslationId()).isTrue() + assertThat(definition.fallbackMacroLanguage).isEqualTo(OppiaLanguage.LANGUAGE_UNSPECIFIED) + assertThat(definition.appStringId.ietfBcp47Id.ietfLanguageTag).isEqualTo("hi") + assertThat(definition.appStringId.androidResourcesLanguageId.languageCode).isEqualTo("hi") + assertThat(definition.appStringId.androidResourcesLanguageId.regionCode).isEmpty() + assertThat(definition.contentStringId.ietfBcp47Id.ietfLanguageTag).isEqualTo("hi") + assertThat(definition.audioTranslationId.ietfBcp47Id.ietfLanguageTag).isEqualTo("hi") + } + + @Test + fun testLoadSupportedLanguages_hinglish_isSupportedForContentAndAudioTranslationsWithFallback() { + val supportedLanguages = languageConfigRetriever.loadSupportedLanguages() + + val definition = supportedLanguages.lookUpLanguage(OppiaLanguage.HINGLISH) + assertThat(definition.hasAppStringId()).isFalse() + assertThat(definition.hasContentStringId()).isTrue() + assertThat(definition.hasAudioTranslationId()).isTrue() + assertThat(definition.fallbackMacroLanguage).isEqualTo(OppiaLanguage.ENGLISH) + assertThat(definition.contentStringId.macaronicId.combinedLanguageCode).isEqualTo("hi-en") + assertThat(definition.audioTranslationId.macaronicId.combinedLanguageCode).isEqualTo("hi-en") + } + + @Test + fun testLoadSupportedLanguages_portuguese_hasNoTranslationSupport() { + val supportedLanguages = languageConfigRetriever.loadSupportedLanguages() + + val definition = supportedLanguages.lookUpLanguage(OppiaLanguage.PORTUGUESE) + assertThat(definition.hasAppStringId()).isFalse() + assertThat(definition.hasContentStringId()).isFalse() + assertThat(definition.hasAudioTranslationId()).isFalse() + assertThat(definition.fallbackMacroLanguage).isEqualTo(OppiaLanguage.LANGUAGE_UNSPECIFIED) + } + + @Test + fun testLoadSupportedLangs_brazilianPortuguese_supportsAppContentAudioTranslationsWithFallback() { + val supportedLanguages = languageConfigRetriever.loadSupportedLanguages() + + val definition = supportedLanguages.lookUpLanguage(OppiaLanguage.BRAZILIAN_PORTUGUESE) + assertThat(definition.hasAppStringId()).isTrue() + assertThat(definition.hasContentStringId()).isTrue() + assertThat(definition.hasAudioTranslationId()).isTrue() + assertThat(definition.fallbackMacroLanguage).isEqualTo(OppiaLanguage.PORTUGUESE) + assertThat(definition.appStringId.ietfBcp47Id.ietfLanguageTag).isEqualTo("pt-BR") + assertThat(definition.appStringId.androidResourcesLanguageId.languageCode).isEqualTo("pt") + assertThat(definition.appStringId.androidResourcesLanguageId.regionCode).isEqualTo("BR") + assertThat(definition.contentStringId.ietfBcp47Id.ietfLanguageTag).isEqualTo("pt-BR") + assertThat(definition.audioTranslationId.ietfBcp47Id.ietfLanguageTag).isEqualTo("pt-BR") + } + + @Test + fun testLoadSupportedRegions_loadsNonDefaultProtoFromAssets() { + val supportedRegions = languageConfigRetriever.loadSupportedRegions() + + assertThat(supportedRegions).isNotEqualToDefaultInstance() + } + + @Test + fun testLoadSupportedRegions_hasThreeSupportedRegions() { + val supportedRegions = languageConfigRetriever.loadSupportedRegions() + + // Change detector test to ensure changes to the configuration are reflected in tests since + // changes to the configuration can have a major impact on the app (and may require additional + // work to be done to support the changes). + assertThat(supportedRegions.regionDefinitionsCount).isEqualTo(3) + } + + @Test + fun testLoadSupportedRegions_brazil_hasCorrectRegionIdAndSupportedLanguages() { + val supportedRegions = languageConfigRetriever.loadSupportedRegions() + + val definition = supportedRegions.lookUpRegion(OppiaRegion.BRAZIL) + assertThat(definition.regionId.ietfRegionTag).isEqualTo("BR") + assertThat(definition.languagesList) + .containsExactly(OppiaLanguage.PORTUGUESE, OppiaLanguage.BRAZILIAN_PORTUGUESE) + } + + @Test + fun testLoadSupportedRegions_india_hasCorrectRegionIdAndSupportedLanguages() { + val supportedRegions = languageConfigRetriever.loadSupportedRegions() + + val definition = supportedRegions.lookUpRegion(OppiaRegion.INDIA) + assertThat(definition.regionId.ietfRegionTag).isEqualTo("IN") + assertThat(definition.languagesList) + .containsExactly(OppiaLanguage.HINDI, OppiaLanguage.HINGLISH) + } + + @Test + fun testLoadSupportedRegions_unitedStates_hasCorrectRegionIdAndSupportedLanguages() { + val supportedRegions = languageConfigRetriever.loadSupportedRegions() + + val definition = supportedRegions.lookUpRegion(OppiaRegion.UNITED_STATES) + assertThat(definition.regionId.ietfRegionTag).isEqualTo("US") + assertThat(definition.languagesList).containsExactly(OppiaLanguage.ENGLISH) + } + + private fun SupportedLanguages.lookUpLanguage( + language: OppiaLanguage + ): LanguageSupportDefinition { + val definition = languageDefinitionsList.find { it.language == language } + // Sanity check. + assertThat(definition).isNotNull() + return definition!! + } + + private fun SupportedRegions.lookUpRegion(region: OppiaRegion): RegionSupportDefinition { + val definition = regionDefinitionsList.find { it.region == region } + // Sanity check. + assertThat(definition).isNotNull() + return definition!! + } + + private fun setUpTestApplicationComponent() { + DaggerLanguageConfigRetrieverTest_TestApplicationComponent.builder() + .setApplication(ApplicationProvider.getApplicationContext()) + .build() + .inject(this) + } + + // TODO(#89): Move this to a common test application component. + @Module + class TestModule { + @Provides + @Singleton + fun provideContext(application: Application): Context { + return application + } + } + + // TODO(#89): Move this to a common test application component. + @Singleton + @Component( + modules = [ + TestModule::class, LoggerModule::class, TestDispatcherModule::class, RobolectricModule::class, + AssetModule::class + ] + ) + interface TestApplicationComponent { + @Component.Builder + interface Builder { + @BindsInstance + fun setApplication(application: Application): Builder + + fun build(): TestApplicationComponent + } + + fun inject(languageConfigRetrieverTest: LanguageConfigRetrieverTest) + } +} diff --git a/domain/src/test/java/org/oppia/android/domain/locale/LanguageConfigRetrieverWithoutAssetsTest.kt b/domain/src/test/java/org/oppia/android/domain/locale/LanguageConfigRetrieverWithoutAssetsTest.kt new file mode 100644 index 00000000000..ff5b65014d5 --- /dev/null +++ b/domain/src/test/java/org/oppia/android/domain/locale/LanguageConfigRetrieverWithoutAssetsTest.kt @@ -0,0 +1,96 @@ +package org.oppia.android.domain.locale + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.extensions.proto.LiteProtoTruth.assertThat +import dagger.BindsInstance +import dagger.Component +import dagger.Module +import dagger.Provides +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.oppia.android.testing.robolectric.RobolectricModule +import org.oppia.android.testing.threading.TestDispatcherModule +import org.oppia.android.util.caching.testing.AssetTestNoOpModule +import org.oppia.android.util.logging.LoggerModule +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import javax.inject.Inject +import javax.inject.Singleton + +// TODO(#59): Use a build-time configuration instead of the runtime AssetRepository fake (i.e. by +// not including the config assets during build time). +/** + * Tests for [LanguageConfigRetriever]. Unlike [LanguageConfigRetrieverTest], this suite verifies + * the retriever's behavior when there are no configuration files to include. + */ +// FunctionName: test names are conventionally named with underscores. +@Suppress("FunctionName") +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +@Config(manifest = Config.NONE) +class LanguageConfigRetrieverWithoutAssetsTest { + @Inject + lateinit var languageConfigRetriever: LanguageConfigRetriever + + @Before + fun setUp() { + setUpTestApplicationComponent() + } + + @Test + fun testLoadSupportedLanguages_withoutAssets_returnsDefaultInstance() { + val supportedLanguages = languageConfigRetriever.loadSupportedLanguages() + + // Using the no-op asset repository results in no assets being present. + assertThat(supportedLanguages).isEqualToDefaultInstance() + } + + @Test + fun testLoadSupportedRegions_withoutAssets_returnsDefaultInstance() { + val supportedRegions = languageConfigRetriever.loadSupportedRegions() + + // Using the no-op asset repository results in no assets being present. + assertThat(supportedRegions).isEqualToDefaultInstance() + } + + private fun setUpTestApplicationComponent() { + DaggerLanguageConfigRetrieverWithoutAssetsTest_TestApplicationComponent.builder() + .setApplication(ApplicationProvider.getApplicationContext()) + .build() + .inject(this) + } + + // TODO(#89): Move this to a common test application component. + @Module + class TestModule { + @Provides + @Singleton + fun provideContext(application: Application): Context { + return application + } + } + + // TODO(#89): Move this to a common test application component. + @Singleton + @Component( + modules = [ + TestModule::class, LoggerModule::class, TestDispatcherModule::class, RobolectricModule::class, + AssetTestNoOpModule::class + ] + ) + interface TestApplicationComponent { + @Component.Builder + interface Builder { + @BindsInstance + fun setApplication(application: Application): Builder + + fun build(): TestApplicationComponent + } + + fun inject(languageConfigRetrieverWithoutAssetsTest: LanguageConfigRetrieverWithoutAssetsTest) + } +} 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 new file mode 100644 index 00000000000..3f61ad22bfa --- /dev/null +++ b/domain/src/test/java/org/oppia/android/domain/locale/LocaleControllerTest.kt @@ -0,0 +1,893 @@ +package org.oppia.android.domain.locale + +import android.app.Application +import android.content.Context +import android.content.res.Configuration +import android.os.LocaleList +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.extensions.proto.LiteProtoTruth.assertThat +import dagger.BindsInstance +import dagger.Component +import dagger.Module +import dagger.Provides +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mock +import org.mockito.junit.MockitoJUnit +import org.mockito.junit.MockitoRule +import org.oppia.android.app.model.OppiaLanguage +import org.oppia.android.app.model.OppiaLanguage.BRAZILIAN_PORTUGUESE +import org.oppia.android.app.model.OppiaLanguage.ENGLISH +import org.oppia.android.app.model.OppiaLanguage.HINDI +import org.oppia.android.app.model.OppiaLanguage.HINGLISH +import org.oppia.android.app.model.OppiaLanguage.LANGUAGE_UNSPECIFIED +import org.oppia.android.app.model.OppiaLocaleContext +import org.oppia.android.app.model.OppiaLocaleContext.LanguageUsageMode.AUDIO_TRANSLATIONS +import org.oppia.android.app.model.OppiaLocaleContext.LanguageUsageMode.CONTENT_STRINGS +import org.oppia.android.app.model.OppiaRegion.INDIA +import org.oppia.android.app.model.OppiaRegion.REGION_UNSPECIFIED +import org.oppia.android.app.model.OppiaRegion.UNITED_STATES +import org.oppia.android.domain.oppialogger.LogStorageModule +import org.oppia.android.testing.TestLogReportingModule +import org.oppia.android.testing.assertThrows +import org.oppia.android.testing.data.DataProviderTestMonitor +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.caching.AssetModule +import org.oppia.android.util.data.DataProvidersInjector +import org.oppia.android.util.data.DataProvidersInjectorProvider +import org.oppia.android.util.locale.LocaleProdModule +import org.oppia.android.util.locale.OppiaLocale +import org.oppia.android.util.logging.LoggerModule +import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import org.robolectric.shadows.ShadowLog +import java.util.Locale +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Tests for [LocaleController]. + * + * Note that these tests depend on real locales being present in the local environment + * (Robolectric). + */ +// FunctionName: test names are conventionally named with underscores. +// SameParameterValue: tests should have specific context included/excluded for readability. +@Suppress("FunctionName", "SameParameterValue") +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +@Config(application = LocaleControllerTest.TestApplication::class) +class LocaleControllerTest { + @Rule + @JvmField + val mockitoRule: MockitoRule = MockitoJUnit.rule() + + @Inject + lateinit var localeController: LocaleController + + @Inject + lateinit var monitorFactory: DataProviderTestMonitor.Factory + + @Inject + lateinit var context: Context + + @Mock + lateinit var mockDisplayLocale: OppiaLocale.DisplayLocale + + @Before + fun setUp() { + setUpTestApplicationComponent() + } + + /* Tests for getLikelyDefaultAppStringLocaleContext & reconstituteDisplayLocale. */ + + @Test + fun testGetLikelyDefaultAppStringLocaleContext_returnsAppStringContextForEnglish() { + val context = localeController.getLikelyDefaultAppStringLocaleContext() + + assertThat(context.languageDefinition.language).isEqualTo(ENGLISH) + assertThat(context.languageDefinition.minAndroidSdkVersion).isEqualTo(1) + assertThat(context.languageDefinition.appStringId.ietfBcp47Id.ietfLanguageTag).isEqualTo("en") + assertThat(context.hasFallbackLanguageDefinition()).isFalse() + assertThat(context.regionDefinition.region).isEqualTo(UNITED_STATES) + assertThat(context.regionDefinition.regionId.ietfRegionTag).isEqualTo("US") + assertThat(context.usageMode).isEqualTo(OppiaLocaleContext.LanguageUsageMode.APP_STRINGS) + } + + @Test + fun testGetLikelyDefaultAppStringLocaleContext_twice_returnsSameContext() { + val firstContext = localeController.getLikelyDefaultAppStringLocaleContext() + val secondContext = localeController.getLikelyDefaultAppStringLocaleContext() + + assertThat(firstContext).isEqualTo(secondContext) + } + + @Test + fun testReconstituteDisplayLocale_defaultContext_returnsDisplayLocaleForContext() { + val context = OppiaLocaleContext.getDefaultInstance() + + val locale = localeController.reconstituteDisplayLocale(context) + + assertThat(locale.localeContext).isEqualToDefaultInstance() + } + + @Test + fun testReconstituteDisplayLocale_nonDefaultContext_returnsDisplayLocaleForContext() { + val context = localeController.getLikelyDefaultAppStringLocaleContext() + + val locale = localeController.reconstituteDisplayLocale(context) + + assertThat(locale.localeContext).isEqualTo(context) + } + + /* Tests for retrieveAppStringDisplayLocale. */ + + @Test + fun testAppStringLocale_rootLocale_noConfigLocale_printsErrorForDefaulting() { + context.applicationContext.resources.configuration.setLocale(null) + Locale.setDefault(Locale.ROOT) + + monitorFactory.waitForNextSuccessfulResult( + localeController.retrieveAppStringDisplayLocale(ENGLISH) + ) + + assertThat(retrieveLogcatLogs()) + .contains("No locales defined for application context. Defaulting to default Locale.") + } + + @Test + fun testAppStringLocale_rootLocale_english_hasCorrectLanguageAndFallbackDefinitions() { + forceDefaultLocale(Locale.ROOT) + + val localeProvider = localeController.retrieveAppStringDisplayLocale(ENGLISH) + + val locale = monitorFactory.waitForNextSuccessfulResult(localeProvider) + val context = locale.localeContext + assertThat(context.languageDefinition.language).isEqualTo(ENGLISH) + assertThat(context.languageDefinition.fallbackMacroLanguage).isEqualTo(LANGUAGE_UNSPECIFIED) + assertThat(context.languageDefinition.appStringId.ietfBcp47Id.ietfLanguageTag).isEqualTo("en") + assertThat(context.hasFallbackLanguageDefinition()).isFalse() + assertThat(context.regionDefinition.region).isEqualTo(REGION_UNSPECIFIED) + } + + @Test + fun testAppStringLocale_usLocale_english_hasMatchedUsRegion() { + forceDefaultLocale(Locale.US) + + val localeProvider = localeController.retrieveAppStringDisplayLocale(ENGLISH) + + val locale = monitorFactory.waitForNextSuccessfulResult(localeProvider) + assertThat(locale.localeContext.regionDefinition.region).isEqualTo(UNITED_STATES) + } + + @Test + fun testAppStringLocale_monacoLocale_english_hasUnmatchedMonacoRegion() { + forceDefaultLocale(MONACO_FRENCH_LOCALE) + + val localeProvider = localeController.retrieveAppStringDisplayLocale(ENGLISH) + + val locale = monitorFactory.waitForNextSuccessfulResult(localeProvider) + assertThat(locale.localeContext.regionDefinition.region).isEqualTo(REGION_UNSPECIFIED) + } + + @Test + fun testAppStringLocale_indiaLocale_english_printsRegionLanguageMismatchError() { + forceDefaultLocale(INDIA_HINDI_LOCALE) + + monitorFactory.waitForNextSuccessfulResult( + localeController.retrieveAppStringDisplayLocale(ENGLISH) + ) + + assertThat(retrieveLogcatLogs()) + .contains( + "Notice: selected language $ENGLISH is not part of the corresponding region matched to" + + " this locale: $INDIA (ID: IN) (supported languages: [$HINDI, $HINGLISH]" + ) + } + + @Test + fun testAppStringLocale_englishUsLocale_defaultLang_returnsEnglishLocale() { + forceDefaultLocale(Locale.US) + + val localeProvider = localeController.retrieveAppStringDisplayLocale(LANGUAGE_UNSPECIFIED) + + // English should be matched per the system locale. + val locale = monitorFactory.waitForNextSuccessfulResult(localeProvider) + val context = locale.localeContext + assertThat(context.languageDefinition.language).isEqualTo(ENGLISH) + assertThat(context.languageDefinition.fallbackMacroLanguage).isEqualTo(LANGUAGE_UNSPECIFIED) + assertThat(context.languageDefinition.appStringId.ietfBcp47Id.ietfLanguageTag).isEqualTo("en") + assertThat(context.hasFallbackLanguageDefinition()).isFalse() + assertThat(context.regionDefinition.region).isEqualTo(UNITED_STATES) + } + + @Test + fun testAppStringLocale_englishUsLocale_defaultLang_printsError() { + forceDefaultLocale(Locale.US) + + monitorFactory.waitForNextSuccessfulResult( + localeController.retrieveAppStringDisplayLocale(LANGUAGE_UNSPECIFIED) + ) + + assertThat(retrieveLogcatLogs()) + .contains("Encountered unmatched language: $LANGUAGE_UNSPECIFIED") + } + + @Test + fun testAppStringLocale_frenchMonacoLocale_defaultLang_returnsFrenchDefaultLocale() { + forceDefaultLocale(MONACO_FRENCH_LOCALE) + + val localeProvider = localeController.retrieveAppStringDisplayLocale(LANGUAGE_UNSPECIFIED) + + // The locale will be forced to fit Monaco & French despite not being directly supported by the + // app. + val locale = monitorFactory.waitForNextSuccessfulResult(localeProvider) + val context = locale.localeContext + assertThat(context.languageDefinition.language).isEqualTo(LANGUAGE_UNSPECIFIED) + assertThat(context.languageDefinition.fallbackMacroLanguage).isEqualTo(LANGUAGE_UNSPECIFIED) + assertThat(context.languageDefinition.appStringId.ietfBcp47Id.ietfLanguageTag).isEqualTo("fr") + assertThat(context.hasFallbackLanguageDefinition()).isFalse() + assertThat(context.regionDefinition.region).isEqualTo(REGION_UNSPECIFIED) + assertThat(context.regionDefinition.regionId.ietfRegionTag).isEqualTo("MC") + } + + @Test + fun testAppStringLocale_newSystemLocale_doesNotNotifyProvider() { + forceDefaultLocale(Locale.US) + val localeProvider = localeController.retrieveAppStringDisplayLocale(ENGLISH) + val monitor = monitorFactory.createMonitor(localeProvider) + monitor.waitForNextSuccessResult() + + forceDefaultLocale(Locale.CANADA) + + // Simply changing the locale shouldn't change the provider. + monitor.verifyProviderIsNotUpdated() + } + + @Test + fun testAppStringLocale_notifyPotentialLocaleChange_doesNotNotifyProvider() { + forceDefaultLocale(Locale.US) + val localeProvider = localeController.retrieveAppStringDisplayLocale(ENGLISH) + val monitor = monitorFactory.createMonitor(localeProvider) + monitor.waitForNextSuccessResult() + + localeController.notifyPotentialLocaleChange() + + // Just notifying isn't sufficient to trigger a change in the provider. + monitor.verifyProviderIsNotUpdated() + } + + @Test + fun testAppStringLocale_newSystemLocale_sameRegion_notify_doesNotNotifyProvider() { + forceDefaultLocale(Locale.US) + val localeProvider = localeController.retrieveAppStringDisplayLocale(ENGLISH) + val monitor = monitorFactory.createMonitor(localeProvider) + monitor.waitForNextSuccessResult() + + forceDefaultLocale(Locale.US) + localeController.notifyPotentialLocaleChange() + + // Changing & notifying for the same locale should not change the provider. + monitor.verifyProviderIsNotUpdated() + } + + @Test + fun testAppStringLocale_newSystemLocale_newRegion_notify_notifiesProvider() { + forceDefaultLocale(Locale.US) + val localeProvider = localeController.retrieveAppStringDisplayLocale(ENGLISH) + val monitor = monitorFactory.createMonitor(localeProvider) + monitor.waitForNextSuccessResult() + + forceDefaultLocale(Locale.CANADA) + localeController.notifyPotentialLocaleChange() + + // Changing to a new region (but keeping the same language) should update the region. + val locale = monitor.waitForNextSuccessResult() + val context = locale.localeContext + assertThat(context.languageDefinition.language).isEqualTo(ENGLISH) + assertThat(context.regionDefinition.region).isEqualTo(REGION_UNSPECIFIED) + assertThat(context.regionDefinition.regionId.ietfRegionTag).isEqualTo("CA") + } + + @Test + fun testAppStringLocale_newSystemLocale_defLangMatching_notify_notifiesProvider() { + forceDefaultLocale(Locale.ROOT) + val localeProvider = localeController.retrieveAppStringDisplayLocale(LANGUAGE_UNSPECIFIED) + val monitor = monitorFactory.createMonitor(localeProvider) + monitor.waitForNextSuccessResult() + + forceDefaultLocale(INDIA_HINDI_LOCALE) + localeController.notifyPotentialLocaleChange() + + // Changing to a matched language should update the provider result. + val locale = monitor.waitForNextSuccessResult() + val context = locale.localeContext + assertThat(context.languageDefinition.language).isEqualTo(HINDI) + assertThat(context.regionDefinition.region).isEqualTo(INDIA) + } + + @Test + fun testAppStringLocale_newSystemLocale_defLangUnmatching_notify_notifiesProvider() { + forceDefaultLocale(Locale.ROOT) + val localeProvider = localeController.retrieveAppStringDisplayLocale(LANGUAGE_UNSPECIFIED) + val monitor = monitorFactory.createMonitor(localeProvider) + monitor.waitForNextSuccessResult() + + forceDefaultLocale(MONACO_FRENCH_LOCALE) + localeController.notifyPotentialLocaleChange() + + // Changing to an unmatched language should update the provider. + val locale = monitor.waitForNextSuccessResult() + val context = locale.localeContext + assertThat(context.languageDefinition.language).isEqualTo(LANGUAGE_UNSPECIFIED) + assertThat(context.languageDefinition.appStringId.ietfBcp47Id.ietfLanguageTag).isEqualTo("fr") + assertThat(context.regionDefinition.region).isEqualTo(REGION_UNSPECIFIED) + assertThat(context.regionDefinition.regionId.ietfRegionTag).isEqualTo("MC") + } + + /* Tests for retrieveWrittenTranslationsLocale. */ + + @Test + fun testContentLocale_rootLocale_noConfigLocale_printsErrorForDefaulting() { + context.applicationContext.resources.configuration.setLocale(null) + Locale.setDefault(Locale.ROOT) + + monitorFactory.waitForNextSuccessfulResult( + localeController.retrieveWrittenTranslationsLocale(ENGLISH) + ) + + assertThat(retrieveLogcatLogs()) + .contains("No locales defined for application context. Defaulting to default Locale.") + } + + @Test + fun testContentLocale_rootLocale_english_hasCorrectLanguageAndFallbackDefinitions() { + forceDefaultLocale(Locale.ROOT) + + val localeProvider = localeController.retrieveWrittenTranslationsLocale(ENGLISH) + + val locale = monitorFactory.waitForNextSuccessfulResult(localeProvider) + val context = locale.localeContext + assertThat(context.languageDefinition.language).isEqualTo(ENGLISH) + assertThat(context.languageDefinition.fallbackMacroLanguage).isEqualTo(LANGUAGE_UNSPECIFIED) + assertThat(context.languageDefinition.appStringId.ietfBcp47Id.ietfLanguageTag).isEqualTo("en") + assertThat(context.hasFallbackLanguageDefinition()).isFalse() + assertThat(context.regionDefinition.region).isEqualTo(REGION_UNSPECIFIED) + } + + @Test + fun testContentLocale_usLocale_english_hasMatchedUsRegion() { + forceDefaultLocale(Locale.US) + + val localeProvider = localeController.retrieveWrittenTranslationsLocale(ENGLISH) + + val locale = monitorFactory.waitForNextSuccessfulResult(localeProvider) + assertThat(locale.localeContext.regionDefinition.region).isEqualTo(UNITED_STATES) + } + + @Test + fun testContentLocale_monacoLocale_english_hasUnmatchedMonacoRegion() { + forceDefaultLocale(MONACO_FRENCH_LOCALE) + + val localeProvider = localeController.retrieveWrittenTranslationsLocale(ENGLISH) + + val locale = monitorFactory.waitForNextSuccessfulResult(localeProvider) + assertThat(locale.localeContext.regionDefinition.region).isEqualTo(REGION_UNSPECIFIED) + } + + @Test + fun testContentLocale_indiaLocale_english_printsRegionLanguageMismatchError() { + forceDefaultLocale(INDIA_HINDI_LOCALE) + + monitorFactory.waitForNextSuccessfulResult( + localeController.retrieveWrittenTranslationsLocale(ENGLISH) + ) + + assertThat(retrieveLogcatLogs()) + .contains( + "Notice: selected language $ENGLISH is not part of the corresponding region matched to" + + " this locale: $INDIA (ID: IN) (supported languages: [$HINDI, $HINGLISH]" + ) + } + + @Test + fun testContentLocale_englishUsLocale_defaultLang_returnsFailure() { + forceDefaultLocale(Locale.US) + + val localeProvider = localeController.retrieveWrittenTranslationsLocale(LANGUAGE_UNSPECIFIED) + + // English should be matched per the system locale. + val error = monitorFactory.waitForNextFailureResult(localeProvider) + assertThat(error) + .hasMessageThat() + .contains( + "Language $LANGUAGE_UNSPECIFIED for usage $CONTENT_STRINGS doesn't match supported" + + " language definitions" + ) + } + + @Test + fun testContentLocale_englishUsLocale_defaultLang_printsError() { + forceDefaultLocale(Locale.US) + + val monitor = monitorFactory.createMonitor( + localeController.retrieveWrittenTranslationsLocale(LANGUAGE_UNSPECIFIED) + ) + monitor.waitForNextResult() + + assertThat(retrieveLogcatLogs()) + .contains("Encountered unmatched language: $LANGUAGE_UNSPECIFIED") + } + + @Test + fun testContentLocale_newSystemLocale_doesNotNotifyProvider() { + forceDefaultLocale(Locale.US) + val localeProvider = localeController.retrieveWrittenTranslationsLocale(ENGLISH) + val monitor = monitorFactory.createMonitor(localeProvider) + monitor.waitForNextSuccessResult() + + forceDefaultLocale(Locale.CANADA) + + // Simply changing the locale shouldn't change the provider. + monitor.verifyProviderIsNotUpdated() + } + + @Test + fun testContentLocale_notifyPotentialLocaleChange_doesNotNotifyProvider() { + forceDefaultLocale(Locale.US) + val localeProvider = localeController.retrieveWrittenTranslationsLocale(ENGLISH) + val monitor = monitorFactory.createMonitor(localeProvider) + monitor.waitForNextSuccessResult() + + localeController.notifyPotentialLocaleChange() + + // Just notifying isn't sufficient to trigger a change in the provider. + monitor.verifyProviderIsNotUpdated() + } + + @Test + fun testContentLocale_newSystemLocale_sameRegion_notify_doesNotNotifyProvider() { + forceDefaultLocale(Locale.US) + val localeProvider = localeController.retrieveWrittenTranslationsLocale(ENGLISH) + val monitor = monitorFactory.createMonitor(localeProvider) + monitor.waitForNextSuccessResult() + + forceDefaultLocale(Locale.US) + localeController.notifyPotentialLocaleChange() + + // Changing & notifying for the same locale should not change the provider. + monitor.verifyProviderIsNotUpdated() + } + + @Test + fun testContentLocale_newSystemLocale_newRegion_notify_notifiesProvider() { + forceDefaultLocale(Locale.US) + val localeProvider = localeController.retrieveWrittenTranslationsLocale(ENGLISH) + val monitor = monitorFactory.createMonitor(localeProvider) + monitor.waitForNextSuccessResult() + + forceDefaultLocale(Locale.CANADA) + localeController.notifyPotentialLocaleChange() + + // Changing to a new region (but keeping the same language) should update the region. + val locale = monitor.waitForNextSuccessResult() + val context = locale.localeContext + assertThat(context.languageDefinition.language).isEqualTo(ENGLISH) + assertThat(context.regionDefinition.region).isEqualTo(REGION_UNSPECIFIED) + assertThat(context.regionDefinition.regionId.ietfRegionTag).isEqualTo("CA") + } + + /* Tests for retrieveAudioTranslationsLocale. */ + + @Test + fun testAudioLocale_rootLocale_noConfigLocale_printsErrorForDefaulting() { + context.applicationContext.resources.configuration.setLocale(null) + Locale.setDefault(Locale.ROOT) + + monitorFactory.waitForNextSuccessfulResult( + localeController.retrieveAudioTranslationsLocale(ENGLISH) + ) + + assertThat(retrieveLogcatLogs()) + .contains("No locales defined for application context. Defaulting to default Locale.") + } + + @Test + fun testAudioLocale_rootLocale_english_hasCorrectLanguageAndFallbackDefinitions() { + forceDefaultLocale(Locale.ROOT) + + val localeProvider = localeController.retrieveAudioTranslationsLocale(ENGLISH) + + val locale = monitorFactory.waitForNextSuccessfulResult(localeProvider) + val context = locale.localeContext + assertThat(context.languageDefinition.language).isEqualTo(ENGLISH) + assertThat(context.languageDefinition.fallbackMacroLanguage).isEqualTo(LANGUAGE_UNSPECIFIED) + assertThat(context.languageDefinition.appStringId.ietfBcp47Id.ietfLanguageTag).isEqualTo("en") + assertThat(context.hasFallbackLanguageDefinition()).isFalse() + assertThat(context.regionDefinition.region).isEqualTo(REGION_UNSPECIFIED) + } + + @Test + fun testAudioLocale_usLocale_english_hasMatchedUsRegion() { + forceDefaultLocale(Locale.US) + + val localeProvider = localeController.retrieveAudioTranslationsLocale(ENGLISH) + + val locale = monitorFactory.waitForNextSuccessfulResult(localeProvider) + assertThat(locale.localeContext.regionDefinition.region).isEqualTo(UNITED_STATES) + } + + @Test + fun testAudioLocale_monacoLocale_english_hasUnmatchedMonacoRegion() { + forceDefaultLocale(MONACO_FRENCH_LOCALE) + + val localeProvider = localeController.retrieveAudioTranslationsLocale(ENGLISH) + + val locale = monitorFactory.waitForNextSuccessfulResult(localeProvider) + assertThat(locale.localeContext.regionDefinition.region).isEqualTo(REGION_UNSPECIFIED) + } + + @Test + fun testAudioLocale_indiaLocale_english_printsRegionLanguageMismatchError() { + forceDefaultLocale(INDIA_HINDI_LOCALE) + + monitorFactory.waitForNextSuccessfulResult( + localeController.retrieveAudioTranslationsLocale(ENGLISH) + ) + + assertThat(retrieveLogcatLogs()) + .contains( + "Notice: selected language $ENGLISH is not part of the corresponding region matched to" + + " this locale: $INDIA (ID: IN) (supported languages: [$HINDI, $HINGLISH]" + ) + } + + @Test + fun testAudioLocale_englishUsLocale_defaultLang_returnsFailure() { + forceDefaultLocale(Locale.US) + + val localeProvider = localeController.retrieveAudioTranslationsLocale(LANGUAGE_UNSPECIFIED) + + // English should be matched per the system locale. + val error = monitorFactory.waitForNextFailureResult(localeProvider) + assertThat(error) + .hasMessageThat() + .contains( + "Language $LANGUAGE_UNSPECIFIED for usage $AUDIO_TRANSLATIONS doesn't match supported" + + " language definitions" + ) + } + + @Test + fun testAudioLocale_englishUsLocale_defaultLang_printsError() { + forceDefaultLocale(Locale.US) + + val monitor = monitorFactory.createMonitor( + localeController.retrieveAudioTranslationsLocale(LANGUAGE_UNSPECIFIED) + ) + monitor.waitForNextResult() + + assertThat(retrieveLogcatLogs()) + .contains("Encountered unmatched language: $LANGUAGE_UNSPECIFIED") + } + + @Test + fun testAudioLocale_newSystemLocale_doesNotNotifyProvider() { + forceDefaultLocale(Locale.US) + val localeProvider = localeController.retrieveAudioTranslationsLocale(ENGLISH) + val monitor = monitorFactory.createMonitor(localeProvider) + monitor.waitForNextSuccessResult() + + forceDefaultLocale(Locale.CANADA) + + // Simply changing the locale shouldn't change the provider. + monitor.verifyProviderIsNotUpdated() + } + + @Test + fun testAudioLocale_notifyPotentialLocaleChange_doesNotNotifyProvider() { + forceDefaultLocale(Locale.US) + val localeProvider = localeController.retrieveAudioTranslationsLocale(ENGLISH) + val monitor = monitorFactory.createMonitor(localeProvider) + monitor.waitForNextSuccessResult() + + localeController.notifyPotentialLocaleChange() + + // Just notifying isn't sufficient to trigger a change in the provider. + monitor.verifyProviderIsNotUpdated() + } + + @Test + fun testAudioLocale_newSystemLocale_sameRegion_notify_doesNotNotifyProvider() { + forceDefaultLocale(Locale.US) + val localeProvider = localeController.retrieveAudioTranslationsLocale(ENGLISH) + val monitor = monitorFactory.createMonitor(localeProvider) + monitor.waitForNextSuccessResult() + + forceDefaultLocale(Locale.US) + localeController.notifyPotentialLocaleChange() + + // Changing & notifying for the same locale should not change the provider. + monitor.verifyProviderIsNotUpdated() + } + + @Test + fun testAudioLocale_newSystemLocale_newRegion_notify_notifiesProvider() { + forceDefaultLocale(Locale.US) + val localeProvider = localeController.retrieveAudioTranslationsLocale(ENGLISH) + val monitor = monitorFactory.createMonitor(localeProvider) + monitor.waitForNextSuccessResult() + + forceDefaultLocale(Locale.CANADA) + localeController.notifyPotentialLocaleChange() + + // Changing to a new region (but keeping the same language) should update the region. + val locale = monitor.waitForNextSuccessResult() + val context = locale.localeContext + assertThat(context.languageDefinition.language).isEqualTo(ENGLISH) + assertThat(context.regionDefinition.region).isEqualTo(REGION_UNSPECIFIED) + assertThat(context.regionDefinition.regionId.ietfRegionTag).isEqualTo("CA") + } + + /* Tests for getSystemLocaleProfile. */ + + @Test + fun testSystemLanguage_rootLocale_returnsUnspecifiedLanguage() { + forceDefaultLocale(Locale.ROOT) + + val languageProvider = localeController.retrieveSystemLanguage() + + val language = monitorFactory.waitForNextSuccessfulResult(languageProvider) + assertThat(language).isEqualTo(LANGUAGE_UNSPECIFIED) + } + + @Test + fun testSystemLanguage_usEnglishLocale_returnsEnglish() { + forceDefaultLocale(Locale.US) + + val languageProvider = localeController.retrieveSystemLanguage() + + val language = monitorFactory.waitForNextSuccessfulResult(languageProvider) + assertThat(language).isEqualTo(ENGLISH) + } + + @Test + fun testSystemLanguage_indiaEnglishLocale_returnsEnglish() { + forceDefaultLocale(INDIA_ENGLISH_LOCALE) + + val languageProvider = localeController.retrieveSystemLanguage() + + val language = monitorFactory.waitForNextSuccessfulResult(languageProvider) + assertThat(language).isEqualTo(ENGLISH) + } + + @Test + fun testSystemLanguage_frenchLocale_returnsUnspecifiedLanguage() { + forceDefaultLocale(Locale.FRENCH) + + val languageProvider = localeController.retrieveSystemLanguage() + + val language = monitorFactory.waitForNextSuccessfulResult(languageProvider) + assertThat(language).isEqualTo(LANGUAGE_UNSPECIFIED) + } + + @Test + fun testSystemLanguage_notifyPotentialLocaleChange_doesNotNotifyProvider() { + forceDefaultLocale(Locale.FRENCH) + val languageProvider = localeController.retrieveSystemLanguage() + val monitor = monitorFactory.createMonitor(languageProvider) + monitor.waitForNextResult() + + localeController.notifyPotentialLocaleChange() + + // The provider shouldn't be updated just for a notification with no state change. + monitor.verifyProviderIsNotUpdated() + } + + @Test + fun testSystemLanguage_changeLocale_doesNotNotifyProvider() { + forceDefaultLocale(Locale.FRENCH) + val languageProvider = localeController.retrieveSystemLanguage() + val monitor = monitorFactory.createMonitor(languageProvider) + monitor.waitForNextResult() + + forceDefaultLocale(Locale.ENGLISH) + + // Changing the locale isn't sufficient to update the provider without a notification (since + // Android doesn't provide a way to monitor for locale without utilizing the configuration + // change system). + monitor.verifyProviderIsNotUpdated() + } + + @Test + fun testSystemLanguage_englishToPortuguese_notify_notifiesProvider() { + forceDefaultLocale(Locale.US) + val languageProvider = localeController.retrieveSystemLanguage() + val monitor = monitorFactory.createMonitor(languageProvider) + monitor.waitForNextResult() + + forceDefaultLocale(BRAZIL_PORTUGUESE_LOCALE) + localeController.notifyPotentialLocaleChange() + + // The notify + locale change should change the reported system language. + val language = monitor.waitForNextSuccessResult() + assertThat(language).isEqualTo(BRAZILIAN_PORTUGUESE) + } + + @Test + fun testSystemLanguage_englishToFrench_notify_notifiesProvider() { + forceDefaultLocale(Locale.US) + val languageProvider = localeController.retrieveSystemLanguage() + val monitor = monitorFactory.createMonitor(languageProvider) + monitor.waitForNextResult() + + forceDefaultLocale(Locale.FRENCH) + localeController.notifyPotentialLocaleChange() + + // A known language (English) can change to an unspecified language. + val language = monitor.waitForNextSuccessResult() + assertThat(language).isEqualTo(LANGUAGE_UNSPECIFIED) + } + + @Test + fun testSystemLanguage_frenchToEnglish_notify_notifiesProvider() { + forceDefaultLocale(Locale.FRENCH) + val languageProvider = localeController.retrieveSystemLanguage() + val monitor = monitorFactory.createMonitor(languageProvider) + monitor.waitForNextResult() + + forceDefaultLocale(Locale.US) + localeController.notifyPotentialLocaleChange() + + // An unspecified language can change to a known language (English). + val language = monitor.waitForNextSuccessResult() + assertThat(language).isEqualTo(ENGLISH) + } + + @Test + fun testSystemLanguage_frenchToGerman_notify_doesNotNotifyProvider() { + forceDefaultLocale(Locale.FRENCH) + val languageProvider = localeController.retrieveSystemLanguage() + val monitor = monitorFactory.createMonitor(languageProvider) + monitor.waitForNextResult() + + forceDefaultLocale(Locale.GERMAN) + localeController.notifyPotentialLocaleChange() + + // Changing from one unspecified language to another shouldn't notify the provider since the + // outcome is the same. + monitor.verifyProviderIsNotUpdated() + } + + /* Tests for setAsDefault. */ + + @Test + fun testSetAsDefault_customLocaleImpl_throwsException() { + val exception = assertThrows(IllegalStateException::class) { + localeController.setAsDefault(mockDisplayLocale, Configuration()) + } + + assertThat(exception).hasMessageThat().contains("Invalid display locale type passed in") + } + + @Test + fun testSetAsDefault_englishDisplayLocale_changesLocaleInConfiguration() { + forceDefaultLocale(Locale.ROOT) + val configuration = Configuration() + val locale = retrieveAppStringDisplayLocale(ENGLISH) + // Sanity check (to validate the configuration was changed). + assertThat(configuration.locales.hasLanguage("en")).isFalse() + + localeController.setAsDefault(locale, configuration) + + // Verify that the configuration's locale has changed. + assertThat(configuration.locales.hasLanguage("en")).isTrue() + } + + @Test + fun testSetAsDefault_englishDisplayLocale_changesSystemDefaultLocale() { + forceDefaultLocale(Locale.ROOT) + val locale = retrieveAppStringDisplayLocale(ENGLISH) + // Sanity check (to validate the system locale was changed). + assertThat(Locale.getDefault().language).isNotEqualTo("en") + + localeController.setAsDefault(locale, Configuration()) + + // Verify that the default locale has changed. + assertThat(Locale.getDefault().language).isEqualTo("en") + } + + @Test + fun testSetAsDefault_englishDisplayLocale_triggersChangeInSystemLanguageProvider() { + forceDefaultLocale(Locale.ROOT) + val locale = retrieveAppStringDisplayLocale(ENGLISH) + val monitor = monitorFactory.createMonitor(localeController.retrieveSystemLanguage()) + // Sanity check (to validate the system language actually changes). + assertThat(monitor.waitForNextSuccessResult()).isNotEqualTo(ENGLISH) + + localeController.setAsDefault(locale, Configuration()) + + // Verify that the system language provider is notified with the language change. + assertThat(monitor.waitForNextSuccessResult()).isEqualTo(ENGLISH) + } + + private fun setUpTestApplicationComponent() { + ApplicationProvider.getApplicationContext().inject(this) + } + + private fun forceDefaultLocale(locale: Locale) { + context.applicationContext.resources.configuration.setLocale(locale) + Locale.setDefault(locale) + } + + private fun retrieveAppStringDisplayLocale(language: OppiaLanguage): OppiaLocale.DisplayLocale { + val localeProvider = localeController.retrieveAppStringDisplayLocale(language) + return monitorFactory.waitForNextSuccessfulResult(localeProvider) + } + + private fun retrieveLogcatLogs(): List = ShadowLog.getLogs().map { it.msg } + + // TODO(#89): Move this to a common test application component. + @Module + class TestModule { + @Provides + @Singleton + fun provideContext(application: Application): Context { + return application + } + } + + // TODO(#89): Move this to a common test application component. + @Singleton + @Component( + modules = [ + TestModule::class, LogStorageModule::class, NetworkConnectionUtilDebugModule::class, + TestLogReportingModule::class, LoggerModule::class, TestDispatcherModule::class, + LocaleProdModule::class, FakeOppiaClockModule::class, RobolectricModule::class, + AssetModule::class + ] + ) + interface TestApplicationComponent : DataProvidersInjector { + @Component.Builder + interface Builder { + @BindsInstance + fun setApplication(application: Application): Builder + + fun build(): TestApplicationComponent + } + + fun inject(localeControllerTest: LocaleControllerTest) + } + + class TestApplication : Application(), DataProvidersInjectorProvider { + private val component: TestApplicationComponent by lazy { + DaggerLocaleControllerTest_TestApplicationComponent.builder() + .setApplication(this) + .build() + } + + fun inject(localeControllerTest: LocaleControllerTest) { + component.inject(localeControllerTest) + } + + override fun getDataProvidersInjector(): DataProvidersInjector = component + } + + private companion object { + private val MONACO_FRENCH_LOCALE = Locale("fr", "MC") + private val INDIA_HINDI_LOCALE = Locale("hi", "IN") + private val INDIA_ENGLISH_LOCALE = Locale("en", "IN") + private val BRAZIL_PORTUGUESE_LOCALE = Locale("pt", "BR") + + private fun LocaleList.toList(): List = (0 until size()).map { this[it] } + private fun LocaleList.hasLanguage(languageCode: String): Boolean = + toList().any { it.language == languageCode } + } +} diff --git a/domain/src/test/java/org/oppia/android/domain/question/QuestionAssessmentProgressControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/question/QuestionAssessmentProgressControllerTest.kt index 015d60584d4..096a377c889 100644 --- a/domain/src/test/java/org/oppia/android/domain/question/QuestionAssessmentProgressControllerTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/question/QuestionAssessmentProgressControllerTest.kt @@ -58,6 +58,7 @@ 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.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.data.AsyncResult import org.oppia.android.util.data.DataProviders.Companion.toLiveData @@ -1882,7 +1883,7 @@ class QuestionAssessmentProgressControllerTest { ImageClickInputModule::class, LogStorageModule::class, TestDispatcherModule::class, RatioInputModule::class, RobolectricModule::class, FakeOppiaClockModule::class, CachingTestModule::class, HintsAndSolutionConfigModule::class, - HintsAndSolutionProdModule::class, NetworkConnectionUtilDebugModule::class + HintsAndSolutionProdModule::class, NetworkConnectionUtilDebugModule::class, AssetModule::class ] ) interface TestApplicationComponent : DataProvidersInjector { diff --git a/domain/src/test/java/org/oppia/android/domain/question/QuestionTrainingControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/question/QuestionTrainingControllerTest.kt index e243d66b9e7..446d71be82c 100644 --- a/domain/src/test/java/org/oppia/android/domain/question/QuestionTrainingControllerTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/question/QuestionTrainingControllerTest.kt @@ -47,6 +47,7 @@ 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.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.data.AsyncResult import org.oppia.android.util.data.DataProviders.Companion.toLiveData @@ -336,7 +337,7 @@ class QuestionTrainingControllerTest { LogStorageModule::class, TestDispatcherModule::class, RatioInputModule::class, RobolectricModule::class, FakeOppiaClockModule::class, CachingTestModule::class, HintsAndSolutionConfigModule::class, HintsAndSolutionProdModule::class, - NetworkConnectionUtilDebugModule::class + NetworkConnectionUtilDebugModule::class, AssetModule::class ] ) interface TestApplicationComponent : DataProvidersInjector { diff --git a/domain/src/test/java/org/oppia/android/domain/topic/TopicControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/topic/TopicControllerTest.kt index a64946d8ff2..e81bbbcf07b 100755 --- a/domain/src/test/java/org/oppia/android/domain/topic/TopicControllerTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/topic/TopicControllerTest.kt @@ -42,6 +42,7 @@ import org.oppia.android.testing.threading.TestCoroutineDispatchers import org.oppia.android.testing.threading.TestDispatcherModule import org.oppia.android.testing.time.FakeOppiaClock import org.oppia.android.testing.time.FakeOppiaClockModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.CacheAssetsLocally import org.oppia.android.util.caching.LoadLessonProtosFromAssets import org.oppia.android.util.caching.TopicListToCache @@ -56,7 +57,6 @@ import org.oppia.android.util.logging.LogLevel import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule import org.robolectric.annotation.Config import org.robolectric.annotation.LooperMode -import java.io.FileNotFoundException import java.lang.IllegalStateException import javax.inject.Inject import javax.inject.Singleton @@ -1143,7 +1143,8 @@ class TopicControllerTest { val exception = fakeExceptionLogger.getMostRecentException() - assertThat(exception).isInstanceOf(FileNotFoundException::class.java) + assertThat(exception).isInstanceOf(IllegalStateException::class.java) + assertThat(exception).hasMessageThat().contains("Asset doesn't exist") } private fun setUpTestApplicationComponent() { @@ -1280,7 +1281,7 @@ class TopicControllerTest { modules = [ TestModule::class, TestLogReportingModule::class, LogStorageModule::class, TestDispatcherModule::class, RobolectricModule::class, FakeOppiaClockModule::class, - NetworkConnectionUtilDebugModule::class + NetworkConnectionUtilDebugModule::class, AssetModule::class ] ) interface TestApplicationComponent : DataProvidersInjector { diff --git a/domain/src/test/java/org/oppia/android/domain/topic/TopicListControllerTest.kt b/domain/src/test/java/org/oppia/android/domain/topic/TopicListControllerTest.kt index 95e5ea469e9..457c4c7f36c 100644 --- a/domain/src/test/java/org/oppia/android/domain/topic/TopicListControllerTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/topic/TopicListControllerTest.kt @@ -36,6 +36,7 @@ import org.oppia.android.testing.threading.TestCoroutineDispatchers import org.oppia.android.testing.threading.TestDispatcherModule import org.oppia.android.testing.time.FakeOppiaClock import org.oppia.android.testing.time.FakeOppiaClockModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.CacheAssetsLocally import org.oppia.android.util.caching.LoadLessonProtosFromAssets import org.oppia.android.util.caching.TopicListToCache @@ -872,7 +873,7 @@ class TopicListControllerTest { modules = [ TestModule::class, TestLogReportingModule::class, LogStorageModule::class, TestDispatcherModule::class, RobolectricModule::class, FakeOppiaClockModule::class, - NetworkConnectionUtilDebugModule::class + NetworkConnectionUtilDebugModule::class, AssetModule::class ] ) interface TestApplicationComponent : DataProvidersInjector { diff --git a/domain/src/test/java/org/oppia/android/domain/translation/BUILD.bazel b/domain/src/test/java/org/oppia/android/domain/translation/BUILD.bazel new file mode 100644 index 00000000000..d72e295975e --- /dev/null +++ b/domain/src/test/java/org/oppia/android/domain/translation/BUILD.bazel @@ -0,0 +1,36 @@ +""" +Tests for domain components pertaining to managing translations. +""" + +load("@dagger//:workspace_defs.bzl", "dagger_rules") +load("//:oppia_android_test.bzl", "oppia_android_test") + +oppia_android_test( + name = "TranslationControllerTest", + srcs = ["TranslationControllerTest.kt"], + custom_package = "org.oppia.android.domain.translation", + test_class = "org.oppia.android.domain.translation.TranslationControllerTest", + test_manifest = "//domain:test_manifest", + deps = [ + ":dagger", + "//domain/src/main/java/org/oppia/android/domain/locale:locale_controller", + "//domain/src/main/java/org/oppia/android/domain/translation:translation_controller", + "//model:languages_java_proto_lite", + "//testing", + "//testing/src/main/java/org/oppia/android/testing/data:data_provider_test_monitor", + "//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", + "//third_party:junit_junit", + "//third_party:org_mockito_mockito-core", + "//third_party:org_robolectric_robolectric", + "//third_party:robolectric_android-all", + "//utility/src/main/java/org/oppia/android/util/caching:asset_prod_module", + "//utility/src/main/java/org/oppia/android/util/locale:prod_module", + "//utility/src/main/java/org/oppia/android/util/networking:debug_module", + ], +) + +dagger_rules() 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 new file mode 100644 index 00000000000..936f34efd73 --- /dev/null +++ b/domain/src/test/java/org/oppia/android/domain/translation/TranslationControllerTest.kt @@ -0,0 +1,1146 @@ +package org.oppia.android.domain.translation + +import android.app.Application +import android.content.Context +import android.content.res.Configuration +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import dagger.BindsInstance +import dagger.Component +import dagger.Module +import dagger.Provides +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.oppia.android.app.model.AppLanguageSelection +import org.oppia.android.app.model.AudioTranslationLanguageSelection +import org.oppia.android.app.model.LanguageSupportDefinition.LanguageId.LanguageTypeCase.IETF_BCP47_ID +import org.oppia.android.app.model.OppiaLanguage +import org.oppia.android.app.model.OppiaLanguage.BRAZILIAN_PORTUGUESE +import org.oppia.android.app.model.OppiaLanguage.ENGLISH +import org.oppia.android.app.model.OppiaLanguage.HINDI +import org.oppia.android.app.model.OppiaLanguage.LANGUAGE_UNSPECIFIED +import org.oppia.android.app.model.OppiaLocaleContext.LanguageUsageMode.APP_STRINGS +import org.oppia.android.app.model.OppiaLocaleContext.LanguageUsageMode.AUDIO_TRANSLATIONS +import org.oppia.android.app.model.OppiaLocaleContext.LanguageUsageMode.CONTENT_STRINGS +import org.oppia.android.app.model.OppiaRegion.BRAZIL +import org.oppia.android.app.model.OppiaRegion.REGION_UNSPECIFIED +import org.oppia.android.app.model.OppiaRegion.UNITED_STATES +import org.oppia.android.app.model.ProfileId +import org.oppia.android.app.model.SubtitledHtml +import org.oppia.android.app.model.SubtitledUnicode +import org.oppia.android.app.model.Translation +import org.oppia.android.app.model.WrittenTranslationContext +import org.oppia.android.app.model.WrittenTranslationLanguageSelection +import org.oppia.android.domain.locale.LocaleController +import org.oppia.android.domain.oppialogger.LogStorageModule +import org.oppia.android.testing.TestLogReportingModule +import org.oppia.android.testing.data.DataProviderTestMonitor +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.caching.AssetModule +import org.oppia.android.util.data.DataProvidersInjector +import org.oppia.android.util.data.DataProvidersInjectorProvider +import org.oppia.android.util.locale.LocaleProdModule +import org.oppia.android.util.locale.OppiaLocale +import org.oppia.android.util.logging.LoggerModule +import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import java.util.Locale +import javax.inject.Inject +import javax.inject.Singleton + +/** Tests for [TranslationController]. */ +// FunctionName: test names are conventionally named with underscores. +// SameParameterValue: tests should have specific context included/excluded for readability. +@Suppress("FunctionName", "SameParameterValue") +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +@Config(application = TranslationControllerTest.TestApplication::class) +class TranslationControllerTest { + @Inject + lateinit var translationController: TranslationController + + @Inject + lateinit var localeController: LocaleController + + @Inject + lateinit var monitorFactory: DataProviderTestMonitor.Factory + + @Inject + lateinit var context: Context + + @Before + fun setUp() { + setUpTestApplicationComponent() + } + + /* Tests for getSystemLanguageLocale */ + + @Test + fun testGetSystemLanguageLocale_rootLocale_returnsLocaleWithBlankContext() { + forceDefaultLocale(Locale.ROOT) + + val localeProvider = translationController.getSystemLanguageLocale() + + 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(LANGUAGE_UNSPECIFIED) + assertThat(appStringId.languageTypeCase).isEqualTo(IETF_BCP47_ID) + assertThat(appStringId.ietfBcp47Id.ietfLanguageTag).isEmpty() + assertThat(appStringId.androidResourcesLanguageId.languageCode).isEmpty() + assertThat(context.regionDefinition.region).isEqualTo(REGION_UNSPECIFIED) + } + + @Test + fun testGetSystemLanguageLocale_usLocale_returnsLocaleWithEnglishContext() { + forceDefaultLocale(Locale.US) + + val localeProvider = translationController.getSystemLanguageLocale() + + val locale = monitorFactory.waitForNextSuccessfulResult(localeProvider) + val context = locale.localeContext + assertThat(context.usageMode).isEqualTo(APP_STRINGS) + assertThat(context.languageDefinition.language).isEqualTo(ENGLISH) + assertThat(context.regionDefinition.region).isEqualTo(UNITED_STATES) + } + + @Test + fun testGetSystemLanguageLocale_updateLocaleToIndia_updatesProviderWithNewLocale() { + forceDefaultLocale(Locale.US) + val localeProvider = translationController.getSystemLanguageLocale() + val monitor = monitorFactory.createMonitor(localeProvider) + monitor.waitForNextSuccessResult() + + localeController.setAsDefault(createDisplayLocaleForLanguage(HINDI), Configuration()) + + // Verify that the provider has changed. + val locale = monitor.waitForNextSuccessResult() + val context = locale.localeContext + assertThat(context.usageMode).isEqualTo(APP_STRINGS) + assertThat(context.languageDefinition.language).isEqualTo(HINDI) + assertThat(context.regionDefinition.region).isEqualTo(REGION_UNSPECIFIED) + } + + /* Tests for app language functions */ + + @Test + fun testUpdateAppLanguage_returnsSuccess() { + forceDefaultLocale(Locale.ROOT) + + val resultProvider = + translationController.updateAppLanguage(PROFILE_ID_0, createAppLanguageSelection(ENGLISH)) + + monitorFactory.waitForNextSuccessfulResult(resultProvider) + } + + @Test + fun testUpdateAppLanguage_notifiesProviderWithChange() { + forceDefaultLocale(Locale.ROOT) + val languageProvider = translationController.getAppLanguage(PROFILE_ID_0) + val langMonitor = monitorFactory.createMonitor(languageProvider) + langMonitor.waitForNextSuccessResult() + + // The result must be observed immediately otherwise it won't execute (which will result in the + // language not being updated). + val resultProvider = + translationController.updateAppLanguage(PROFILE_ID_0, createAppLanguageSelection(ENGLISH)) + val updateMonitor = monitorFactory.createMonitor(resultProvider) + + updateMonitor.waitForNextSuccessResult() + langMonitor.ensureNextResultIsSuccess() + } + + @Test + fun testGetAppLanguage_uninitialized_returnsSystemLanguage() { + forceDefaultLocale(Locale.ROOT) + + val languageProvider = translationController.getAppLanguage(PROFILE_ID_0) + + val language = monitorFactory.waitForNextSuccessfulResult(languageProvider) + assertThat(language).isEqualTo(LANGUAGE_UNSPECIFIED) + } + + @Test + fun testGetAppLanguage_updateLanguageToEnglish_returnsEnglish() { + forceDefaultLocale(Locale.ROOT) + ensureAppLanguageIsUpdatedTo(PROFILE_ID_0, ENGLISH) + + val languageProvider = translationController.getAppLanguage(PROFILE_ID_0) + + val language = monitorFactory.waitForNextSuccessfulResult(languageProvider) + assertThat(language).isEqualTo(ENGLISH) + } + + @Test + fun testGetAppLanguage_updateLanguageToHindi_returnsHindi() { + forceDefaultLocale(Locale.ROOT) + ensureAppLanguageIsUpdatedTo(PROFILE_ID_0, HINDI) + + val languageProvider = translationController.getAppLanguage(PROFILE_ID_0) + + val language = monitorFactory.waitForNextSuccessfulResult(languageProvider) + assertThat(language).isEqualTo(HINDI) + } + + @Test + fun testGetAppLanguage_updateLanguageToUseSystem_returnsSystemLanguage() { + forceDefaultLocale(Locale.ENGLISH) + ensureAppLanguageIsUpdatedToUseSystem(PROFILE_ID_0) + + val languageProvider = translationController.getAppLanguage(PROFILE_ID_0) + + val language = monitorFactory.waitForNextSuccessfulResult(languageProvider) + assertThat(language).isEqualTo(ENGLISH) + } + + @Test + fun testGetAppLanguage_useSystemLang_updateLocale_notifiesProviderWithNewLanguage() { + forceDefaultLocale(Locale.ENGLISH) + ensureAppLanguageIsUpdatedToUseSystem(PROFILE_ID_0) + val languageProvider = translationController.getAppLanguage(PROFILE_ID_0) + val monitor = monitorFactory.createMonitor(languageProvider) + monitor.waitForNextSuccessResult() + + localeController.setAsDefault(createDisplayLocaleForLanguage(HINDI), Configuration()) + + // The language should be updated to English since the system language was changed. + val updatedLanguage = monitor.waitForNextSuccessResult() + assertThat(updatedLanguage).isEqualTo(HINDI) + } + + @Test + fun testGetAppLanguage_updateLanguageToEnglish_differentProfile_returnsDifferentLang() { + forceDefaultLocale(Locale.ENGLISH) + ensureAppLanguageIsUpdatedTo(PROFILE_ID_0, HINDI) + + val languageProvider = translationController.getAppLanguage(PROFILE_ID_1) + + // English is returned since the language is being fetched for a different profile. + val language = monitorFactory.waitForNextSuccessfulResult(languageProvider) + assertThat(language).isEqualTo(ENGLISH) + } + + @Test + fun testGetAppLanguageLocale_uninitialized_returnsLocaleWithSystemLanguage() { + forceDefaultLocale(Locale.ROOT) + + 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(LANGUAGE_UNSPECIFIED) + assertThat(appStringId.languageTypeCase).isEqualTo(IETF_BCP47_ID) + assertThat(appStringId.ietfBcp47Id.ietfLanguageTag).isEmpty() + assertThat(appStringId.androidResourcesLanguageId.languageCode).isEmpty() + assertThat(context.regionDefinition.region).isEqualTo(REGION_UNSPECIFIED) + } + + @Test + fun testGetAppLanguageLocale_updateLanguageToEnglish_returnsEnglishLocale() { + forceDefaultLocale(Locale.ROOT) + ensureAppLanguageIsUpdatedTo(PROFILE_ID_0, ENGLISH) + + val localeProvider = translationController.getAppLanguageLocale(PROFILE_ID_0) + + val locale = monitorFactory.waitForNextSuccessfulResult(localeProvider) + val context = locale.localeContext + assertThat(context.usageMode).isEqualTo(APP_STRINGS) + assertThat(context.languageDefinition.language).isEqualTo(ENGLISH) + // This region comes from the default locale. + assertThat(context.regionDefinition.region).isEqualTo(REGION_UNSPECIFIED) + } + + @Test + fun testGetAppLanguageLocale_updateLanguageToPortuguese_returnsPortugueseLocale() { + forceDefaultLocale(BRAZIL_ENGLISH_LOCALE) + ensureAppLanguageIsUpdatedTo(PROFILE_ID_0, BRAZILIAN_PORTUGUESE) + + val localeProvider = translationController.getAppLanguageLocale(PROFILE_ID_0) + + val locale = monitorFactory.waitForNextSuccessfulResult(localeProvider) + val context = locale.localeContext + assertThat(context.usageMode).isEqualTo(APP_STRINGS) + assertThat(context.languageDefinition.language).isEqualTo(BRAZILIAN_PORTUGUESE) + // This region comes from the default locale. + assertThat(context.regionDefinition.region).isEqualTo(BRAZIL) + } + + @Test + fun testGetAppLanguageLocale_updateLanguageToUseSystem_returnsSystemLanguageLocale() { + forceDefaultLocale(Locale.ENGLISH) + ensureAppLanguageIsUpdatedToUseSystem(PROFILE_ID_0) + + val localeProvider = translationController.getAppLanguageLocale(PROFILE_ID_0) + + val locale = monitorFactory.waitForNextSuccessfulResult(localeProvider) + val context = locale.localeContext + assertThat(context.usageMode).isEqualTo(APP_STRINGS) + assertThat(context.languageDefinition.language).isEqualTo(ENGLISH) + } + + @Test + fun testGetAppLanguageLocale_useSystemLang_updateLocale_notifiesProviderWithNewLocale() { + forceDefaultLocale(Locale.ENGLISH) + ensureAppLanguageIsUpdatedToUseSystem(PROFILE_ID_0) + val localeProvider = translationController.getAppLanguageLocale(PROFILE_ID_0) + val monitor = monitorFactory.createMonitor(localeProvider) + monitor.waitForNextSuccessResult() + + localeController.setAsDefault(createDisplayLocaleForLanguage(HINDI), Configuration()) + + // The language should be updated to English since the system language was changed. + val updateLocale = monitor.waitForNextSuccessResult() + val context = updateLocale.localeContext + assertThat(context.usageMode).isEqualTo(APP_STRINGS) + assertThat(context.languageDefinition.language).isEqualTo(HINDI) + } + + @Test + fun testGetAppLanguageLocale_updateLangToEnglish_differentProfile_returnsDifferentLocale() { + forceDefaultLocale(Locale.ENGLISH) + ensureAppLanguageIsUpdatedTo(PROFILE_ID_0, HINDI) + + val localeProvider = translationController.getAppLanguageLocale(PROFILE_ID_1) + + // English is returned since the language is being fetched for a different profile. + val locale = monitorFactory.waitForNextSuccessfulResult(localeProvider) + val context = locale.localeContext + assertThat(context.usageMode).isEqualTo(APP_STRINGS) + assertThat(context.languageDefinition.language).isEqualTo(ENGLISH) + } + + /* Tests for written translation content functions */ + + @Test + fun testUpdateWrittenContentLanguage_returnsSuccess() { + forceDefaultLocale(Locale.ROOT) + + val resultProvider = + translationController.updateWrittenTranslationContentLanguage( + PROFILE_ID_0, createWrittenTranslationLanguageSelection(ENGLISH) + ) + + monitorFactory.waitForNextSuccessfulResult(resultProvider) + } + + @Test + fun testUpdateWrittenContentLanguage_notifiesProviderWithChange() { + forceDefaultLocale(Locale.US) + val languageProvider = translationController.getWrittenTranslationContentLanguage(PROFILE_ID_0) + val langMonitor = monitorFactory.createMonitor(languageProvider) + langMonitor.waitForNextSuccessResult() + + // The result must be observed immediately otherwise it won't execute (which will result in the + // language not being updated). + val resultProvider = + translationController.updateWrittenTranslationContentLanguage( + PROFILE_ID_0, createWrittenTranslationLanguageSelection(BRAZILIAN_PORTUGUESE) + ) + val updateMonitor = monitorFactory.createMonitor(resultProvider) + + updateMonitor.waitForNextSuccessResult() + langMonitor.ensureNextResultIsSuccess() + } + + @Test + fun testGetWrittenContentLang_uninitialized_rootLocale_returnsFailure() { + forceDefaultLocale(Locale.ROOT) + + val languageProvider = translationController.getWrittenTranslationContentLanguage(PROFILE_ID_0) + + val error = monitorFactory.waitForNextFailureResult(languageProvider) + assertThat(error).hasMessageThat().contains("doesn't match supported language definitions") + } + + @Test + fun testGetWrittenContentLang_uninitialized_englishLocale_returnsSystemLanguage() { + forceDefaultLocale(Locale.US) + + val languageProvider = translationController.getWrittenTranslationContentLanguage(PROFILE_ID_0) + + val language = monitorFactory.waitForNextSuccessfulResult(languageProvider) + assertThat(language).isEqualTo(ENGLISH) + } + + @Test + fun testGetWrittenContentLang_updateLanguageToEnglish_returnsEnglish() { + forceDefaultLocale(Locale.ROOT) + ensureWrittenTranslationsLanguageIsUpdatedTo(PROFILE_ID_0, ENGLISH) + + val languageProvider = translationController.getWrittenTranslationContentLanguage(PROFILE_ID_0) + + val language = monitorFactory.waitForNextSuccessfulResult(languageProvider) + assertThat(language).isEqualTo(ENGLISH) + } + + @Test + fun testGetWrittenContentLang_updateLanguageToHindi_returnsHindi() { + forceDefaultLocale(Locale.ROOT) + ensureWrittenTranslationsLanguageIsUpdatedTo(PROFILE_ID_0, HINDI) + + val languageProvider = translationController.getWrittenTranslationContentLanguage(PROFILE_ID_0) + + val language = monitorFactory.waitForNextSuccessfulResult(languageProvider) + assertThat(language).isEqualTo(HINDI) + } + + @Test + fun testGetWrittenContentLang_updateLanguageToUseApp_returnsAppLanguage() { + // First, initialize the language to Hindi before overwriting to use the app language. + ensureWrittenTranslationsLanguageIsUpdatedTo(PROFILE_ID_0, HINDI) + ensureAppLanguageIsUpdatedToUseSystem(PROFILE_ID_0) + forceDefaultLocale(Locale.US) + ensureWrittenTranslationsLanguageIsUpdatedToUseApp(PROFILE_ID_0) + + val languageProvider = translationController.getWrittenTranslationContentLanguage(PROFILE_ID_0) + + val language = monitorFactory.waitForNextSuccessfulResult(languageProvider) + assertThat(language).isEqualTo(ENGLISH) + } + + @Test + fun testGetWrittenContentLang_useAppLang_updateAppLanguage_notifiesProviderWithNewLang() { + ensureWrittenTranslationsLanguageIsUpdatedTo(PROFILE_ID_0, HINDI) + ensureAppLanguageIsUpdatedToUseSystem(PROFILE_ID_0) + forceDefaultLocale(Locale.US) + ensureWrittenTranslationsLanguageIsUpdatedToUseApp(PROFILE_ID_0) + + ensureAppLanguageIsUpdatedTo(PROFILE_ID_0, BRAZILIAN_PORTUGUESE) + val languageProvider = translationController.getWrittenTranslationContentLanguage(PROFILE_ID_0) + + // Changing the app language should change the provided language since this provider depends on + // the app strings language. + val language = monitorFactory.waitForNextSuccessfulResult(languageProvider) + assertThat(language).isEqualTo(BRAZILIAN_PORTUGUESE) + } + + @Test + fun testGetWrittenContentLang_useSystemLangForApp_updateLocale_notifiesProviderWithNewLang() { + ensureAppLanguageIsUpdatedToUseSystem(PROFILE_ID_0) + forceDefaultLocale(Locale.US) + ensureWrittenTranslationsLanguageIsUpdatedToUseApp(PROFILE_ID_0) + + localeController.setAsDefault(createDisplayLocaleForLanguage(HINDI), Configuration()) + val languageProvider = translationController.getWrittenTranslationContentLanguage(PROFILE_ID_0) + + // Changing the locale should change the language since this provider depends on the app strings + // language & app strings depend on the system locale. + val language = monitorFactory.waitForNextSuccessfulResult(languageProvider) + assertThat(language).isEqualTo(HINDI) + } + + @Test + fun testGetWrittenContentLang_updateLanguageToEnglish_differentProfile_returnsDifferentLang() { + forceDefaultLocale(Locale.ENGLISH) + ensureWrittenTranslationsLanguageIsUpdatedTo(PROFILE_ID_0, HINDI) + + val languageProvider = translationController.getWrittenTranslationContentLanguage(PROFILE_ID_1) + + // English is returned since the language is being fetched for a different profile. + val language = monitorFactory.waitForNextSuccessfulResult(languageProvider) + assertThat(language).isEqualTo(ENGLISH) + } + + @Test + fun testGetWrittenContentLocale_uninitialized_rootLocale_returnsFailure() { + forceDefaultLocale(Locale.ROOT) + + val localeProvider = translationController.getWrittenTranslationContentLocale(PROFILE_ID_0) + + val error = monitorFactory.waitForNextFailureResult(localeProvider) + assertThat(error).hasMessageThat().contains("doesn't match supported language definitions") + } + + @Test + fun testGetWrittenContentLocale_uninitialized_englishLocale_returnsLocaleWithSystemLanguage() { + forceDefaultLocale(Locale.US) + + val localeProvider = translationController.getWrittenTranslationContentLocale(PROFILE_ID_0) + + val locale = monitorFactory.waitForNextSuccessfulResult(localeProvider) + val context = locale.localeContext + assertThat(context.usageMode).isEqualTo(CONTENT_STRINGS) + assertThat(context.languageDefinition.language).isEqualTo(ENGLISH) + assertThat(context.regionDefinition.region).isEqualTo(UNITED_STATES) + } + + @Test + fun testGetWrittenContentLocale_updateLanguageToEnglish_returnsEnglishLocale() { + forceDefaultLocale(Locale.ROOT) + ensureWrittenTranslationsLanguageIsUpdatedTo(PROFILE_ID_0, ENGLISH) + + val localeProvider = translationController.getWrittenTranslationContentLocale(PROFILE_ID_0) + + val locale = monitorFactory.waitForNextSuccessfulResult(localeProvider) + val context = locale.localeContext + assertThat(context.usageMode).isEqualTo(CONTENT_STRINGS) + assertThat(context.languageDefinition.language).isEqualTo(ENGLISH) + // This region comes from the default locale. + assertThat(context.regionDefinition.region).isEqualTo(REGION_UNSPECIFIED) + } + + @Test + fun testGetWrittenContentLocale_updateLanguageToPortuguese_returnsPortugueseLocale() { + forceDefaultLocale(BRAZIL_ENGLISH_LOCALE) + ensureWrittenTranslationsLanguageIsUpdatedTo(PROFILE_ID_0, BRAZILIAN_PORTUGUESE) + + val localeProvider = translationController.getWrittenTranslationContentLocale(PROFILE_ID_0) + + val locale = monitorFactory.waitForNextSuccessfulResult(localeProvider) + val context = locale.localeContext + assertThat(context.usageMode).isEqualTo(CONTENT_STRINGS) + assertThat(context.languageDefinition.language).isEqualTo(BRAZILIAN_PORTUGUESE) + // This region comes from the default locale. + assertThat(context.regionDefinition.region).isEqualTo(BRAZIL) + } + + @Test + fun testGetWrittenContentLocale_updateLanguageToUseApp_returnsAppLanguage() { + // First, initialize the language to Hindi before overwriting to use the app language. + ensureWrittenTranslationsLanguageIsUpdatedTo(PROFILE_ID_0, HINDI) + ensureAppLanguageIsUpdatedToUseSystem(PROFILE_ID_0) + forceDefaultLocale(Locale.US) + ensureWrittenTranslationsLanguageIsUpdatedToUseApp(PROFILE_ID_0) + + val localeProvider = translationController.getWrittenTranslationContentLocale(PROFILE_ID_0) + + val locale = monitorFactory.waitForNextSuccessfulResult(localeProvider) + val context = locale.localeContext + assertThat(context.usageMode).isEqualTo(CONTENT_STRINGS) + assertThat(context.languageDefinition.language).isEqualTo(ENGLISH) + } + + @Test + fun testGetWrittenContentLocale_useAppLang_updateAppLanguage_notifiesProviderWithNewLang() { + ensureWrittenTranslationsLanguageIsUpdatedTo(PROFILE_ID_0, HINDI) + ensureAppLanguageIsUpdatedToUseSystem(PROFILE_ID_0) + forceDefaultLocale(Locale.US) + ensureWrittenTranslationsLanguageIsUpdatedToUseApp(PROFILE_ID_0) + + ensureAppLanguageIsUpdatedTo(PROFILE_ID_0, BRAZILIAN_PORTUGUESE) + val localeProvider = translationController.getWrittenTranslationContentLocale(PROFILE_ID_0) + + // Changing the app language should change the provided language since this provider depends on + // the app strings language. + val locale = monitorFactory.waitForNextSuccessfulResult(localeProvider) + val context = locale.localeContext + assertThat(context.usageMode).isEqualTo(CONTENT_STRINGS) + assertThat(context.languageDefinition.language).isEqualTo(BRAZILIAN_PORTUGUESE) + } + + @Test + fun testGetWrittenContentLocale_useSystemLangForApp_updateLocale_notifiesProviderWithNewLang() { + ensureAppLanguageIsUpdatedToUseSystem(PROFILE_ID_0) + forceDefaultLocale(Locale.US) + ensureWrittenTranslationsLanguageIsUpdatedToUseApp(PROFILE_ID_0) + + localeController.setAsDefault(createDisplayLocaleForLanguage(HINDI), Configuration()) + val localeProvider = translationController.getWrittenTranslationContentLocale(PROFILE_ID_0) + + // Changing the locale should change the language since this provider depends on the app strings + // language & app strings depend on the system locale. + val locale = monitorFactory.waitForNextSuccessfulResult(localeProvider) + val context = locale.localeContext + assertThat(context.usageMode).isEqualTo(CONTENT_STRINGS) + assertThat(context.languageDefinition.language).isEqualTo(HINDI) + } + + @Test + fun testGetWrittenContentLocale_updateLangToEnglish_differentProfile_returnsDifferentLocale() { + forceDefaultLocale(Locale.ENGLISH) + ensureWrittenTranslationsLanguageIsUpdatedTo(PROFILE_ID_0, HINDI) + + val localeProvider = translationController.getWrittenTranslationContentLocale(PROFILE_ID_1) + + // English is returned since the language is being fetched for a different profile. + val locale = monitorFactory.waitForNextSuccessfulResult(localeProvider) + val context = locale.localeContext + assertThat(context.usageMode).isEqualTo(CONTENT_STRINGS) + assertThat(context.languageDefinition.language).isEqualTo(ENGLISH) + } + + /* Tests for audio translation content functions */ + + @Test + fun testUpdateAudioLanguage_returnsSuccess() { + forceDefaultLocale(Locale.ROOT) + + val resultProvider = + translationController.updateAudioTranslationContentLanguage( + PROFILE_ID_0, createAudioTranslationLanguageSelection(ENGLISH) + ) + + monitorFactory.waitForNextSuccessfulResult(resultProvider) + } + + @Test + fun testUpdateAudioLanguage_notifiesProviderWithChange() { + forceDefaultLocale(Locale.US) + val languageProvider = translationController.getAudioTranslationContentLanguage(PROFILE_ID_0) + val langMonitor = monitorFactory.createMonitor(languageProvider) + langMonitor.waitForNextSuccessResult() + + // The result must be observed immediately otherwise it won't execute (which will result in the + // language not being updated). + val resultProvider = + translationController.updateAudioTranslationContentLanguage( + PROFILE_ID_0, createAudioTranslationLanguageSelection(BRAZILIAN_PORTUGUESE) + ) + val updateMonitor = monitorFactory.createMonitor(resultProvider) + + updateMonitor.waitForNextSuccessResult() + langMonitor.ensureNextResultIsSuccess() + } + + @Test + fun testGetAudioLanguage_uninitialized_rootLocale_returnsFailure() { + forceDefaultLocale(Locale.ROOT) + + val languageProvider = translationController.getAudioTranslationContentLanguage(PROFILE_ID_0) + + val error = monitorFactory.waitForNextFailureResult(languageProvider) + assertThat(error).hasMessageThat().contains("doesn't match supported language definitions") + } + + @Test + fun testGetAudioLanguage_uninitialized_englishLocale_returnsSystemLanguage() { + forceDefaultLocale(Locale.US) + + val languageProvider = translationController.getAudioTranslationContentLanguage(PROFILE_ID_0) + + val language = monitorFactory.waitForNextSuccessfulResult(languageProvider) + assertThat(language).isEqualTo(ENGLISH) + } + + @Test + fun testGetAudioLanguage_updateLanguageToEnglish_returnsEnglish() { + forceDefaultLocale(Locale.ROOT) + ensureAudioTranslationsLanguageIsUpdatedTo(PROFILE_ID_0, ENGLISH) + + val languageProvider = translationController.getAudioTranslationContentLanguage(PROFILE_ID_0) + + val language = monitorFactory.waitForNextSuccessfulResult(languageProvider) + assertThat(language).isEqualTo(ENGLISH) + } + + @Test + fun testGetAudioLanguage_updateLanguageToHindi_returnsHindi() { + forceDefaultLocale(Locale.ROOT) + ensureAudioTranslationsLanguageIsUpdatedTo(PROFILE_ID_0, HINDI) + + val languageProvider = translationController.getAudioTranslationContentLanguage(PROFILE_ID_0) + + val language = monitorFactory.waitForNextSuccessfulResult(languageProvider) + assertThat(language).isEqualTo(HINDI) + } + + @Test + fun testGetAudioLanguage_updateLanguageToUseApp_returnsAppLanguage() { + // First, initialize the language to Hindi before overwriting to use the app language. + ensureAudioTranslationsLanguageIsUpdatedTo(PROFILE_ID_0, HINDI) + ensureAppLanguageIsUpdatedToUseSystem(PROFILE_ID_0) + forceDefaultLocale(Locale.US) + ensureAudioTranslationsLanguageIsUpdatedToUseApp(PROFILE_ID_0) + + val languageProvider = translationController.getAudioTranslationContentLanguage(PROFILE_ID_0) + + val language = monitorFactory.waitForNextSuccessfulResult(languageProvider) + assertThat(language).isEqualTo(ENGLISH) + } + + @Test + fun testGetAudioLanguage_useAppLang_updateAppLanguage_notifiesProviderWithNewLang() { + ensureAudioTranslationsLanguageIsUpdatedTo(PROFILE_ID_0, HINDI) + ensureAppLanguageIsUpdatedToUseSystem(PROFILE_ID_0) + forceDefaultLocale(Locale.US) + ensureAudioTranslationsLanguageIsUpdatedToUseApp(PROFILE_ID_0) + + ensureAppLanguageIsUpdatedTo(PROFILE_ID_0, BRAZILIAN_PORTUGUESE) + val languageProvider = translationController.getAudioTranslationContentLanguage(PROFILE_ID_0) + + // Changing the app language should change the provided language since this provider depends on + // the app strings language. + val language = monitorFactory.waitForNextSuccessfulResult(languageProvider) + assertThat(language).isEqualTo(BRAZILIAN_PORTUGUESE) + } + + @Test + fun testGetAudioLanguage_useSystemLangForApp_updateLocale_notifiesProviderWithNewLang() { + ensureAppLanguageIsUpdatedToUseSystem(PROFILE_ID_0) + forceDefaultLocale(Locale.US) + ensureAudioTranslationsLanguageIsUpdatedToUseApp(PROFILE_ID_0) + + localeController.setAsDefault(createDisplayLocaleForLanguage(HINDI), Configuration()) + val languageProvider = translationController.getAudioTranslationContentLanguage(PROFILE_ID_0) + + // Changing the locale should change the language since this provider depends on the app strings + // language & app strings depend on the system locale. + val language = monitorFactory.waitForNextSuccessfulResult(languageProvider) + assertThat(language).isEqualTo(HINDI) + } + + @Test + fun testGetAudioLanguage_updateLanguageToEnglish_differentProfile_returnsDifferentLang() { + forceDefaultLocale(Locale.ENGLISH) + ensureAudioTranslationsLanguageIsUpdatedTo(PROFILE_ID_0, HINDI) + + val languageProvider = translationController.getAudioTranslationContentLanguage(PROFILE_ID_1) + + // English is returned since the language is being fetched for a different profile. + val language = monitorFactory.waitForNextSuccessfulResult(languageProvider) + assertThat(language).isEqualTo(ENGLISH) + } + + @Test + fun testGetAudioLocale_uninitialized_rootLocale_returnsFailure() { + forceDefaultLocale(Locale.ROOT) + + val localeProvider = translationController.getAudioTranslationContentLocale(PROFILE_ID_0) + + val error = monitorFactory.waitForNextFailureResult(localeProvider) + assertThat(error).hasMessageThat().contains("doesn't match supported language definitions") + } + + @Test + fun testGetAudioLocale_uninitialized_englishLocale_returnsLocaleWithSystemLanguage() { + forceDefaultLocale(Locale.US) + + val localeProvider = translationController.getAudioTranslationContentLocale(PROFILE_ID_0) + + val locale = monitorFactory.waitForNextSuccessfulResult(localeProvider) + val context = locale.localeContext + assertThat(context.usageMode).isEqualTo(AUDIO_TRANSLATIONS) + assertThat(context.languageDefinition.language).isEqualTo(ENGLISH) + assertThat(context.regionDefinition.region).isEqualTo(UNITED_STATES) + } + + @Test + fun testGetAudioLocale_updateLanguageToEnglish_returnsEnglishLocale() { + forceDefaultLocale(Locale.ROOT) + ensureAudioTranslationsLanguageIsUpdatedTo(PROFILE_ID_0, ENGLISH) + + val localeProvider = translationController.getAudioTranslationContentLocale(PROFILE_ID_0) + + val locale = monitorFactory.waitForNextSuccessfulResult(localeProvider) + val context = locale.localeContext + assertThat(context.usageMode).isEqualTo(AUDIO_TRANSLATIONS) + assertThat(context.languageDefinition.language).isEqualTo(ENGLISH) + // This region comes from the default locale. + assertThat(context.regionDefinition.region).isEqualTo(REGION_UNSPECIFIED) + } + + @Test + fun testGetAudioLocale_updateLanguageToPortuguese_returnsPortugueseLocale() { + forceDefaultLocale(BRAZIL_ENGLISH_LOCALE) + ensureAudioTranslationsLanguageIsUpdatedTo(PROFILE_ID_0, BRAZILIAN_PORTUGUESE) + + val localeProvider = translationController.getAudioTranslationContentLocale(PROFILE_ID_0) + + val locale = monitorFactory.waitForNextSuccessfulResult(localeProvider) + val context = locale.localeContext + assertThat(context.usageMode).isEqualTo(AUDIO_TRANSLATIONS) + assertThat(context.languageDefinition.language).isEqualTo(BRAZILIAN_PORTUGUESE) + // This region comes from the default locale. + assertThat(context.regionDefinition.region).isEqualTo(BRAZIL) + } + + @Test + fun testGetAudioLocale_updateLanguageToUseApp_returnsAppLanguage() { + // First, initialize the language to Hindi before overwriting to use the app language. + ensureAudioTranslationsLanguageIsUpdatedTo(PROFILE_ID_0, HINDI) + ensureAppLanguageIsUpdatedToUseSystem(PROFILE_ID_0) + forceDefaultLocale(Locale.US) + ensureAudioTranslationsLanguageIsUpdatedToUseApp(PROFILE_ID_0) + + val localeProvider = translationController.getAudioTranslationContentLocale(PROFILE_ID_0) + + val locale = monitorFactory.waitForNextSuccessfulResult(localeProvider) + val context = locale.localeContext + assertThat(context.usageMode).isEqualTo(AUDIO_TRANSLATIONS) + assertThat(context.languageDefinition.language).isEqualTo(ENGLISH) + } + + @Test + fun testGetAudioLocale_useAppLang_updateAppLanguage_notifiesProviderWithNewLang() { + ensureAudioTranslationsLanguageIsUpdatedTo(PROFILE_ID_0, HINDI) + ensureAppLanguageIsUpdatedToUseSystem(PROFILE_ID_0) + forceDefaultLocale(Locale.US) + ensureAudioTranslationsLanguageIsUpdatedToUseApp(PROFILE_ID_0) + + ensureAppLanguageIsUpdatedTo(PROFILE_ID_0, BRAZILIAN_PORTUGUESE) + val localeProvider = translationController.getAudioTranslationContentLocale(PROFILE_ID_0) + + // Changing the app language should change the provided language since this provider depends on + // the app strings language. + val locale = monitorFactory.waitForNextSuccessfulResult(localeProvider) + val context = locale.localeContext + assertThat(context.usageMode).isEqualTo(AUDIO_TRANSLATIONS) + assertThat(context.languageDefinition.language).isEqualTo(BRAZILIAN_PORTUGUESE) + } + + @Test + fun testGetAudioLocale_useSystemLangForApp_updateLocale_notifiesProviderWithNewLang() { + ensureAppLanguageIsUpdatedToUseSystem(PROFILE_ID_0) + forceDefaultLocale(Locale.US) + ensureAudioTranslationsLanguageIsUpdatedToUseApp(PROFILE_ID_0) + + localeController.setAsDefault(createDisplayLocaleForLanguage(HINDI), Configuration()) + val localeProvider = translationController.getAudioTranslationContentLocale(PROFILE_ID_0) + + // Changing the locale should change the language since this provider depends on the app strings + // language & app strings depend on the system locale. + val locale = monitorFactory.waitForNextSuccessfulResult(localeProvider) + val context = locale.localeContext + assertThat(context.usageMode).isEqualTo(AUDIO_TRANSLATIONS) + assertThat(context.languageDefinition.language).isEqualTo(HINDI) + } + + @Test + fun testGetAudioLocale_updateLangToEnglish_differentProfile_returnsDifferentLocale() { + forceDefaultLocale(Locale.ENGLISH) + ensureAudioTranslationsLanguageIsUpdatedTo(PROFILE_ID_0, HINDI) + + val localeProvider = translationController.getAudioTranslationContentLocale(PROFILE_ID_1) + + // English is returned since the language is being fetched for a different profile. + val locale = monitorFactory.waitForNextSuccessfulResult(localeProvider) + val context = locale.localeContext + assertThat(context.usageMode).isEqualTo(AUDIO_TRANSLATIONS) + assertThat(context.languageDefinition.language).isEqualTo(ENGLISH) + } + + /* Tests for string extraction functions */ + + @Test + fun testExtractString_defaultSubtitledHtml_defaultContext_returnsEmptyString() { + val extracted = + translationController.extractString( + SubtitledHtml.getDefaultInstance(), WrittenTranslationContext.getDefaultInstance() + ) + + assertThat(extracted).isEmpty() + } + + @Test + fun testExtractString_defaultSubtitledHtml_validContext_returnsEmptyString() { + val context = WrittenTranslationContext.newBuilder().apply { + putTranslations( + "other_content_id", + Translation.newBuilder().apply { + html = "Translated string" + }.build() + ) + }.build() + + val extracted = translationController.extractString(SubtitledHtml.getDefaultInstance(), context) + + assertThat(extracted).isEmpty() + } + + @Test + fun testExtractString_subtitledHtml_defaultContext_returnsUntranslatedHtml() { + val subtitledHtml = SubtitledHtml.newBuilder().apply { + contentId = "content_id" + html = "default html" + }.build() + + val extracted = + translationController.extractString( + subtitledHtml, WrittenTranslationContext.getDefaultInstance() + ) + + assertThat(extracted).isEqualTo("default html") + } + + @Test + fun testExtractString_subtitledHtml_validContext_missingContentId_returnsUntranslatedHtml() { + val subtitledHtml = SubtitledHtml.newBuilder().apply { + contentId = "content_id" + html = "default html" + }.build() + val context = WrittenTranslationContext.newBuilder().apply { + putTranslations( + "other_content_id", + Translation.newBuilder().apply { + html = "Translated string" + }.build() + ) + }.build() + + val extracted = translationController.extractString(subtitledHtml, context) + + // The content ID doesn't match in the context. + assertThat(extracted).isEqualTo("default html") + } + + @Test + fun testExtractString_subtitledHtml_validContext_includesContentId_returnsTranslatedHtml() { + val subtitledHtml = SubtitledHtml.newBuilder().apply { + contentId = "content_id" + html = "default html" + }.build() + val context = WrittenTranslationContext.newBuilder().apply { + putTranslations( + "content_id", + Translation.newBuilder().apply { + html = "Translated string" + }.build() + ) + }.build() + + val extracted = translationController.extractString(subtitledHtml, context) + + // The context ID does match, so the matching string is extracted. + assertThat(extracted).isEqualTo("Translated string") + } + + @Test + fun testExtractString_defaultSubtitledUnicode_defaultContext_returnsEmptyString() { + val extracted = + translationController.extractString( + SubtitledUnicode.getDefaultInstance(), WrittenTranslationContext.getDefaultInstance() + ) + + assertThat(extracted).isEmpty() + } + + @Test + fun testExtractString_defaultSubtitledUnicode_validContext_returnsEmptyString() { + val context = WrittenTranslationContext.newBuilder().apply { + putTranslations( + "other_content_id", + Translation.newBuilder().apply { + html = "Translated string" + }.build() + ) + }.build() + + val extracted = + translationController.extractString(SubtitledUnicode.getDefaultInstance(), context) + + assertThat(extracted).isEmpty() + } + + @Test + fun testExtractString_subtitledUnicode_defaultContext_returnsUntranslatedUnicode() { + val subtitledUnicode = SubtitledUnicode.newBuilder().apply { + contentId = "content_id" + unicodeStr = "default str" + }.build() + + val extracted = + translationController.extractString( + subtitledUnicode, WrittenTranslationContext.getDefaultInstance() + ) + + assertThat(extracted).isEqualTo("default str") + } + + @Test + fun testExtractString_subtitledUnicode_validContext_missingContentId_returnsUnxlatedUnicode() { + val subtitledUnicode = SubtitledUnicode.newBuilder().apply { + contentId = "content_id" + unicodeStr = "default str" + }.build() + val context = WrittenTranslationContext.newBuilder().apply { + putTranslations( + "other_content_id", + Translation.newBuilder().apply { + html = "Translated string" + }.build() + ) + }.build() + + val extracted = translationController.extractString(subtitledUnicode, context) + + // The content ID doesn't match in the context. + assertThat(extracted).isEqualTo("default str") + } + + @Test + fun testExtractString_subtitledUnicode_validContext_includesContentId_returnsTranslatedUnicode() { + val subtitledUnicode = SubtitledUnicode.newBuilder().apply { + contentId = "content_id" + unicodeStr = "default str" + }.build() + val context = WrittenTranslationContext.newBuilder().apply { + putTranslations( + "content_id", + Translation.newBuilder().apply { + html = "Translated string" + }.build() + ) + }.build() + + val extracted = translationController.extractString(subtitledUnicode, context) + + // The context ID does match, so the matching string is extracted. + assertThat(extracted).isEqualTo("Translated string") + } + + private fun setUpTestApplicationComponent() { + ApplicationProvider.getApplicationContext().inject(this) + } + + private fun forceDefaultLocale(locale: Locale) { + context.applicationContext.resources.configuration.setLocale(locale) + Locale.setDefault(locale) + } + + private fun createDisplayLocaleForLanguage(language: OppiaLanguage): OppiaLocale.DisplayLocale { + val localeProvider = localeController.retrieveAppStringDisplayLocale(language) + return monitorFactory.waitForNextSuccessfulResult(localeProvider) + } + + private fun ensureAppLanguageIsUpdatedToUseSystem(profileId: ProfileId) { + val resultProvider = + translationController.updateAppLanguage(profileId, APP_LANGUAGE_SELECTION_SYSTEM) + monitorFactory.waitForNextSuccessfulResult(resultProvider) + } + + private fun ensureAppLanguageIsUpdatedTo(profileId: ProfileId, language: OppiaLanguage) { + val resultProvider = + translationController.updateAppLanguage(profileId, createAppLanguageSelection(language)) + monitorFactory.waitForNextSuccessfulResult(resultProvider) + } + + private fun createAppLanguageSelection(language: OppiaLanguage): AppLanguageSelection { + return AppLanguageSelection.newBuilder().apply { + selectedLanguage = language + }.build() + } + + private fun ensureWrittenTranslationsLanguageIsUpdatedTo( + profileId: ProfileId, + language: OppiaLanguage + ) { + val resultProvider = + translationController.updateWrittenTranslationContentLanguage( + profileId, createWrittenTranslationLanguageSelection(language) + ) + monitorFactory.waitForNextSuccessfulResult(resultProvider) + } + + private fun ensureWrittenTranslationsLanguageIsUpdatedToUseApp(profileId: ProfileId) { + val resultProvider = + translationController.updateWrittenTranslationContentLanguage( + profileId, WRITTEN_TRANSLATION_LANGUAGE_SELECTION_APP_LANGUAGE + ) + monitorFactory.waitForNextSuccessfulResult(resultProvider) + } + + private fun createWrittenTranslationLanguageSelection( + language: OppiaLanguage + ): WrittenTranslationLanguageSelection { + return WrittenTranslationLanguageSelection.newBuilder().apply { + selectedLanguage = language + }.build() + } + + private fun ensureAudioTranslationsLanguageIsUpdatedTo( + profileId: ProfileId, + language: OppiaLanguage + ) { + val resultProvider = + translationController.updateAudioTranslationContentLanguage( + profileId, createAudioTranslationLanguageSelection(language) + ) + monitorFactory.waitForNextSuccessfulResult(resultProvider) + } + + private fun ensureAudioTranslationsLanguageIsUpdatedToUseApp(profileId: ProfileId) { + val resultProvider = + translationController.updateAudioTranslationContentLanguage( + profileId, AUDIO_TRANSLATION_LANGUAGE_SELECTION_APP_LANGUAGE + ) + monitorFactory.waitForNextSuccessfulResult(resultProvider) + } + + private fun createAudioTranslationLanguageSelection( + language: OppiaLanguage + ): AudioTranslationLanguageSelection { + return AudioTranslationLanguageSelection.newBuilder().apply { + selectedLanguage = language + }.build() + } + + // TODO(#89): Move this to a common test application component. + @Module + class TestModule { + @Provides + @Singleton + fun provideContext(application: Application): Context { + return application + } + } + + // TODO(#89): Move this to a common test application component. + @Singleton + @Component( + modules = [ + TestModule::class, LogStorageModule::class, NetworkConnectionUtilDebugModule::class, + TestLogReportingModule::class, LoggerModule::class, TestDispatcherModule::class, + LocaleProdModule::class, FakeOppiaClockModule::class, RobolectricModule::class, + AssetModule::class + ] + ) + interface TestApplicationComponent : DataProvidersInjector { + @Component.Builder + interface Builder { + @BindsInstance + fun setApplication(application: Application): Builder + + fun build(): TestApplicationComponent + } + + fun inject(translationControllerTest: TranslationControllerTest) + } + + class TestApplication : Application(), DataProvidersInjectorProvider { + private val component: TestApplicationComponent by lazy { + DaggerTranslationControllerTest_TestApplicationComponent.builder() + .setApplication(this) + .build() + } + + fun inject(translationControllerTest: TranslationControllerTest) { + component.inject(translationControllerTest) + } + + override fun getDataProvidersInjector(): DataProvidersInjector = component + } + + private companion object { + private val BRAZIL_ENGLISH_LOCALE = Locale("en", "BR") + + private val PROFILE_ID_0 = ProfileId.newBuilder().apply { + internalId = 0 + }.build() + + private val PROFILE_ID_1 = ProfileId.newBuilder().apply { + internalId = 1 + }.build() + + private val APP_LANGUAGE_SELECTION_SYSTEM = AppLanguageSelection.newBuilder().apply { + useSystemLanguageOrAppDefault = true + }.build() + + private val WRITTEN_TRANSLATION_LANGUAGE_SELECTION_APP_LANGUAGE = + WrittenTranslationLanguageSelection.newBuilder().apply { + useAppLanguage = true + }.build() + + private val AUDIO_TRANSLATION_LANGUAGE_SELECTION_APP_LANGUAGE = + AudioTranslationLanguageSelection.newBuilder().apply { + useAppLanguage = true + }.build() + } +} diff --git a/domain/src/test/java/org/oppia/android/domain/util/StateRetrieverTest.kt b/domain/src/test/java/org/oppia/android/domain/util/StateRetrieverTest.kt index 510787d58b6..a747f675122 100644 --- a/domain/src/test/java/org/oppia/android/domain/util/StateRetrieverTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/util/StateRetrieverTest.kt @@ -23,6 +23,7 @@ import org.oppia.android.app.model.TranslatableHtmlContentId 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.caching.AssetModule import org.oppia.android.util.caching.CacheAssetsLocally import org.oppia.android.util.logging.EnableConsoleLog import org.oppia.android.util.logging.EnableFileLog @@ -427,9 +428,8 @@ class StateRetrieverTest { @Singleton @Component( modules = [ - TestModule::class, - TestDispatcherModule::class, - RobolectricModule::class, FakeOppiaClockModule::class + TestModule::class, TestDispatcherModule::class, RobolectricModule::class, + FakeOppiaClockModule::class, AssetModule::class ] ) interface TestApplicationComponent { diff --git a/domain/src/test/res/values/strings.xml b/domain/src/test/res/values/strings.xml new file mode 100644 index 00000000000..97f8a64e96e --- /dev/null +++ b/domain/src/test/res/values/strings.xml @@ -0,0 +1,27 @@ + + + + Basic string + Basic string2 + האם התכוונת ל %s + + + 1 item + + + 2 items + + + + + 1 item + + + %s items + + + + @string/test_basic_string + @string/test_basic_string2 + + diff --git a/instrumentation/src/java/org/oppia/android/instrumentation/application/BUILD.bazel b/instrumentation/src/java/org/oppia/android/instrumentation/application/BUILD.bazel index b65fba2144f..4a14c9cf370 100644 --- a/instrumentation/src/java/org/oppia/android/instrumentation/application/BUILD.bazel +++ b/instrumentation/src/java/org/oppia/android/instrumentation/application/BUILD.bazel @@ -22,7 +22,7 @@ kt_android_library( "//data/src/main/java/org/oppia/android/data/backends/gae:network_config_annotations", "//domain", "//utility", - "//utility/src/main/java/org/oppia/android/util/caching:caching_module", + "//utility/src/main/java/org/oppia/android/util/caching:caching_prod_module", ], ) diff --git a/instrumentation/src/java/org/oppia/android/instrumentation/application/TestApplicationComponent.kt b/instrumentation/src/java/org/oppia/android/instrumentation/application/TestApplicationComponent.kt index dacf066c0bc..38370de9e23 100644 --- a/instrumentation/src/java/org/oppia/android/instrumentation/application/TestApplicationComponent.kt +++ b/instrumentation/src/java/org/oppia/android/instrumentation/application/TestApplicationComponent.kt @@ -39,6 +39,7 @@ import org.oppia.android.domain.question.QuestionModule import org.oppia.android.domain.topic.PrimeTopicAssetsControllerModule import org.oppia.android.domain.workmanager.WorkManagerConfigurationModule import org.oppia.android.util.accessibility.AccessibilityProdModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.CachingModule import org.oppia.android.util.logging.LoggerModule import org.oppia.android.util.logging.firebase.DebugLogReportingModule @@ -80,7 +81,7 @@ import javax.inject.Singleton PlatformParameterModule::class, ExplorationStorageModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, PlatformParameterSyncUpWorkerModule::class, NetworkConnectionUtilDebugModule::class, - EndToEndTestNetworkConfigModule::class, + EndToEndTestNetworkConfigModule::class, AssetModule::class, // TODO(#59): Remove this module once we completely migrate to Bazel from Gradle as we can then // directly exclude debug files from the build and thus won't be requiring this module. NetworkConnectionDebugUtilModule::class diff --git a/model/BUILD.bazel b/model/BUILD.bazel index 7c805314de7..d64d7e2a340 100644 --- a/model/BUILD.bazel +++ b/model/BUILD.bazel @@ -84,6 +84,18 @@ java_lite_proto_library( deps = [":interaction_object_proto"], ) +proto_library( + name = "languages_proto", + srcs = ["src/main/proto/languages.proto"], + visibility = ["//visibility:public"], +) + +java_lite_proto_library( + name = "languages_java_proto_lite", + visibility = ["//visibility:public"], + deps = [":languages_proto"], +) + proto_library( name = "onboarding_proto", srcs = ["src/main/proto/onboarding.proto"], @@ -113,6 +125,7 @@ proto_library( java_lite_proto_library( name = "subtitled_html_java_proto_lite", + visibility = ["//:oppia_api_visibility"], deps = [":subtitled_html_proto"], ) @@ -123,6 +136,7 @@ proto_library( java_lite_proto_library( name = "subtitled_unicode_java_proto_lite", + visibility = ["//:oppia_api_visibility"], deps = [":subtitled_unicode_proto"], ) diff --git a/model/build.gradle b/model/build.gradle index 5ba79d77c05..5030ef8bebf 100644 --- a/model/build.gradle +++ b/model/build.gradle @@ -3,36 +3,28 @@ apply plugin: 'com.google.protobuf' protobuf { protoc { - artifact = 'com.google.protobuf:protoc:3.0.0' - } - plugins { - javalite { - artifact = 'com.google.protobuf:protoc-gen-javalite:3.0.0' - } + artifact = 'com.google.protobuf:protoc:3.8.0' } generateProtoTasks { all().each { task -> task.builtins { - remove java - } - task.plugins { - javalite {} + java { + // This setup is per https://github.com/google/protobuf-gradle-plugin/issues/315. + option 'lite' + } } } } } dependencies { - // This must use compile in order for lite protobufs to generate correctly. - // TODO(#59): Move to Bazel to avoid relying on a deprecated Gradle directive. - compile 'com.google.protobuf:protobuf-lite:3.0.0' + implementation 'com.google.protobuf:protobuf-javalite:3.17.3' } sourceSets { - main.java.srcDirs += "${protobuf.generatedFilesBaseDir}/main/javalite" - main.java.srcDirs += "$projectDir/src/main/proto" -} - -processResources { - exclude('**/*.proto') + main { + proto { + srcDir 'src/main/proto' + } + } } diff --git a/model/src/main/proto/languages.proto b/model/src/main/proto/languages.proto new file mode 100644 index 00000000000..c992db49407 --- /dev/null +++ b/model/src/main/proto/languages.proto @@ -0,0 +1,265 @@ +syntax = "proto3"; + +package model; + +option java_package = "org.oppia.android.app.model"; +option java_multiple_files = true; + +// The list of languages partly or fully supported natively by the Android app. +enum OppiaLanguage { + // Corresponds to an unspecified, unknown, or unsupported language. + LANGUAGE_UNSPECIFIED = 0; + + // Corresponds to the Arabic (اَلْعَرَبِيَّةُ‎) macro language. IETF BCP 47 language tag: ar. + ARABIC = 1; + + // Corresponds to the English (English) macro language. IETF BCP 47 language tag: en. + ENGLISH = 2; + + // Corresponds to the Hindi (हिन्दी) macro language. IETF BCP 47 language tag: hi. + HINDI = 3; + + // Corresponds to the Hindi-English macaraonic language. Custom language tag: hi-en. + HINGLISH = 4; + + // Corresponds to the Portuguese (português) macro language. IETF BCP 47 language tag: pt. + PORTUGUESE = 5; + + // Corresponds to the Brazilian variant of Portuguese. IETF BCP 47 language tag: pt-BR. + BRAZILIAN_PORTUGUESE = 6; +} + +// The list of regions explicitly supported natively by the Android app. Note that the app is not +// automatically unsupported in countries not on this list. Countries absent from this list may +// default to default system behavior for certain localization situations (such as date & time +// formatting) rather than being explicitly handled by the app. +// +// Note also that these regions cannot be construed as countries, nor as the area the user is in. +// The system's locale can be changed by the user, so this is a best-effort basis to match to the +// user's current system. Further, the app retains future support for regions not directly supported +// by the Android system. +enum OppiaRegion { + // Corresponds to an unspecified, unknown, or undefined region. In these cases, the app will rely + // on system behavior for locale-related decisions (such as formatting). + REGION_UNSPECIFIED = 0; + + // Corresponds to Brazil (Brasil). IETF BCP 47 region tag: BR. + BRAZIL = 1; + + // Corresponds to India (Bhārat Gaṇarājya). IETF BCP 47 region tag: IN. + INDIA = 2; + + // Corresponds to United State of America (U.S.A.). IETF BCP 47 region tag: US. + UNITED_STATES = 3; +} + +// Defines the list of supported languages in the app. +message SupportedLanguages { + // The list of language definitions, one for each language. If any languages are not represented + // in this list then it's assumed that they are not supported. If a language is represented + // multiple times, the first occurrence in the list for that language is used. + repeated LanguageSupportDefinition language_definitions = 1; +} + +// Defines the list of supported regions in the app. Note that countries missing from this list are +// handled generically (that is, they rely on the system to handle certain localization contexts +// or otherwise fallback to the default locale of the system). Note that regions are only associated +// with languages for the purpose of localizing text such as date & time formats. +message SupportedRegions { + // The list of region deifnitions, one for each OppiaRegion. If any regions are not represented in + // this list then the app relies on default behavior for that region's corresponding locale. If a + // region is represented multiply times, the first occurrence in the list for that region is used. + repeated RegionSupportDefinition region_definitions = 1; +} + +// Defines the support for a specific language. +message LanguageSupportDefinition { + // The language corresponding to this definition. + OppiaLanguage language = 1; + + // The macro language to fall back to if this language is not supported on the local system or + // for specific content (e.g.: 'pt' could be a fallback for Brazilian Portuguese). + OppiaLanguage fallback_macro_language = 2; + + // Corresponds to the minimum SDK version required to natively support this language. May be + // missing (i.e. 0) if the language is not natively supported by Android. Note that Android does + // not, strictly speaking, support individual languages at the SDK level since the actual support + // for languages may depend on the specific OEM and installation situation. However, Android does + // support certain versions of the Unicode standard based on the release which indicates which + // characters it _can_ support for rendering. That means this field indicates the minimum version + // of Android needed to render this language but not necessarily have Locale support for it. Note + // also that Android always allows defining translation strings for language codes that it does + // not recognize/natively support. + // + // https://developer.android.com/guide/topics/resources/internationalization provides a reference + // for Android Unicode support. Note that Android 7.0 and above leverages the ICU4J library for + // handling newer Unicode standards and is thus less clear about compatibility. The app doesn't + // currently compile in ICU4J. + // + // https://unicode.org/standard/supported.html provides a reference for which scripts are + // supported by each Unicode version. The IANA registry for languages includes scripts: + // http://www.iana.org/assignments/language-subtag-registry/language-subtag-registry. Languages + // also have their scripts defined, so a mapping can be established between language and minimum + // Android SDK version. + int32 min_android_sdk_version = 3; + + // Details of how to identify this language when translating app strings. If missing, this + // language will be unsupported for app strings. + LanguageId app_string_id = 4; + + // Details of how to identify this language when translating content strings. If missing, this + // language will be unsupported for content strings. + LanguageId content_string_id = 5; + + // Details of how to identify this language when selecting audio voiceover translations. If + // missing, this language will be unsupported for audio voiceovers. + LanguageId audio_translation_id = 6; + + // A representation of identifying a particular language in different contexts. + message LanguageId { + // Corresponds to the type of language being identified. + oneof language_type { + // Indicates that this identifier corresponds to an IETF BCP 47 identified language. + IetfBcp47LanguageId ietf_bcp47_id = 1; + + // Indicates that this identifier corresponds to a macaronic language. + MacaronicLanguageId macaronic_id = 2; + } + + // Identifier for retrieving Android resources corresponding to this language. If this is absent + // then it's assumed there are no Android resources corresponding to this language. + AndroidLanguageId android_resources_language_id = 3; + } + + // An identifier representation for IETF BCP 47 languages. Note that ISO 639-1/2/3 is not used + // since it can't represent regional languages like Canadian French. See the following article for + // details on how IETF language tags are formed: https://www.unfoldingword.org/ietf. Current tag + // registry: http://www.iana.org/assignments/language-subtag-registry/language-subtag-registry. + // Note that the list above will contain languages not supported on all Android platforms. + message IetfBcp47LanguageId { + // The language tag according to the IETF BCP 47 standard. + string ietf_language_tag = 1; + } + + // An identifier representation for macaronic languages (which are languages which combine two + // others, e.g.: Hinglish). + message MacaronicLanguageId { + // The combined language code for this macaronic language (e.g. 'hi-en' for Hinglish). Note that + // the constituent parts of the language may not necessarily correspond to ISO 639-1 language + // codes. It's also expected that order matters here: hi-en and en_hi would not correspond to + // the same macaronic language. + string combined_language_code = 1; + } + + // An identifier representation for languages supported on Android via resource directories. Note + // that these may not exactly match with IETF BCP 47 since Android uses a slightly different + // system, and custom types are allowed by providing fake language/region codes (such as for + // hi-en). + message AndroidLanguageId { + // The language code understood by Android (usually an ISO 639-1 code, but custom codes may be + // used). This is always expected to be present for valid language IDs. + string language_code = 1; + + // The language code understood by Android (usually an ISO 3166 alpha-2 code, but custom codes + // may be used). This may be absent when referencing macro languages. + string region_code = 2; + } +} + +// Defines the support for a specific region. +message RegionSupportDefinition { + // The specific region corresponding to this definition. + OppiaRegion region = 1; + + // The IETF BCP 47 identifier corresponding to the region this region represents. + IetfBcp47RegionId region_id = 2; + + // The list of languages corresponding to this region. Note that the app first prioritizes the + // selected language by the user (either via the Android system or through a language picker) when + // deciding which language to represent this region. If the user's locale and language selection + // do not match the region support definitions, the first language of this list will be used. If + // no languages are defined, the app will fall back to rely on the system's default behavior for + // the user's locale (if the system supports it, otherwise the default locale will be used). + repeated OppiaLanguage languages = 3; + + // An identifier for IETF BCP 47 regions. The current registry of available regions is at: + // http://www.iana.org/assignments/language-subtag-registry/language-subtag-registry (search for + // 'Type: region'). + message IetfBcp47RegionId { + // The region tag according to the IETF BCP 47 standard, usually either an ISO 3166 alpha-2 + // country code or a UN M.49 numeric-3 area code. + string ietf_region_tag = 1; + } +} + +// Corresponds to a serializable context that can be used to reconstitute an OppiaLocale. +message OppiaLocaleContext { + // The definition corresponding to this context's language. + LanguageSupportDefinition language_definition = 1; + + // The definition corresponding to this context's fallback language. + LanguageSupportDefinition fallback_language_definition = 2; + + // The definition corresponding to this context's region. + RegionSupportDefinition region_definition = 3; + + // Indicates how the language & fallback support definitions should be used in this context. Note + // that this actually implies this context should not be used for other cases not indicated by + // this usage mode. + LanguageUsageMode usage_mode = 4; + + // Corresponds to different usage modes that language definitions can be used in. + enum LanguageUsageMode { + // Indicates that no usage mode is defined (meaning that no aspects of language definitions + // should be used). + USAGE_MODE_UNSPECIFIED = 0; + + // Indicates that the language definitions should only be used for retrieving app strings. + APP_STRINGS = 1; + + // Indicates that the language definitions should only be used for translating content strings. + CONTENT_STRINGS = 2; + + // Indicates that the language definitions should only be used for managing audio translations. + AUDIO_TRANSLATIONS = 3; + } +} + +// Represents the selection of an app language. +message AppLanguageSelection { + // Different types of selections that can be made when choosing an app language. + oneof selection_type { + // Indicates that the system-specified language should be used for app translations. The actual + // boolean value specified here doesn't matter. + bool use_system_language_or_app_default = 1; + + // Indicates that the specified language should be used for app string translations. + OppiaLanguage selected_language = 2; + } +} + +// Represents the selection of a written content translation language. +message WrittenTranslationLanguageSelection { + // Different types of selections that can be made when choosing a language for written content + // translations. If this is not defined, the selection is assumed to be use_app_language. + oneof selection_type { + // Indicates that the selected app language should be used for written content translations. + bool use_app_language = 1; + + // Indicates that the specified language should be used for written content translations. + OppiaLanguage selected_language = 2; + } +} + +// Represents the selection of an audio voiceover language. +message AudioTranslationLanguageSelection { + // Different types of selections that can be made when choosing a language for audio voiceovers. + // If this is not defined, the selection is assumed to be use_app_language. + oneof selection_type { + // Indicates that the selected app language should be used for audio voiceovers. + bool use_app_language = 1; + + // Indicates that the specified language should be used for audio voiceovers. + OppiaLanguage selected_language = 2; + } +} diff --git a/model/src/main/proto/translation.proto b/model/src/main/proto/translation.proto index aae405c34d0..09a28b74732 100644 --- a/model/src/main/proto/translation.proto +++ b/model/src/main/proto/translation.proto @@ -16,3 +16,9 @@ message Translation { string html = 1; bool needs_update = 2; } + +// Represents the context for translating written content strings. +message WrittenTranslationContext { + // A map from content ID to translation. + map translations = 1; +} diff --git a/oppia_android_application.bzl b/oppia_android_application.bzl index 8c17e56cbec..870642a617a 100644 --- a/oppia_android_application.bzl +++ b/oppia_android_application.bzl @@ -232,23 +232,27 @@ def oppia_android_application(name, config_file, **kwargs): module_zip_name = "%s_module_zip" % name native.android_binary( name = binary_name, + tags = ["manual"], **kwargs ) _convert_apk_to_module_aab( name = module_aab_name, input_file = ":%s.apk" % binary_name, output_file = "%s.aab" % module_aab_name, + tags = ["manual"], ) _convert_module_aab_to_structured_zip( name = module_zip_name, input_file = ":%s.aab" % module_aab_name, output_file = "%s.zip" % module_zip_name, + tags = ["manual"], ) _bundle_module_zip_into_deployable_aab( name = name, input_file = ":%s.zip" % module_zip_name, config_file = config_file, output_file = "%s.aab" % name, + tags = ["manual"], ) def declare_deployable_application(name, aab_target): @@ -275,4 +279,5 @@ def declare_deployable_application(name, aab_target): _generate_apks_and_install( name = name, input_file = aab_target, + tags = ["manual"], ) diff --git a/scripts/assets/file_content_validation_checks.textproto b/scripts/assets/file_content_validation_checks.textproto index 0354796382a..c9669b41a16 100644 --- a/scripts/assets/file_content_validation_checks.textproto +++ b/scripts/assets/file_content_validation_checks.textproto @@ -87,7 +87,6 @@ file_content_checks { file_path_regex: "app/src/main/res/values/strings.xml" prohibited_content_regex: "translatable=\"false\"" failure_message: "Untranslatable strings should go in untranslated_strings.xml, instead." - exempted_file_name: "app/src/main/res/values/untranslated_strings.xml" } file_content_checks { file_path_regex: ".+?.xml" @@ -95,3 +94,18 @@ file_content_checks { failure_message: "All strings outside strings.xml must be marked as not translatable, or moved to strings.xml." exempted_file_patterns: "app/src/main/res/values.*?/strings\\.xml" } +file_content_checks { + file_path_regex: ".+?.xml" + prohibited_content_regex: "" + failure_message: "All plurals outside strings.xml must be marked as not translatable, or moved to strings.xml." + exempted_file_patterns: "app/src/main/res/values.*?/strings\\.xml" +} +file_content_checks { + file_path_regex: ".+?.kt" + prohibited_content_regex: "android.text.BidiFormatter" + failure_message: "Do not use Android's BidiFormatter directly. Instead, use the wrapper utility OppiaBidiFormatter so that tests can verify that formatting actually occurs on select strings." + exempted_file_name: "scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt" + exempted_file_name: "testing/src/main/java/org/oppia/android/testing/robolectric/ShadowBidiFormatter.kt" + exempted_file_name: "testing/src/test/java/org/oppia/android/testing/robolectric/ShadowBidiFormatterTest.kt" + exempted_file_name: "utility/src/main/java/org/oppia/android/util/locale/OppiaBidiFormatterImpl.kt" +} diff --git a/scripts/assets/test_file_exemptions.textproto b/scripts/assets/test_file_exemptions.textproto index edb70a7eeca..a4acde2b959 100644 --- a/scripts/assets/test_file_exemptions.textproto +++ b/scripts/assets/test_file_exemptions.textproto @@ -653,6 +653,7 @@ exempted_file_path: "utility/src/main/java/org/oppia/android/util/accessibility/ exempted_file_path: "utility/src/main/java/org/oppia/android/util/accessibility/AccessibilityTestModule.kt" exempted_file_path: "utility/src/main/java/org/oppia/android/util/accessibility/FakeAccessibilityChecker.kt" exempted_file_path: "utility/src/main/java/org/oppia/android/util/caching/AssetRepository.kt" +exempted_file_path: "utility/src/main/java/org/oppia/android/util/caching/AssetRepositoryImpl.kt" exempted_file_path: "utility/src/main/java/org/oppia/android/util/caching/CacheAssetsLocally.kt" exempted_file_path: "utility/src/main/java/org/oppia/android/util/caching/CachingModule.kt" exempted_file_path: "utility/src/main/java/org/oppia/android/util/caching/LoadImagesFromAssets.kt" @@ -666,6 +667,8 @@ exempted_file_path: "utility/src/main/java/org/oppia/android/util/data/DataProvi exempted_file_path: "utility/src/main/java/org/oppia/android/util/extensions/ContextExtensions.kt" exempted_file_path: "utility/src/main/java/org/oppia/android/util/gcsresource/GcsResourceAnnotations.kt" exempted_file_path: "utility/src/main/java/org/oppia/android/util/gcsresource/GcsResourceModule.kt" +exempted_file_path: "utility/src/main/java/org/oppia/android/util/locale/OppiaBidiFormatter.kt" +exempted_file_path: "utility/src/main/java/org/oppia/android/util/locale/OppiaLocale.kt" exempted_file_path: "utility/src/main/java/org/oppia/android/util/logging/ConsoleLogger.kt" exempted_file_path: "utility/src/main/java/org/oppia/android/util/logging/EventLogger.kt" exempted_file_path: "utility/src/main/java/org/oppia/android/util/logging/ExceptionLogger.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 675ebe06f53..9cf2871dd1a 100644 --- a/scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt +++ b/scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt @@ -58,6 +58,12 @@ class RegexPatternValidationCheckTest { "Untranslatable strings should go in untranslated_strings.xml, instead." private val translatableStringsGoInMainFileErrorMessage = "All strings outside strings.xml must be marked as not translatable, or moved to strings.xml." + private val translatablePluralsGoInMainFileErrorMessage = + "All plurals outside strings.xml must be marked as not translatable, or moved to strings.xml." + private val importingAndroidBidiFormatterErrorMessage = + "Do not use Android's BidiFormatter directly. Instead, use the wrapper utility" + + " OppiaBidiFormatter so that tests can verify that formatting actually occurs on select" + + " strings." 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." @@ -871,6 +877,48 @@ class RegexPatternValidationCheckTest { ) } + @Test + fun testFileContent_translatablePlural_outsidePrimaryStringsFile_fileContentIsNotCorrect() { + val prohibitedContent = "" + tempFolder.newFolder("testfiles", "app", "src", "main", "res", "values") + val stringFilePath = "app/src/main/res/values/untranslated_strings.xml" + tempFolder.newFile("testfiles/$stringFilePath").writeText(prohibitedContent) + + val exception = assertThrows(Exception::class) { + runScript() + } + + assertThat(exception).hasMessageThat().contains(REGEX_CHECK_FAILED_OUTPUT_INDICATOR) + assertThat(outContent.toString().trim()) + .isEqualTo( + """ + $stringFilePath:1: $translatablePluralsGoInMainFileErrorMessage + $wikiReferenceNote + """.trimIndent() + ) + } + + @Test + fun testFileContent_bidiFormatterImport_fileContentIsNotCorrect() { + val prohibitedContent = "import android.text.BidiFormatter" + tempFolder.newFolder("testfiles", "data", "src", "main") + val stringFilePath = "data/src/main/SomeController.kt" + tempFolder.newFile("testfiles/$stringFilePath").writeText(prohibitedContent) + + val exception = assertThrows(Exception::class) { + runScript() + } + + assertThat(exception).hasMessageThat().contains(REGEX_CHECK_FAILED_OUTPUT_INDICATOR) + assertThat(outContent.toString().trim()) + .isEqualTo( + """ + $stringFilePath:1: $importingAndroidBidiFormatterErrorMessage + $wikiReferenceNote + """.trimIndent() + ) + } + @Test fun testFileContent_untranslatableString_outsidePrimaryStringsFile_fileContentIsCorrect() { val prohibitedContent = "Translatable" diff --git a/testing/BUILD.bazel b/testing/BUILD.bazel index c57d9e81d61..ba3984493d8 100644 --- a/testing/BUILD.bazel +++ b/testing/BUILD.bazel @@ -89,6 +89,7 @@ TEST_DEPS = [ "//third_party:org_jetbrains_kotlinx_kotlinx-coroutines-test", "//third_party:org_mockito_mockito-core", "//third_party:robolectric_android-all", + "//utility/src/main/java/org/oppia/android/util/caching:asset_prod_module", "//utility/src/main/java/org/oppia/android/util/caching/testing:caching_test_module", "//utility/src/main/java/org/oppia/android/util/logging:prod_module", "//utility/src/main/java/org/oppia/android/util/networking:debug_module", diff --git a/testing/build.gradle b/testing/build.gradle index ef2b75ced3a..f139aa1bbf0 100644 --- a/testing/build.gradle +++ b/testing/build.gradle @@ -57,6 +57,7 @@ dependencies { 'androidx.test.espresso:espresso-core:3.2.0', 'androidx.test:runner:1.2.0', 'com.google.dagger:dagger:2.24', + 'com.google.protobuf:protobuf-javalite:3.17.3', 'com.google.truth:truth:0.43', 'nl.dionsegijn:konfetti:1.2.5', 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.2.2', diff --git a/testing/src/main/java/org/oppia/android/testing/data/BUILD.bazel b/testing/src/main/java/org/oppia/android/testing/data/BUILD.bazel new file mode 100644 index 00000000000..e57cf3bf175 --- /dev/null +++ b/testing/src/main/java/org/oppia/android/testing/data/BUILD.bazel @@ -0,0 +1,26 @@ +# TODO(#1532): Rename file to 'BUILD' post-Gradle. +""" +Package for common test utilities corresponding to data processing & data providers. +""" + +load("@dagger//:workspace_defs.bzl", "dagger_rules") +load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_android_library") + +kt_android_library( + name = "data_provider_test_monitor", + testonly = True, + srcs = [ + "DataProviderTestMonitor.kt", + ], + visibility = ["//:oppia_testing_visibility"], + deps = [ + ":dagger", + "//testing/src/main/java/org/oppia/android/testing/mockito", + "//testing/src/main/java/org/oppia/android/testing/threading:test_coroutine_dispatchers", + "//third_party:org_mockito_mockito-core", + "//utility/src/main/java/org/oppia/android/util/data:data_provider", + "//utility/src/main/java/org/oppia/android/util/data:data_providers", + ], +) + +dagger_rules() diff --git a/testing/src/main/java/org/oppia/android/testing/data/DataProviderTestMonitor.kt b/testing/src/main/java/org/oppia/android/testing/data/DataProviderTestMonitor.kt new file mode 100644 index 00000000000..441e484d238 --- /dev/null +++ b/testing/src/main/java/org/oppia/android/testing/data/DataProviderTestMonitor.kt @@ -0,0 +1,179 @@ +package org.oppia.android.testing.data + +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import org.mockito.ArgumentCaptor +import org.mockito.Mockito.atLeastOnce +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.reset +import org.mockito.Mockito.verify +import org.oppia.android.testing.data.DataProviderTestMonitor.Factory +import org.oppia.android.testing.mockito.anyOrNull +import org.oppia.android.testing.threading.TestCoroutineDispatchers +import org.oppia.android.util.data.AsyncResult +import org.oppia.android.util.data.DataProvider +import org.oppia.android.util.data.DataProviders.Companion.toLiveData +import javax.inject.Inject + +// TODO(#3813): Migrate all data provider tests over to using this utility. +/** + * A test monitor for [DataProvider]s that provides operations to simplify waiting for the + * provider's results, or to verify that notifications actually change the data provider when + * expected. + * + * Note that this utility leverages [TestCoroutineDispatchers] to synchronize data providers with + * the test thread. This may result in other operations unintentionally being completed, so care + * should be taken when using this monitor in complex multi-operation scenarios. Further, this + * utility does not internally support synchronizing against data providers which perform operations + * at a future time (i.e. those that would require [TestCoroutineDispatchers.advanceTimeBy] to + * synchronize). There are separate methods to use for these cases (e.g. + * [ensureNextResultIsSuccess]) which can be called after manually synchronizing test dispatchers + * (even for future timed operations). + * + * To use this monitor, inject its [Factory] and either create a new monitor or use an available + * helper method. + */ +class DataProviderTestMonitor private constructor( + private val testCoroutineDispatchers: TestCoroutineDispatchers, + private val dataProvider: DataProvider +) { + private val mockObserver by lazy { createMock>>() } + private val resultCaptor by lazy { createCaptor>() } + private val liveData: LiveData> by lazy { dataProvider.toLiveData() } + + /** + * Waits for the data provider to execute & returns the most recent [AsyncResult] produced by the + * provider. + * + * This method assumes that the data provider has at least one update (which may be any result). + * + * This can be useful to verify that other operations notify the data provider since subsequent + * calls to this method will reset state to ensure the latest state is always being observed. + */ + fun waitForNextResult(): AsyncResult { + reset(mockObserver) + testCoroutineDispatchers.runCurrent() + return ensureNextResultIsPresent() + } + + /** + * Same as [waitForNextResult] except this also assumes that the most recent result is a success & + * then returns the success value of the result. + */ + fun waitForNextSuccessResult(): T = retrieveSuccess(this::waitForNextResult) + + /** + * A version of [waitForNextSuccessResult] that doesn't wait for the operation. This is useful for + * cases when the calling code is already synchronizing dispatchers and doing so again would break + * monitor behavior. + */ + fun ensureNextResultIsSuccess(): T = retrieveSuccess(this::ensureNextResultIsPresent) + + /** + * Same as [waitForNextResult] except this also assumes that the most recent result is a failure & + * then returns the reason for the result's failure. + */ + fun waitForNextFailingResult(): Throwable = retrieveFailing(this::waitForNextResult) + + /** Same as [ensureNextResultIsSuccess] except for failing cases instead of successes. */ + fun ensureNextResultIsFailing(): Throwable = retrieveFailing(this::ensureNextResultIsPresent) + + /** + * Waits for the data provider to potentially update, then verifies that no new result is + * available from the provider. This can be used in contrast with [waitForNextResult] to validate + * that the data provider has not been been notified or updated. + */ + fun verifyProviderIsNotUpdated() { + reset(mockObserver) + testCoroutineDispatchers.runCurrent() + verify(mockObserver, never()).onChanged(anyOrNull()) + } + + private fun startObservingDataProvider() { + liveData.observeForever(mockObserver) + } + + private fun stopObservingDataProvider() { + liveData.removeObserver(mockObserver) + } + + private fun ensureNextResultIsPresent(): AsyncResult { + // Note to reader: if you encounter the following line in a stack trace then that means the + // monitored data provider has not updated in the time before calling one of the waitFor* or + // ensure* methods. For uses of ensure* methods, this might mean you should use a waitFor* + // method, instead. For uses of waitFor* methods, this might mean you're using one of the + // Factory convenience methods instead of creating a monitor. If you're using a waitFor* method + // and an explicit monitor, it either means the provider hasn't updated or it requires advancing + // the clock (in which case you should use TestCoroutineDispatchers.advanceUntilIdle() and an + // ensure* method from this class). + verify(mockObserver, atLeastOnce()).onChanged(resultCaptor.capture()) + reset(mockObserver) + return resultCaptor.value + } + + private fun retrieveSuccess(operation: () -> AsyncResult): T { + return operation().also { + // Sanity check. + check(it.isSuccess()) { "Expected next result to be a success, not: $it" } + }.getOrThrow() + } + + private fun retrieveFailing(operation: () -> AsyncResult): Throwable { + return operation().also { + // Sanity check. + check(it.isFailure()) { "Expected next result to be a failure, not: $it" } + }.getErrorOrNull() ?: error("Expect result to have a failure error") + } + + /** + * Factory for creating new [DataProviderTestMonitor]s. This class should be injected within tests + * at the application scope & requires the host test application component to include the + * necessary modules for [TestCoroutineDispatchers]. + */ + class Factory @Inject constructor( + private val testCoroutineDispatchers: TestCoroutineDispatchers + ) { + /** Returns a new monitor for the specified [DataProvider]. */ + fun createMonitor(dataProvider: DataProvider): DataProviderTestMonitor { + return DataProviderTestMonitor(testCoroutineDispatchers, dataProvider).also { + // Immediately start observing since it doesn't make sense not to always be observing for + // the current monitor. + it.startObservingDataProvider() + } + } + + /** + * Convenience function for monitoring the specified data provider & waiting for its next result + * (expected to be a success). See [waitForNextSuccessResult] for specifics. + * + * This method ensures that only one result is ever captured from the data provider. + */ + fun waitForNextSuccessfulResult(dataProvider: DataProvider): T { + val monitor = createMonitor(dataProvider) + return monitor.waitForNextSuccessResult().also { + monitor.stopObservingDataProvider() + } + } + + /** + * Convenience function for monitoring the specified data provider & waiting for its next result + * (expected to be a failure). See [waitForNextFailingResult] for specifics. + * + * This method ensures that only one result is ever captured from the data provider. + */ + fun waitForNextFailureResult(dataProvider: DataProvider): Throwable { + val monitor = createMonitor(dataProvider) + return monitor.waitForNextFailingResult().also { + monitor.stopObservingDataProvider() + } + } + } + + private companion object { + private inline fun createMock(): T = mock(T::class.java) + + private inline fun createCaptor(): ArgumentCaptor = + ArgumentCaptor.forClass(T::class.java) + } +} diff --git a/testing/src/main/java/org/oppia/android/testing/robolectric/BUILD.bazel b/testing/src/main/java/org/oppia/android/testing/robolectric/BUILD.bazel index c6ee41183b7..af491298ca6 100644 --- a/testing/src/main/java/org/oppia/android/testing/robolectric/BUILD.bazel +++ b/testing/src/main/java/org/oppia/android/testing/robolectric/BUILD.bazel @@ -31,4 +31,22 @@ kt_android_library( ], ) +# Note that this library is visibility restricted to avoid wide use of custom shadows in the +# codebase. +kt_android_library( + name = "shadow_bidi_formatter", + testonly = True, + srcs = [ + "ShadowBidiFormatter.kt", + ], + visibility = [ + "//testing/src/test/java/org/oppia/android/testing/robolectric:__pkg__", + "//utility/src/test/java/org/oppia/android/util/locale:__pkg__", + "//utility/src/test/java/org/oppia/android/util/locale/testing:__pkg__", + ], + deps = [ + "//third_party:org_robolectric_robolectric", + ], +) + dagger_rules() diff --git a/testing/src/main/java/org/oppia/android/testing/robolectric/ShadowBidiFormatter.kt b/testing/src/main/java/org/oppia/android/testing/robolectric/ShadowBidiFormatter.kt new file mode 100644 index 00000000000..a24d0daaed3 --- /dev/null +++ b/testing/src/main/java/org/oppia/android/testing/robolectric/ShadowBidiFormatter.kt @@ -0,0 +1,105 @@ +package org.oppia.android.testing.robolectric + +import android.text.BidiFormatter +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import org.robolectric.annotation.RealObject +import org.robolectric.shadow.api.Shadow +import org.robolectric.util.ReflectionHelpers +import java.util.Locale + +/** + * A custom Robolectric shadow for tracking interactions with [BidiFormatter]. + * + * This is the main way to ensure that the wrapper actually calls through to Android's + * bidirectional formatter. Note that the reason this isn't used broadly is for better + * compatibility: + * - Shadows are complicated to set up (versus using existing Dagger module mechanism with + * ubiquitous examples) + * - Shadows don't work when running shared app layer tests with Espresso + * - Custom shadows are discouraged, so this restricts use + * - The wrapper can be replaced with a test double which can more easily be configured to rely on + * the real implementation & perform test-only tracking that's a bit more complicated with the + * shadow + * - Shadow static state can leak between tests whereas singleton state in Dagger is properly + * recreated between test application instances + */ +@Implements(BidiFormatter::class) +class ShadowBidiFormatter { + companion object { + private val trackedFormatters = mutableMapOf() + + /** + * Returns new instances of [BidiFormatter] based on the specified [Locale]. This is called by + * Robolectric as a replacement to [BidiFormatter.getInstance]. + * + * Note that Android may reuse formatters internally, so there's no guarantee a new + * implementation will be returned for subsequent calls (even if different [Locale]s are used). + */ + @Implementation + @JvmStatic + fun getInstance(locale: Locale): BidiFormatter { + return trackedFormatters.getOrPut(locale) { + Shadow.directlyOn( + BidiFormatter::class.java, "getInstance", + ReflectionHelpers.ClassParameter.from(Locale::class.java, locale) + ) + } + } + + /** + * Returns the [ShadowBidiFormatter] corresponding to the specified [Locale] (as created with + * [getInstance]) or null if none exists. + */ + fun lookUpFormatter(locale: Locale): ShadowBidiFormatter? = + trackedFormatters[locale]?.let { shadowOf(it) } + + /** + * Returns all [ShadowBidiFormatter]s created via [getInstance] since the last call to [reset]. + */ + fun lookUpFormatters(): Map = + trackedFormatters.mapValues { (_, formatter) -> shadowOf(formatter) } + + /** + * Resets all tracked formatters up to now. This should always be called in a tear-down method + * to avoid leaking state between tests. + */ + fun reset() { + // The tracked formatters are cleared to make it seem like new formatters are being created. + // Similarly each individual shadow needs to be reset since Android only creates a couple of + // Bidi formatters & Robolectric will keep a 1:1 relationship between classes and their + // shadows. + lookUpFormatters().values.forEach { it.wrappedSequences.clear() } + trackedFormatters.clear() + } + + private fun shadowOf(bidiFormatter: BidiFormatter): ShadowBidiFormatter = + Shadow.extract(bidiFormatter) as ShadowBidiFormatter + } + + @RealObject + private lateinit var realObject: BidiFormatter + + private val wrappedSequences = mutableListOf() + + /** + * Robolectric shadow override of [BidiFormatter.unicodeWrap]. Note that only the [CharSequence] + * version of unicode wrap is implemented here, so callers should make sure to only use that + * overload. + */ + @Suppress("unused") // Incorrect warning; Robolectric uses this via reflection. + @Implementation + fun unicodeWrap(str: CharSequence): CharSequence { + wrappedSequences += str + return Shadow.directlyOn( + realObject, BidiFormatter::class.java, "unicodeWrap", + ReflectionHelpers.ClassParameter.from(CharSequence::class.java, str) + ) + } + + /** Returns the most recent wrapped sequence as per the call to [unicodeWrap]. */ + fun getLastWrappedSequence(): CharSequence? = wrappedSequences.lastOrNull() + + /** Returns all sequences passed to [unicodeWrap] for this formatter. */ + fun getAllWrappedSequences(): List = wrappedSequences +} diff --git a/testing/src/main/java/org/oppia/android/testing/time/FakeOppiaClock.kt b/testing/src/main/java/org/oppia/android/testing/time/FakeOppiaClock.kt index 3bcb944c283..d2c3b433902 100644 --- a/testing/src/main/java/org/oppia/android/testing/time/FakeOppiaClock.kt +++ b/testing/src/main/java/org/oppia/android/testing/time/FakeOppiaClock.kt @@ -2,6 +2,7 @@ package org.oppia.android.testing.time import android.annotation.SuppressLint import android.os.SystemClock +import org.oppia.android.testing.time.FakeOppiaClock.FakeTimeMode import org.oppia.android.util.system.OppiaClock import java.text.SimpleDateFormat import java.util.Calendar @@ -24,6 +25,11 @@ class FakeOppiaClock @Inject constructor() : OppiaClock { private var fixedFakeTimeMs: Long = 0 private var fakeTimeMode: FakeTimeMode = FakeTimeMode.MODE_WALL_CLOCK_TIME + init { + // Ensure tests that rely on this clock are always operating in UTC. + TimeZone.setDefault(TimeZone.getTimeZone("UTC")) + } + override fun getCurrentTimeMs(): Long { return when (fakeTimeMode) { FakeTimeMode.MODE_WALL_CLOCK_TIME -> System.currentTimeMillis() @@ -32,12 +38,6 @@ class FakeOppiaClock @Inject constructor() : OppiaClock { } } - override fun getCurrentCalendar(): Calendar { - val calendar = Calendar.getInstance() - calendar.timeInMillis = getCurrentTimeMs() - return calendar - } - /** * Sets the current wall-clock time in milliseconds since the Unix epoch, in UTC. * diff --git a/testing/src/test/java/org/oppia/android/testing/data/BUILD.bazel b/testing/src/test/java/org/oppia/android/testing/data/BUILD.bazel new file mode 100644 index 00000000000..4b84736203b --- /dev/null +++ b/testing/src/test/java/org/oppia/android/testing/data/BUILD.bazel @@ -0,0 +1,37 @@ +# TODO(#1532): Rename file to 'BUILD' post-Gradle. +""" +Tests for data processing test utilities. +""" + +load("@dagger//:workspace_defs.bzl", "dagger_rules") +load("//:oppia_android_test.bzl", "oppia_android_test") + +oppia_android_test( + name = "DataProviderTestMonitorTest", + srcs = ["DataProviderTestMonitorTest.kt"], + custom_package = "org.oppia.android.testing.data", + test_class = "org.oppia.android.testing.data.DataProviderTestMonitorTest", + test_manifest = "//testing:test_manifest", + deps = [ + ":dagger", + "//domain/src/main/java/org/oppia/android/domain/locale:locale_controller", + "//domain/src/main/java/org/oppia/android/domain/translation:translation_controller", + "//model:languages_java_proto_lite", + "//testing", + "//testing/src/main/java/org/oppia/android/testing/data:data_provider_test_monitor", + "//testing/src/main/java/org/oppia/android/testing/robolectric:test_module", + "//testing/src/main/java/org/oppia/android/testing/threading:test_coroutine_dispatchers", + "//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", + "//third_party:junit_junit", + "//third_party:org_robolectric_robolectric", + "//third_party:robolectric_android-all", + "//utility/src/main/java/org/oppia/android/util/data:data_providers", + "//utility/src/main/java/org/oppia/android/util/locale:prod_module", + "//utility/src/main/java/org/oppia/android/util/networking:debug_module", + ], +) + +dagger_rules() diff --git a/testing/src/test/java/org/oppia/android/testing/data/DataProviderTestMonitorTest.kt b/testing/src/test/java/org/oppia/android/testing/data/DataProviderTestMonitorTest.kt new file mode 100644 index 00000000000..152ea5a7dc9 --- /dev/null +++ b/testing/src/test/java/org/oppia/android/testing/data/DataProviderTestMonitorTest.kt @@ -0,0 +1,888 @@ +package org.oppia.android.testing.data + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import dagger.BindsInstance +import dagger.Component +import dagger.Module +import dagger.Provides +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.exceptions.verification.NeverWantedButInvoked +import org.oppia.android.domain.oppialogger.LogStorageModule +import org.oppia.android.testing.TestLogReportingModule +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.data.AsyncDataSubscriptionManager +import org.oppia.android.util.data.AsyncResult +import org.oppia.android.util.data.DataProvider +import org.oppia.android.util.data.DataProviders +import org.oppia.android.util.data.DataProvidersInjector +import org.oppia.android.util.data.DataProvidersInjectorProvider +import org.oppia.android.util.locale.LocaleProdModule +import org.oppia.android.util.logging.LoggerModule +import org.oppia.android.util.networking.NetworkConnectionUtilDebugModule +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import javax.inject.Inject +import javax.inject.Singleton + +/** Tests for [DataProviderTestMonitor]. */ +// FunctionName: test names are conventionally named with underscores. +// SameParameterValue: tests should have specific context included/excluded for readability. +// ThrowableNotThrown: exceptions are created for AsyncResults. +@Suppress("FunctionName", "SameParameterValue", "ThrowableNotThrown") +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +@Config(application = DataProviderTestMonitorTest.TestApplication::class) +class DataProviderTestMonitorTest { + @Inject + lateinit var monitorFactory: DataProviderTestMonitor.Factory + + @Inject + lateinit var dataProviders: DataProviders + + @Inject + lateinit var asyncDataSubscriptionManager: AsyncDataSubscriptionManager + + @Inject + lateinit var testCoroutineDispatchers: TestCoroutineDispatchers + + @Before + fun setUp() { + setUpTestApplicationComponent() + } + + /* Tests for createMonitor */ + + @Test + fun testCreateMonitor_returnsNewObservingMonitor() { + var fakeLoadMemoryCallbackCalled = false + val fakeLoadMemoryCallback: () -> String = { + fakeLoadMemoryCallbackCalled = true + "test value" + } + val dataProvider = dataProviders.createInMemoryDataProvider("test", fakeLoadMemoryCallback) + + monitorFactory.createMonitor(dataProvider) + testCoroutineDispatchers.runCurrent() + + // Verify that the data provider was executed (indicating that the monitor is live). + assertThat(fakeLoadMemoryCallbackCalled).isTrue() + } + + @Test + fun testCreateMonitor_twice_returnsDifferentMonitors() { + val dataProvider = dataProviders.createInMemoryDataProvider("test") { 0 } + + val monitor1 = monitorFactory.createMonitor(dataProvider) + val monitor2 = monitorFactory.createMonitor(dataProvider) + + // Verify that the two monitors are different + assertThat(monitor1).isNotEqualTo(monitor2) + } + + /* Tests for waitForNextResult */ + + @Test + fun testWaitForNextResult_pendingDataProvider_returnsResult() { + val dataProvider = dataProviders.createInMemoryDataProviderAsync("test") { + AsyncResult.pending() + } + val monitor = monitorFactory.createMonitor(dataProvider) + + val result = monitor.waitForNextResult() + + assertThat(result.isPending()).isTrue() + } + + @Test + fun testWaitForNextResult_failingDataProvider_returnsResult() { + val dataProvider = dataProviders.createInMemoryDataProviderAsync("test") { + AsyncResult.failed(Exception("Failure")) + } + val monitor = monitorFactory.createMonitor(dataProvider) + + val result = monitor.waitForNextResult() + + assertThat(result.isFailure()).isTrue() + assertThat(result.getErrorOrNull()).hasMessageThat().contains("Failure") + } + + @Test + fun testWaitForNextResult_successfulDataProvider_returnsResult() { + val dataProvider = dataProviders.createInMemoryDataProviderAsync("test") { + AsyncResult.success("str value") + } + val monitor = monitorFactory.createMonitor(dataProvider) + + val result = monitor.waitForNextResult() + + assertThat(result.isSuccess()).isTrue() + assertThat(result.getOrThrow()).isEqualTo("str value") + } + + @Test + fun testWaitForNextResult_failureThenSuccess_returnsLatest() { + val dataProvider = + createDataProviderWithResultsQueue( + "test", AsyncResult.failed(Exception("Failure")), AsyncResult.success("str value") + ) + val monitor = monitorFactory.createMonitor(dataProvider) + + // Update the provider, then wait for the result. + asyncDataSubscriptionManager.notifyChangeAsync("test") + val result = monitor.waitForNextResult() + + assertThat(result.isSuccess()).isTrue() + assertThat(result.getOrThrow()).isEqualTo("str value") + } + + @Test + fun testWaitForNextResult_differentValues_notified_returnsLatest() { + val dataProvider = + createDataProviderWithResultsQueue( + "test", AsyncResult.success("first"), AsyncResult.success("second") + ) + val monitor = monitorFactory.createMonitor(dataProvider) + monitor.waitForNextResult() + + asyncDataSubscriptionManager.notifyChangeAsync("test") + val result = monitor.waitForNextResult() + + assertThat(result.isSuccess()).isTrue() + assertThat(result.getOrThrow()).isEqualTo("second") + } + + /* Tests for waitForNextSuccessResult */ + + @Test + fun testWaitForNextSuccessResult_pendingDataProvider_throwsException() { + val dataProvider = dataProviders.createInMemoryDataProviderAsync("test") { + AsyncResult.pending() + } + val monitor = monitorFactory.createMonitor(dataProvider) + + val failure = assertThrows(IllegalStateException::class) { monitor.waitForNextSuccessResult() } + + assertThat(failure).hasMessageThat().contains("Expected next result to be a success") + } + + @Test + fun testWaitForNextSuccessResult_failingDataProvider_throwsException() { + val dataProvider = dataProviders.createInMemoryDataProviderAsync("test") { + AsyncResult.failed(Exception("Failure")) + } + val monitor = monitorFactory.createMonitor(dataProvider) + + val failure = assertThrows(IllegalStateException::class) { monitor.waitForNextSuccessResult() } + + assertThat(failure).hasMessageThat().contains("Expected next result to be a success") + } + + @Test + fun testWaitForNextSuccessResult_successfulDataProvider_returnsResult() { + val dataProvider = dataProviders.createInMemoryDataProviderAsync("test") { + AsyncResult.success("str value") + } + val monitor = monitorFactory.createMonitor(dataProvider) + + val result = monitor.waitForNextSuccessResult() + + assertThat(result).isEqualTo("str value") + } + + @Test + fun testWaitForNextSuccessResult_failureThenSuccess_notified_returnsLatest() { + val dataProvider = + createDataProviderWithResultsQueue( + "test", AsyncResult.failed(Exception("Failure")), AsyncResult.success("str value") + ) + val monitor = monitorFactory.createMonitor(dataProvider) + asyncDataSubscriptionManager.notifyChangeAsync("test") + + val result = monitor.waitForNextSuccessResult() + + assertThat(result).isEqualTo("str value") + } + + @Test + fun testWaitForNextSuccessResult_successThenFailure_notified_throwsException() { + val dataProvider = + createDataProviderWithResultsQueue( + "test", AsyncResult.success("str value"), AsyncResult.failed(Exception("Failure")) + ) + val monitor = monitorFactory.createMonitor(dataProvider) + asyncDataSubscriptionManager.notifyChangeAsync("test") + + val failure = assertThrows(IllegalStateException::class) { monitor.waitForNextSuccessResult() } + + assertThat(failure).hasMessageThat().contains("Expected next result to be a success") + } + + @Test + fun testWaitForNextSuccessResult_differentValues_notified_returnsLatest() { + val dataProvider = + createDataProviderWithResultsQueue( + "test", AsyncResult.success("first"), AsyncResult.success("second") + ) + val monitor = monitorFactory.createMonitor(dataProvider) + asyncDataSubscriptionManager.notifyChangeAsync("test") + + val result = monitor.waitForNextSuccessResult() + + assertThat(result).isEqualTo("second") + } + + /* Tests for ensureNextResultIsSuccess */ + + @Test + fun testEnsureNextResultIsSuccess_successfulDataProvider_throwsException() { + val dataProvider = dataProviders.createInMemoryDataProviderAsync("test") { + AsyncResult.success("str value") + } + val monitor = monitorFactory.createMonitor(dataProvider) + + // Internal expectation failure since the operation hasn't completed. + assertThrows(AssertionError::class) { monitor.ensureNextResultIsSuccess() } + } + + @Test + fun testEnsureNextResultIsSuccess_successfulDataProvider_wait_returnsResult() { + val dataProvider = dataProviders.createInMemoryDataProviderAsync("test") { + AsyncResult.success("str value") + } + val monitor = monitorFactory.createMonitor(dataProvider) + + testCoroutineDispatchers.runCurrent() + val result = monitor.ensureNextResultIsSuccess() + + assertThat(result).isEqualTo("str value") + } + + @Test + fun testEnsureNextResultIsSuccess_pendingDataProvider_wait_throwsException() { + val dataProvider = dataProviders.createInMemoryDataProviderAsync("test") { + AsyncResult.pending() + } + val monitor = monitorFactory.createMonitor(dataProvider) + + testCoroutineDispatchers.runCurrent() + val failure = assertThrows(IllegalStateException::class) { monitor.ensureNextResultIsSuccess() } + + assertThat(failure).hasMessageThat().contains("Expected next result to be a success") + } + + @Test + fun testEnsureNextResultIsSuccess_failingDataProvider_wait_throwsException() { + val dataProvider = dataProviders.createInMemoryDataProviderAsync("test") { + AsyncResult.failed(Exception("Failure")) + } + val monitor = monitorFactory.createMonitor(dataProvider) + + testCoroutineDispatchers.runCurrent() + val failure = assertThrows(IllegalStateException::class) { monitor.ensureNextResultIsSuccess() } + + assertThat(failure).hasMessageThat().contains("Expected next result to be a success") + } + + @Test + fun testEnsureNextResultIsSuccess_failureThenSuccess_notified_throwsException() { + val dataProvider = + createDataProviderWithResultsQueue( + "test", AsyncResult.failed(Exception("Failure")), AsyncResult.success("str value") + ) + val monitor = monitorFactory.createMonitor(dataProvider) + asyncDataSubscriptionManager.notifyChangeAsync("test") + + // Internal expectation failure since the operation hasn't completed. + assertThrows(AssertionError::class) { monitor.ensureNextResultIsSuccess() } + } + + @Test + fun testEnsureNextResultIsSuccess_failureThenSuccess_notified_wait_returnsLatest() { + val dataProvider = + createDataProviderWithResultsQueue( + "test", AsyncResult.failed(Exception("Failure")), AsyncResult.success("str value") + ) + val monitor = monitorFactory.createMonitor(dataProvider) + asyncDataSubscriptionManager.notifyChangeAsync("test") + + testCoroutineDispatchers.runCurrent() + val result = monitor.ensureNextResultIsSuccess() + + assertThat(result).isEqualTo("str value") + } + + @Test + fun testEnsureNextResultIsSuccess_successThenFailure_notified_wait_throwsException() { + val dataProvider = + createDataProviderWithResultsQueue( + "test", AsyncResult.success("str value"), AsyncResult.failed(Exception("Failure")) + ) + val monitor = monitorFactory.createMonitor(dataProvider) + asyncDataSubscriptionManager.notifyChangeAsync("test") + + testCoroutineDispatchers.runCurrent() + val failure = assertThrows(IllegalStateException::class) { monitor.ensureNextResultIsSuccess() } + + assertThat(failure).hasMessageThat().contains("Expected next result to be a success") + } + + @Test + fun testEnsureNextResultIsSuccess_differentValues_notified_wait_returnsLatest() { + val dataProvider = + createDataProviderWithResultsQueue( + "test", AsyncResult.success("first"), AsyncResult.success("second") + ) + val monitor = monitorFactory.createMonitor(dataProvider) + asyncDataSubscriptionManager.notifyChangeAsync("test") + + testCoroutineDispatchers.runCurrent() + val result = monitor.ensureNextResultIsSuccess() + + assertThat(result).isEqualTo("second") + } + + /* Tests for waitForNextFailingResult */ + + @Test + fun testWaitForNextFailingResult_pendingDataProvider_throwsException() { + val dataProvider = dataProviders.createInMemoryDataProviderAsync("test") { + AsyncResult.pending() + } + val monitor = monitorFactory.createMonitor(dataProvider) + + val failure = assertThrows(IllegalStateException::class) { monitor.waitForNextFailingResult() } + + assertThat(failure).hasMessageThat().contains("Expected next result to be a failure") + } + + @Test + fun testWaitForNextFailingResult_failingDataProvider_returnsResult() { + val dataProvider = dataProviders.createInMemoryDataProviderAsync("test") { + AsyncResult.failed(Exception("Failure")) + } + val monitor = monitorFactory.createMonitor(dataProvider) + + val result = monitor.waitForNextFailingResult() + + assertThat(result).hasMessageThat().contains("Failure") + } + + @Test + fun testWaitForNextFailingResult_successfulDataProvider_throwsException() { + val dataProvider = dataProviders.createInMemoryDataProviderAsync("test") { + AsyncResult.success("str value") + } + val monitor = monitorFactory.createMonitor(dataProvider) + + val failure = assertThrows(IllegalStateException::class) { monitor.waitForNextFailingResult() } + + assertThat(failure).hasMessageThat().contains("Expected next result to be a failure") + } + + @Test + fun testWaitForNextFailingResult_successThenFailure_notified_returnsLatest() { + val dataProvider = + createDataProviderWithResultsQueue( + "test", AsyncResult.success("str value"), AsyncResult.failed(Exception("Failure")) + ) + val monitor = monitorFactory.createMonitor(dataProvider) + asyncDataSubscriptionManager.notifyChangeAsync("test") + + val result = monitor.waitForNextFailingResult() + + assertThat(result).hasMessageThat().contains("Failure") + } + + @Test + fun testWaitForNextFailingResult_failureThenSuccess_notified_throwsException() { + val dataProvider = + createDataProviderWithResultsQueue( + "test", AsyncResult.failed(Exception("Failure")), AsyncResult.success("str value") + ) + val monitor = monitorFactory.createMonitor(dataProvider) + asyncDataSubscriptionManager.notifyChangeAsync("test") + + val failure = assertThrows(IllegalStateException::class) { monitor.waitForNextFailingResult() } + + assertThat(failure).hasMessageThat().contains("Expected next result to be a failure") + } + + @Test + fun testWaitForNextFailingResult_differentValues_notified_returnsLatest() { + val dataProvider = + createDataProviderWithResultsQueue( + "test", AsyncResult.failed(Exception("First")), AsyncResult.failed(Exception("Second")) + ) + val monitor = monitorFactory.createMonitor(dataProvider) + asyncDataSubscriptionManager.notifyChangeAsync("test") + + val result = monitor.waitForNextFailingResult() + + assertThat(result).hasMessageThat().contains("Second") + } + + /* Tests for ensureNextResultIsFailing */ + + @Test + fun testEnsureNextResultIsFailing_failingDataProvider_throwsException() { + val dataProvider = dataProviders.createInMemoryDataProviderAsync("test") { + AsyncResult.failed(Exception("Failure")) + } + val monitor = monitorFactory.createMonitor(dataProvider) + + // Internal expectation failure since the operation hasn't completed. + assertThrows(AssertionError::class) { monitor.ensureNextResultIsSuccess() } + } + + @Test + fun testEnsureNextResultIsFailing_failingDataProvider_wait_returnsResult() { + val dataProvider = dataProviders.createInMemoryDataProviderAsync("test") { + AsyncResult.failed(Exception("Failure")) + } + val monitor = monitorFactory.createMonitor(dataProvider) + + testCoroutineDispatchers.runCurrent() + val result = monitor.ensureNextResultIsFailing() + + assertThat(result).hasMessageThat().contains("Failure") + } + + @Test + fun testEnsureNextResultIsFailing_pendingDataProvider_wait_throwsException() { + val dataProvider = dataProviders.createInMemoryDataProviderAsync("test") { + AsyncResult.pending() + } + val monitor = monitorFactory.createMonitor(dataProvider) + + testCoroutineDispatchers.runCurrent() + val failure = assertThrows(IllegalStateException::class) { monitor.ensureNextResultIsFailing() } + + assertThat(failure).hasMessageThat().contains("Expected next result to be a failure") + } + + @Test + fun testEnsureNextResultIsFailing_successfulDataProvider_wait_throwsException() { + val dataProvider = dataProviders.createInMemoryDataProviderAsync("test") { + AsyncResult.success("str value") + } + val monitor = monitorFactory.createMonitor(dataProvider) + + testCoroutineDispatchers.runCurrent() + val failure = assertThrows(IllegalStateException::class) { monitor.ensureNextResultIsFailing() } + + assertThat(failure).hasMessageThat().contains("Expected next result to be a failure") + } + + @Test + fun testEnsureNextResultIsFailing_successThenFailure_notified_throwsException() { + val dataProvider = + createDataProviderWithResultsQueue( + "test", AsyncResult.success("str value"), AsyncResult.failed(Exception("Failure")) + ) + val monitor = monitorFactory.createMonitor(dataProvider) + asyncDataSubscriptionManager.notifyChangeAsync("test") + + // Internal expectation failure since the operation hasn't completed. + assertThrows(AssertionError::class) { monitor.ensureNextResultIsSuccess() } + } + + @Test + fun testEnsureNextResultIsFailing_successThenFailure_notified_wait_returnsLatest() { + val dataProvider = + createDataProviderWithResultsQueue( + "test", AsyncResult.success("str value"), AsyncResult.failed(Exception("Failure")) + ) + val monitor = monitorFactory.createMonitor(dataProvider) + asyncDataSubscriptionManager.notifyChangeAsync("test") + + testCoroutineDispatchers.runCurrent() + val result = monitor.ensureNextResultIsFailing() + + assertThat(result).hasMessageThat().contains("Failure") + } + + @Test + fun testEnsureNextResultIsFailing_failureThenSuccess_notified_wait_throwsException() { + val dataProvider = + createDataProviderWithResultsQueue( + "test", AsyncResult.failed(Exception("Failure")), AsyncResult.success("str value") + ) + val monitor = monitorFactory.createMonitor(dataProvider) + asyncDataSubscriptionManager.notifyChangeAsync("test") + + testCoroutineDispatchers.runCurrent() + val failure = assertThrows(IllegalStateException::class) { monitor.ensureNextResultIsFailing() } + + assertThat(failure).hasMessageThat().contains("Expected next result to be a failure") + } + + @Test + fun testEnsureNextResultIsFailing_differentValues_notified_wait_returnsLatest() { + val dataProvider = + createDataProviderWithResultsQueue( + "test", AsyncResult.failed(Exception("First")), AsyncResult.failed(Exception("Second")) + ) + val monitor = monitorFactory.createMonitor(dataProvider) + asyncDataSubscriptionManager.notifyChangeAsync("test") + + testCoroutineDispatchers.runCurrent() + val result = monitor.ensureNextResultIsFailing() + + assertThat(result).hasMessageThat().contains("Second") + } + + /* Tests for verifyProviderIsNotUpdated */ + + @Test + fun testVerifyProviderIsNotUpdated_pendingDataProvider_throwsException() { + val dataProvider = dataProviders.createInMemoryDataProviderAsync("test") { + AsyncResult.pending() + } + val monitor = monitorFactory.createMonitor(dataProvider) + + // Verify that the method wsa actually called despite not being expected to have been. + assertThrows(NeverWantedButInvoked::class) { monitor.verifyProviderIsNotUpdated() } + } + + @Test + fun testVerifyProviderIsNotUpdated_failingDataProvider_throwsException() { + val dataProvider = dataProviders.createInMemoryDataProviderAsync("test") { + AsyncResult.failed(Exception("Failure")) + } + val monitor = monitorFactory.createMonitor(dataProvider) + + // Verify that the method wsa actually called despite not being expected to have been. + assertThrows(NeverWantedButInvoked::class) { monitor.verifyProviderIsNotUpdated() } + } + + @Test + fun testVerifyProviderIsNotUpdated_successfulDataProvider_throwsException() { + val dataProvider = dataProviders.createInMemoryDataProviderAsync("test") { + AsyncResult.success("str value") + } + val monitor = monitorFactory.createMonitor(dataProvider) + + // Verify that the method wsa actually called despite not being expected to have been. + assertThrows(NeverWantedButInvoked::class) { monitor.verifyProviderIsNotUpdated() } + } + + @Test + fun testVerifyProviderIsNotUpdated_successThenFailure_notified_throwsException() { + val dataProvider = + createDataProviderWithResultsQueue( + "test", AsyncResult.success("str value"), AsyncResult.failed(Exception("Failure")) + ) + val monitor = monitorFactory.createMonitor(dataProvider) + asyncDataSubscriptionManager.notifyChangeAsync("test") + + // Verify that the method wsa actually called despite not being expected to have been. + assertThrows(NeverWantedButInvoked::class) { monitor.verifyProviderIsNotUpdated() } + } + + @Test + fun testVerifyProviderIsNotUpdated_failureThenSuccess_notified_throwsException() { + val dataProvider = + createDataProviderWithResultsQueue( + "test", AsyncResult.failed(Exception("Failure")), AsyncResult.success("str value") + ) + val monitor = monitorFactory.createMonitor(dataProvider) + + // Update the provider, then wait for the result. + asyncDataSubscriptionManager.notifyChangeAsync("test") + + // Verify that the method wsa actually called despite not being expected to have been. + assertThrows(NeverWantedButInvoked::class) { monitor.verifyProviderIsNotUpdated() } + } + + @Test + fun testVerifyProviderIsNotUpdated_differentValues_notified_throwsException() { + val dataProvider = + createDataProviderWithResultsQueue( + "test", AsyncResult.success("first"), AsyncResult.success("second") + ) + val monitor = monitorFactory.createMonitor(dataProvider) + monitor.waitForNextResult() + + asyncDataSubscriptionManager.notifyChangeAsync("test") + + // Verify that the method wsa actually called despite not being expected to have been. + assertThrows(NeverWantedButInvoked::class) { monitor.verifyProviderIsNotUpdated() } + } + + @Test + fun testVerifyProviderIsNotUpdated_waitForSuccess_noChanges_doesNotThrowException() { + val dataProvider = + createDataProviderWithResultsQueue( + "test", AsyncResult.success("first"), AsyncResult.success("second") + ) + val monitor = monitorFactory.createMonitor(dataProvider) + monitor.waitForNextSuccessResult() + + monitor.verifyProviderIsNotUpdated() + + // The verification check doesn't throw since nothing's changed since the first result was + // retrieved. + } + + /* Tests for waitForNextSuccessfulResult */ + + @Test + fun testWaitForNextSuccessfulResult_pendingDataProvider_throwsException() { + val dataProvider = dataProviders.createInMemoryDataProviderAsync("test") { + AsyncResult.pending() + } + + val failure = + assertThrows(IllegalStateException::class) { + monitorFactory.waitForNextSuccessfulResult(dataProvider) + } + + assertThat(failure).hasMessageThat().contains("Expected next result to be a success") + } + + @Test + fun testWaitForNextSuccessfulResult_failingDataProvider_throwsException() { + val dataProvider = dataProviders.createInMemoryDataProviderAsync("test") { + AsyncResult.failed(Exception("Failure")) + } + + val failure = assertThrows(IllegalStateException::class) { + monitorFactory.waitForNextSuccessfulResult(dataProvider) + } + + assertThat(failure).hasMessageThat().contains("Expected next result to be a success") + } + + @Test + fun testWaitForNextSuccessfulResult_successfulDataProvider_returnsResult() { + val dataProvider = dataProviders.createInMemoryDataProviderAsync("test") { + AsyncResult.success("str value") + } + + val result = monitorFactory.waitForNextSuccessfulResult(dataProvider) + + assertThat(result).isEqualTo("str value") + } + + @Test + fun testWaitForNextSuccessfulResult_failureThenSuccess_consumed_returnsLatest() { + val dataProvider = + createDataProviderWithResultsQueue( + "test", AsyncResult.failed(Exception("Failure")), AsyncResult.success("str value") + ) + monitorFactory.waitForNextFailureResult(dataProvider) + + val result = monitorFactory.waitForNextSuccessfulResult(dataProvider) + + assertThat(result).isEqualTo("str value") + } + + @Test + fun testWaitForNextSuccessfulResult_successThenFailure_consumed_throwsException() { + val dataProvider = + createDataProviderWithResultsQueue( + "test", AsyncResult.success("str value"), AsyncResult.failed(Exception("Failure")) + ) + monitorFactory.waitForNextSuccessfulResult(dataProvider) + + val failure = assertThrows(IllegalStateException::class) { + monitorFactory.waitForNextSuccessfulResult(dataProvider) + } + + assertThat(failure).hasMessageThat().contains("Expected next result to be a success") + } + + @Test + fun testWaitForNextSuccessfulResult_differentValues_consumed_returnsLatest() { + val dataProvider = + createDataProviderWithResultsQueue( + "test", AsyncResult.success("first"), AsyncResult.success("second") + ) + monitorFactory.waitForNextSuccessfulResult(dataProvider) + + val result = monitorFactory.waitForNextSuccessfulResult(dataProvider) + + assertThat(result).isEqualTo("second") + } + + @Test + fun testWaitForNextSuccessfulResult_twiceForChangedProvider_returnsCorrectValues() { + val dataProvider = + createDataProviderWithResultsQueue( + "test", AsyncResult.success("first"), AsyncResult.success("second") + ) + + val firstResult = monitorFactory.waitForNextSuccessfulResult(dataProvider) + val secondResult = monitorFactory.waitForNextSuccessfulResult(dataProvider) + + assertThat(firstResult).isEqualTo("first") + assertThat(secondResult).isEqualTo("second") + } + + /* Tests for waitForNextFailureResult */ + + @Test + fun testWaitForNextFailureResult_pendingDataProvider_throwsException() { + val dataProvider = dataProviders.createInMemoryDataProviderAsync("test") { + AsyncResult.pending() + } + + val failure = assertThrows(IllegalStateException::class) { + monitorFactory.waitForNextFailureResult(dataProvider) + } + + assertThat(failure).hasMessageThat().contains("Expected next result to be a failure") + } + + @Test + fun testWaitForNextFailureResult_failingDataProvider_returnsResult() { + val dataProvider = dataProviders.createInMemoryDataProviderAsync("test") { + AsyncResult.failed(Exception("Failure")) + } + + val result = monitorFactory.waitForNextFailureResult(dataProvider) + + assertThat(result).hasMessageThat().contains("Failure") + } + + @Test + fun testWaitForNextFailureResult_successfulDataProvider_throwsException() { + val dataProvider = dataProviders.createInMemoryDataProviderAsync("test") { + AsyncResult.success("str value") + } + + val failure = assertThrows(IllegalStateException::class) { + monitorFactory.waitForNextFailureResult(dataProvider) + } + + assertThat(failure).hasMessageThat().contains("Expected next result to be a failure") + } + + @Test + fun testWaitForNextFailureResult_successThenFailure_consumed_returnsLatest() { + val dataProvider = + createDataProviderWithResultsQueue( + "test", AsyncResult.success("str value"), AsyncResult.failed(Exception("Failure")) + ) + monitorFactory.waitForNextSuccessfulResult(dataProvider) + + val result = monitorFactory.waitForNextFailureResult(dataProvider) + + assertThat(result).hasMessageThat().contains("Failure") + } + + @Test + fun testWaitForNextFailureResult_failureThenSuccess_consumed_throwsException() { + val dataProvider = + createDataProviderWithResultsQueue( + "test", AsyncResult.failed(Exception("Failure")), AsyncResult.success("str value") + ) + monitorFactory.waitForNextFailureResult(dataProvider) + + val failure = assertThrows(IllegalStateException::class) { + monitorFactory.waitForNextFailureResult(dataProvider) + } + + assertThat(failure).hasMessageThat().contains("Expected next result to be a failure") + } + + @Test + fun testWaitForNextFailureResult_differentValues_consumed_returnsLatest() { + val dataProvider = + createDataProviderWithResultsQueue( + "test", AsyncResult.failed(Exception("First")), AsyncResult.failed(Exception("Second")) + ) + monitorFactory.waitForNextFailureResult(dataProvider) + + val result = monitorFactory.waitForNextFailureResult(dataProvider) + + assertThat(result).hasMessageThat().contains("Second") + } + + @Test + fun testWaitForNextFailureResult_twiceForChangedProvider_returnsCorrectValues() { + val dataProvider = + createDataProviderWithResultsQueue( + "test", AsyncResult.failed(Exception("First")), AsyncResult.failed(Exception("Second")) + ) + + val firstResult = monitorFactory.waitForNextFailureResult(dataProvider) + val secondResult = monitorFactory.waitForNextFailureResult(dataProvider) + + assertThat(firstResult).hasMessageThat().contains("First") + assertThat(secondResult).hasMessageThat().contains("Second") + } + + private fun createDataProviderWithResultsQueue( + id: Any, + vararg results: AsyncResult + ): DataProvider { + val resultsQueue = createResultQueue(*results) + return dataProviders.createInMemoryDataProviderAsync(id) { + resultsQueue.removeFirst() + } + } + + private fun createResultQueue(vararg results: AsyncResult): ArrayDeque> { + return ArrayDeque(results.toList()) + } + + private fun setUpTestApplicationComponent() { + ApplicationProvider.getApplicationContext().inject(this) + } + + // TODO(#89): Move this to a common test application component. + @Module + class TestModule { + @Provides + @Singleton + fun provideContext(application: Application): Context { + return application + } + } + + // TODO(#89): Move this to a common test application component. + @Singleton + @Component( + modules = [ + TestModule::class, LogStorageModule::class, NetworkConnectionUtilDebugModule::class, + TestLogReportingModule::class, LoggerModule::class, TestDispatcherModule::class, + LocaleProdModule::class, FakeOppiaClockModule::class, RobolectricModule::class + ] + ) + interface TestApplicationComponent : DataProvidersInjector { + @Component.Builder + interface Builder { + @BindsInstance + fun setApplication(application: Application): Builder + + fun build(): TestApplicationComponent + } + + fun inject(dataProviderTestMonitorTest: DataProviderTestMonitorTest) + } + + class TestApplication : Application(), DataProvidersInjectorProvider { + private val component: TestApplicationComponent by lazy { + DaggerDataProviderTestMonitorTest_TestApplicationComponent.builder() + .setApplication(this) + .build() + } + + fun inject(dataProviderTestMonitorTest: DataProviderTestMonitorTest) { + component.inject(dataProviderTestMonitorTest) + } + + override fun getDataProvidersInjector(): DataProvidersInjector = component + } +} diff --git a/testing/src/test/java/org/oppia/android/testing/lightweightcheckpointing/ExplorationCheckpointTestHelperTest.kt b/testing/src/test/java/org/oppia/android/testing/lightweightcheckpointing/ExplorationCheckpointTestHelperTest.kt index f7c2628ab61..23f5b99dd68 100644 --- a/testing/src/test/java/org/oppia/android/testing/lightweightcheckpointing/ExplorationCheckpointTestHelperTest.kt +++ b/testing/src/test/java/org/oppia/android/testing/lightweightcheckpointing/ExplorationCheckpointTestHelperTest.kt @@ -37,6 +37,7 @@ import org.oppia.android.testing.threading.TestCoroutineDispatchers import org.oppia.android.testing.threading.TestDispatcherModule import org.oppia.android.testing.time.FakeOppiaClock import org.oppia.android.testing.time.FakeOppiaClockModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.CacheAssetsLocally import org.oppia.android.util.caching.LoadLessonProtosFromAssets import org.oppia.android.util.caching.TopicListToCache @@ -290,7 +291,7 @@ class ExplorationCheckpointTestHelperTest { modules = [ TestModule::class, TestLogReportingModule::class, LogStorageModule::class, TestDispatcherModule::class, RobolectricModule::class, FakeOppiaClockModule::class, - ExplorationStorageModule::class, NetworkConnectionUtilDebugModule::class + ExplorationStorageModule::class, NetworkConnectionUtilDebugModule::class, AssetModule::class ] ) interface TestApplicationComponent : DataProvidersInjector { diff --git a/testing/src/test/java/org/oppia/android/testing/robolectric/BUILD.bazel b/testing/src/test/java/org/oppia/android/testing/robolectric/BUILD.bazel new file mode 100644 index 00000000000..8e92cbfb7b0 --- /dev/null +++ b/testing/src/test/java/org/oppia/android/testing/robolectric/BUILD.bazel @@ -0,0 +1,25 @@ +""" +Tests for Robolectric-specific utilities and configurations. +""" + +load("@dagger//:workspace_defs.bzl", "dagger_rules") +load("//:oppia_android_test.bzl", "oppia_android_test") + +oppia_android_test( + name = "ShadowBidiFormatterTest", + srcs = ["ShadowBidiFormatterTest.kt"], + custom_package = "org.oppia.android.testing.robolectric", + test_class = "org.oppia.android.testing.robolectric.ShadowBidiFormatterTest", + test_manifest = "//testing:test_manifest", + deps = [ + ":dagger", + "//testing/src/main/java/org/oppia/android/testing/robolectric:shadow_bidi_formatter", + "//third_party:androidx_test_ext_junit", + "//third_party:com_google_truth_truth", + "//third_party:junit_junit", + "//third_party:org_robolectric_robolectric", + "//third_party:robolectric_android-all", + ], +) + +dagger_rules() diff --git a/testing/src/test/java/org/oppia/android/testing/robolectric/ShadowBidiFormatterTest.kt b/testing/src/test/java/org/oppia/android/testing/robolectric/ShadowBidiFormatterTest.kt new file mode 100644 index 00000000000..11838bd2300 --- /dev/null +++ b/testing/src/test/java/org/oppia/android/testing/robolectric/ShadowBidiFormatterTest.kt @@ -0,0 +1,154 @@ +package org.oppia.android.testing.robolectric + +import android.app.Application +import android.content.Context +import android.text.BidiFormatter +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import dagger.BindsInstance +import dagger.Component +import dagger.Module +import dagger.Provides +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import java.util.Locale +import javax.inject.Singleton + +/** Tests for [ShadowBidiFormatter]. */ +// FunctionName: test names are conventionally named with underscores. +@Suppress("FunctionName") +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +@Config(manifest = Config.NONE, shadows = [ShadowBidiFormatter::class]) +class ShadowBidiFormatterTest { + @Before + fun setUp() { + setUpTestApplicationComponent() + } + + @After + fun tearDown() { + // Make sure this is reset between tests. + ShadowBidiFormatter.reset() + } + + @Test + fun testCustomShadow_initialState_noFormattersCreator_hasZeroTrackedFormatters() { + assertThat(ShadowBidiFormatter.lookUpFormatters()).isEmpty() + assertThat(ShadowBidiFormatter.lookUpFormatter(Locale.US)).isNull() + } + + @Test + fun testCustomShadow_getFormatterInstance_addsTrackedFormatter() { + BidiFormatter.getInstance(Locale.US) + + assertThat(ShadowBidiFormatter.lookUpFormatters()).hasSize(1) + assertThat(ShadowBidiFormatter.lookUpFormatters()).containsKey(Locale.US) + assertThat(ShadowBidiFormatter.lookUpFormatter(Locale.US)).isNotNull() + } + + @Test + fun testCustomShadow_createFormatter_hasNoTrackedStrings() { + BidiFormatter.getInstance(Locale.US) + + val shadow = ShadowBidiFormatter.lookUpFormatter(Locale.US) + assertThat(shadow?.getLastWrappedSequence()).isNull() + assertThat(shadow?.getAllWrappedSequences()).isEmpty() + } + + @Test + fun testCustomShadow_createFormatter_wrapNonCharSequence_doesNotContainString() { + val formatter = BidiFormatter.getInstance(Locale.US) + + formatter.unicodeWrap("test string") + + // This slightly weaker check is needed since the internal implementation of BidiFormatter seems + // like it might call the CharSequence version of unicodeWrap(), so no assumptions can be made + // about the exact calls. + val shadow = ShadowBidiFormatter.lookUpFormatter(Locale.US) + assertThat(shadow?.getAllWrappedSequences()).doesNotContain("test string") + } + + @Test + fun testCustomShadow_createFormatter_wrapString_tracksWrappedString() { + val formatter = BidiFormatter.getInstance(Locale.US) + + formatter.unicodeWrap("test string" as CharSequence) + + val shadow = ShadowBidiFormatter.lookUpFormatter(Locale.US) + assertThat(shadow?.getLastWrappedSequence()).isEqualTo("test string") + } + + @Test + fun testCustomShadow_createFormatter_wrapStrings_tracksLatestString() { + val formatter = BidiFormatter.getInstance(Locale.US) + + formatter.unicodeWrap("test string one" as CharSequence) + formatter.unicodeWrap("test string two" as CharSequence) + + val shadow = ShadowBidiFormatter.lookUpFormatter(Locale.US) + assertThat(shadow?.getLastWrappedSequence()).isEqualTo("test string two") + } + + @Test + fun testCustomShadow_createFormatter_wrapStrings_tracksAllStringsInOrder() { + val formatter = BidiFormatter.getInstance(Locale.US) + + formatter.unicodeWrap("test string one" as CharSequence) + formatter.unicodeWrap("test string two" as CharSequence) + + val shadow = ShadowBidiFormatter.lookUpFormatter(Locale.US) + assertThat(shadow?.getAllWrappedSequences()) + .containsAllOf("test string one", "test string two").inOrder() + } + + @Test + fun testCustomShadow_reset_clearsFormatters() { + BidiFormatter.getInstance(Locale.US) + + ShadowBidiFormatter.reset() + + assertThat(ShadowBidiFormatter.lookUpFormatters()).isEmpty() + } + + private fun setUpTestApplicationComponent() { + DaggerShadowBidiFormatterTest_TestApplicationComponent.builder() + .setApplication(ApplicationProvider.getApplicationContext()) + .build() + .inject(this) + } + + // TODO(#89): Move this to a common test application component. + @Module + class TestModule { + @Provides + @Singleton + fun provideContext(application: Application): Context { + return application + } + } + + // TODO(#89): Move this to a common test application component. + @Singleton + @Component( + modules = [ + TestModule::class + ] + ) + interface TestApplicationComponent { + @Component.Builder + interface Builder { + @BindsInstance + fun setApplication(application: Application): Builder + + fun build(): TestApplicationComponent + } + + fun inject(shadowBidiFormatterTest: ShadowBidiFormatterTest) + } +} diff --git a/testing/src/test/java/org/oppia/android/testing/story/StoryProgressTestHelperTest.kt b/testing/src/test/java/org/oppia/android/testing/story/StoryProgressTestHelperTest.kt index 3eed6951c42..cc027eeba1a 100644 --- a/testing/src/test/java/org/oppia/android/testing/story/StoryProgressTestHelperTest.kt +++ b/testing/src/test/java/org/oppia/android/testing/story/StoryProgressTestHelperTest.kt @@ -59,6 +59,7 @@ import org.oppia.android.testing.threading.TestCoroutineDispatchers import org.oppia.android.testing.threading.TestDispatcherModule import org.oppia.android.testing.time.FakeOppiaClock import org.oppia.android.testing.time.FakeOppiaClockModule +import org.oppia.android.util.caching.AssetModule import org.oppia.android.util.caching.LoadLessonProtosFromAssets import org.oppia.android.util.data.AsyncResult import org.oppia.android.util.data.DataProvider @@ -1769,7 +1770,8 @@ class StoryProgressTestHelperTest { modules = [ TestModule::class, TestLogReportingModule::class, LogStorageModule::class, TestDispatcherModule::class, RobolectricModule::class, FakeOppiaClockModule::class, - ImageParsingModule::class, LoggerModule::class, NetworkConnectionUtilDebugModule::class + ImageParsingModule::class, LoggerModule::class, NetworkConnectionUtilDebugModule::class, + AssetModule::class ] ) interface TestApplicationComponent : DataProvidersInjector { diff --git a/testing/src/test/java/org/oppia/android/testing/time/FakeOppiaClockTest.kt b/testing/src/test/java/org/oppia/android/testing/time/FakeOppiaClockTest.kt index f9afaeb4d31..5b534916126 100644 --- a/testing/src/test/java/org/oppia/android/testing/time/FakeOppiaClockTest.kt +++ b/testing/src/test/java/org/oppia/android/testing/time/FakeOppiaClockTest.kt @@ -32,7 +32,9 @@ import org.oppia.android.util.logging.LoggerModule import org.oppia.android.util.parser.image.ImageParsingModule import org.robolectric.annotation.Config import org.robolectric.annotation.LooperMode +import java.time.ZoneId import java.util.Calendar +import java.util.TimeZone import javax.inject.Inject import javax.inject.Singleton @@ -58,6 +60,11 @@ class FakeOppiaClockTest { setUpTestApplicationComponent() } + @Test + fun testInitialState_forcesTimeZoneToUtc() { + assertThat(TimeZone.getDefault().toZoneId()).isEqualTo(ZoneId.of("UTC")) + } + @Test fun testGetFakeTimeMode_initialState_isWallClockTime() { val timeMode = fakeOppiaClock.getFakeTimeMode() diff --git a/third_party/versions.bzl b/third_party/versions.bzl index d27ab652108..0981f7177bd 100644 --- a/third_party/versions.bzl +++ b/third_party/versions.bzl @@ -132,7 +132,7 @@ HTTP_DEPENDENCY_VERSIONS = { "version": "v1.5.0-alpha-2", }, "rules_proto": { - "sha": "602e7161d9195e50246177e7c55b2f39950a9cf7366f74ed5f22fd45750cd208", + "sha": "e0cab008a9cdc2400a1d6572167bf9c5afc72e19ee2b862d18581051efab42c9", "version": "c0b62f2f46c85c16cb3b5e9e921f0d00e3101934", }, } diff --git a/utility/BUILD.bazel b/utility/BUILD.bazel index 68333f33a93..6b8f9f87786 100644 --- a/utility/BUILD.bazel +++ b/utility/BUILD.bazel @@ -33,6 +33,14 @@ android_library( resource_files = glob(["src/main/res/**/*.xml"]), ) +# Visibility for migrated utility tests. +package_group( + name = "utility_testing_visibility", + packages = [ + "//utility/src/test/...", + ], +) + # Library for general-purpose utilities. kt_android_library( name = "utility", @@ -57,7 +65,8 @@ kt_android_library( "//third_party:com_google_guava_guava", "//third_party:glide_compiler", "//third_party:org_jetbrains_kotlinx_kotlinx-coroutines-core", - "//utility/src/main/java/org/oppia/android/util/caching:assets", + "//utility/src/main/java/org/oppia/android/util/caching:annotations", + "//utility/src/main/java/org/oppia/android/util/caching:asset_repository", "//utility/src/main/java/org/oppia/android/util/gcsresource:annotations", "//utility/src/main/java/org/oppia/android/util/gcsresource:prod_module", "//utility/src/main/java/org/oppia/android/util/logging:console_logger", @@ -95,6 +104,7 @@ TEST_DEPS = [ "//third_party:org_jetbrains_kotlin_kotlin-test-junit", "//third_party:org_mockito_mockito-core", "//third_party:robolectric_android-all", + "//utility/src/main/java/org/oppia/android/util/caching:asset_prod_module", "//utility/src/main/java/org/oppia/android/util/caching/testing:caching_test_module", "//utility/src/main/java/org/oppia/android/util/extensions:bundle_extensions", "//utility/src/main/java/org/oppia/android/util/logging:event_bundle_creator", diff --git a/utility/build.gradle b/utility/build.gradle index 57a62088322..98fd0e5a4b3 100644 --- a/utility/build.gradle +++ b/utility/build.gradle @@ -70,6 +70,7 @@ dependencies { 'com.google.firebase:firebase-analytics-ktx:17.5.0', 'com.google.firebase:firebase-core:17.5.0', 'com.google.firebase:firebase-crashlytics:17.0.0', + 'com.google.protobuf:protobuf-javalite:3.17.3', "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version", ) compileOnly( @@ -81,6 +82,7 @@ dependencies { 'androidx.test.ext:junit:1.1.1', 'com.google.dagger:dagger:2.24', 'com.google.truth:truth:0.43', + 'com.google.truth.extensions:truth-liteproto-extension:0.43', 'junit:junit:4.12', "org.jetbrains.kotlin:kotlin-test-junit:$kotlin_version", 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.2.2', diff --git a/utility/src/main/java/org/oppia/android/util/caching/AssetModule.kt b/utility/src/main/java/org/oppia/android/util/caching/AssetModule.kt new file mode 100644 index 00000000000..5ad45b26ffb --- /dev/null +++ b/utility/src/main/java/org/oppia/android/util/caching/AssetModule.kt @@ -0,0 +1,11 @@ +package org.oppia.android.util.caching + +import dagger.Module +import dagger.Provides + +/** Provides dependencies corresponding to loading assets. */ +@Module +class AssetModule { + @Provides + fun provideAssetRepository(impl: AssetRepositoryImpl): AssetRepository = impl +} diff --git a/utility/src/main/java/org/oppia/android/util/caching/AssetRepository.kt b/utility/src/main/java/org/oppia/android/util/caching/AssetRepository.kt index 0e7514e838c..434cc7ee1f0 100644 --- a/utility/src/main/java/org/oppia/android/util/caching/AssetRepository.kt +++ b/utility/src/main/java/org/oppia/android/util/caching/AssetRepository.kt @@ -1,190 +1,56 @@ package org.oppia.android.util.caching -import android.content.Context import com.google.protobuf.MessageLite -import org.oppia.android.util.logging.ConsoleLogger -import java.io.File -import java.io.InputStream -import java.io.OutputStream -import java.net.URL -import java.util.concurrent.locks.ReentrantLock -import javax.inject.Inject -import javax.inject.Singleton -import kotlin.concurrent.withLock -// TODO(#169): Leverage this repository or a version of it for caching all topic contents in a -// proto. It may also be worth keeping a version of this repository for caching audio files within -// certain size limits for buffering during an exploration. /** * A generic repository for accessing local APK asset files, and downloading remote binary files. * This repository aims to centralize caching management of external asset files to simplify * downstream code, and allow assets to be retrieved quickly and synchronously. + * + * Implementations of this class can be injected at the application scope and below. */ -@Singleton -class AssetRepository @Inject constructor( - private val context: Context, - private val logger: ConsoleLogger -) { - private val repositoryLock = ReentrantLock() - - /** Map of asset names to file contents for text file assets. */ - private val textFileAssets = mutableMapOf() - - /** Map of asset names to file contents for proto file assets. */ - private val protoFileAssets = mutableMapOf() - +interface AssetRepository { /** Returns the whole text contents of the file corresponding to the specified asset name. */ - fun loadTextFileFromLocalAssets(assetName: String): String { - repositoryLock.withLock { - primeTextFileFromLocalAssets(assetName) - return textFileAssets.getValue(assetName) - } - } + fun loadTextFileFromLocalAssets(assetName: String): String /** * Ensures the contents corresponding to the specified asset are available for quick retrieval. */ - fun primeTextFileFromLocalAssets(assetName: String) { - repositoryLock.withLock { - if (assetName !in textFileAssets) { - logger.d("AssetRepo", "Caching local text asset: $assetName") - textFileAssets[assetName] = context.assets.open(assetName).bufferedReader().use { - it.readText() - } - } - } - } + fun primeTextFileFromLocalAssets(assetName: String) /** * Returns a new proto of type [T] that is retrieved from the local assets for the given asset * name. The [baseMessage] is used to load the proto; its value will never actually be used (so * callers are recommended to use [T]'s default instance for this purpose). */ - fun loadProtoFromLocalAssets(assetName: String, baseMessage: T): T { - @Suppress("UNCHECKED_CAST") // Safe type-cast per newBuilderForType's contract. - return baseMessage.newBuilderForType() - .mergeFrom(loadProtoBlobFromLocalAssets(assetName)) - .build() as T - } + fun loadProtoFromLocalAssets(assetName: String, baseMessage: T): T - /** Returns the size of the specified proto asset. */ - fun getLocalAssetProtoSize(assetName: String): Int { - return loadProtoBlobFromLocalAssets(assetName).size - } - - private fun loadProtoBlobFromLocalAssets(assetName: String): ByteArray { - primeProtoBlobFromLocalAssets(assetName) - return protoFileAssets.getValue(assetName) - } + /** + * A version of [loadProtoFromLocalAssets] which will return the specified default message if the + * asset doesn't exist locally (rather than throwing an exception). + */ + fun tryLoadProtoFromLocalAssets(assetName: String, defaultMessage: T): T - private fun primeProtoBlobFromLocalAssets(assetName: String) { - repositoryLock.withLock { - if (assetName !in protoFileAssets) { - protoFileAssets[assetName] = context.assets.open("$assetName.pb").use { it.readBytes() } - } - } - } + /** Returns the size of the specified proto asset, or -1 if the asset doesn't exist. */ + fun getLocalAssetProtoSize(assetName: String): Int /** * Returns a function to retrieve the stream of the binary asset corresponding to the specified * URL, to be called on a background thread. */ - fun loadRemoteBinaryAsset(url: String): () -> ByteArray { - return { - logger.d("AssetRepo", "Loading binary asset: $url") - val stream = openLocalCacheFileForRead(url) ?: openCachingStreamToRemoteFile(url) - stream.use { it.readBytes() } - } - } + fun loadRemoteBinaryAsset(url: String): () -> ByteArray /** * Returns a function to retrieve the image data corresponding to the specified URL (where the * image represented by that URL is assumed to be included in the app's assets directory). */ - fun loadImageAssetFromLocalAssets(url: String): () -> ByteArray { - return { - val filename = url.substring(url.lastIndexOf('/') + 1) - context.assets.open("images/$filename").use { it.readBytes() } - } - } + fun loadImageAssetFromLocalAssets(url: String): () -> ByteArray /** Ensures the contents corresponding to the specified URL are available for quick retrieval. */ - fun primeRemoteBinaryAsset(url: String) { - if (!isRemoteBinaryAssetDownloaded(url)) { - // Otherwise, download it remotely and cache it locally. - logger.d("AssetRepo", "Downloading binary asset: $url") - val contents = openRemoteStream(url).use { it.readBytes() } - saveLocalCacheFile(url, contents) - } - } + fun primeRemoteBinaryAsset(url: String) /** * Returns whether a binary asset corresponding to the specified URL has already been downloaded. */ - fun isRemoteBinaryAssetDownloaded(url: String): Boolean { - return getLocalCacheFile(url).exists() - } - - private fun openRemoteStream(url: String): InputStream { - return URL(url).openStream() - } - - /** Returns an [InputStream] that also saves its results to a local file. */ - private fun openCachingStreamToRemoteFile(url: String): InputStream { - val urlInStream = openRemoteStream(url) - val fileOutStream = openLocalCacheFileForWrite(url) - return object : InputStream() { - override fun available(): Int { - return urlInStream.available() - } - - override fun read(): Int { - val byte = urlInStream.read() - if (byte != -1) { - fileOutStream.write(byte) - } - return byte - } - - override fun read(b: ByteArray?): Int { - return read(b, 0, b!!.size) - } - - override fun read(b: ByteArray?, off: Int, len: Int): Int { - val count = urlInStream.read(b, off, len) - if (count > -1) { - fileOutStream.write(b, off, count) - } - return count - } - - override fun close() { - super.close() - fileOutStream.flush() - fileOutStream.close() - urlInStream.close() - } - } - } - - private fun openLocalCacheFileForRead(identifier: String): InputStream? { - val cacheFile = getLocalCacheFile(identifier) - return if (cacheFile.exists()) cacheFile.inputStream() else null - } - - private fun saveLocalCacheFile(identifier: String, contents: ByteArray) { - getLocalCacheFile(identifier).writeBytes(contents) - } - - private fun openLocalCacheFileForWrite(identifier: String): OutputStream { - return getLocalCacheFile(identifier).outputStream() - } - - private fun getLocalCacheFile(identifier: String): File { - return File(context.cacheDir, convertIdentifierToCacheFileName(identifier)) - } - - private fun convertIdentifierToCacheFileName(identifier: String): String { - return "${identifier.hashCode()}.cache" - } + fun isRemoteBinaryAssetDownloaded(url: String): Boolean } diff --git a/utility/src/main/java/org/oppia/android/util/caching/AssetRepositoryImpl.kt b/utility/src/main/java/org/oppia/android/util/caching/AssetRepositoryImpl.kt new file mode 100644 index 00000000000..ad36216bab9 --- /dev/null +++ b/utility/src/main/java/org/oppia/android/util/caching/AssetRepositoryImpl.kt @@ -0,0 +1,191 @@ +package org.oppia.android.util.caching + +import android.content.Context +import com.google.protobuf.MessageLite +import org.oppia.android.util.logging.ConsoleLogger +import java.io.File +import java.io.FileNotFoundException +import java.io.InputStream +import java.io.OutputStream +import java.net.URL +import java.util.concurrent.locks.ReentrantLock +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.concurrent.withLock + +// TODO(#169): Leverage this repository or a version of it for caching all topic contents in a +// proto. It may also be worth keeping a version of this repository for caching audio files within +// certain size limits for buffering during an exploration. +/** Implementation of [AssetRepository]. */ +@Singleton +class AssetRepositoryImpl @Inject constructor( + private val context: Context, + private val logger: ConsoleLogger +) : AssetRepository { + private val repositoryLock = ReentrantLock() + + /** Map of asset names to file contents for text file assets. */ + private val textFileAssets = mutableMapOf() + + /** Map of asset names to file contents for proto file assets. */ + private val protoFileAssets = mutableMapOf() + + override fun loadTextFileFromLocalAssets(assetName: String): String { + repositoryLock.withLock { + primeTextFileFromLocalAssets(assetName) + return textFileAssets.getValue(assetName) + } + } + + override fun primeTextFileFromLocalAssets(assetName: String) { + repositoryLock.withLock { + if (assetName !in textFileAssets) { + logger.d("AssetRepo", "Caching local text asset: $assetName") + try { + textFileAssets[assetName] = context.assets.open(assetName).bufferedReader().use { + it.readText() + } + } catch (e: FileNotFoundException) { + // Catch & rethrow for consistency with the proto asset codepath. + error("Asset doesn't exist: $assetName") + } + } + } + } + + override fun loadProtoFromLocalAssets(assetName: String, baseMessage: T): T { + return maybeProtoFromLocalAssetsOrFail(assetName, baseMessage) + ?: error("Asset doesn't exist: $assetName") + } + + override fun tryLoadProtoFromLocalAssets( + assetName: String, + defaultMessage: T + ): T { + return maybeProtoFromLocalAssetsOrFail(assetName, defaultMessage) ?: defaultMessage + } + + override fun getLocalAssetProtoSize(assetName: String): Int { + return loadProtoBlobFromLocalAssets(assetName)?.size ?: -1 + } + + private fun maybeProtoFromLocalAssetsOrFail( + assetName: String, + baseMessage: T + ): T? { + return loadProtoBlobFromLocalAssets(assetName)?.let { serializedProto -> + @Suppress("UNCHECKED_CAST") // Safe type-cast per newBuilderForType's contract. + return baseMessage.newBuilderForType() + .mergeFrom(serializedProto) + .build() as T + } + } + + private fun loadProtoBlobFromLocalAssets(assetName: String): ByteArray? { + primeProtoBlobFromLocalAssets(assetName) + return protoFileAssets.getValue(assetName) + } + + private fun primeProtoBlobFromLocalAssets(assetName: String) { + repositoryLock.withLock { + if (assetName !in protoFileAssets) { + val files = context.assets.list(/* path= */ "")?.toList() ?: listOf() + val assetNameFile = "$assetName.pb" + protoFileAssets[assetName] = if (assetNameFile in files) { + context.assets.open(assetNameFile).use { it.readBytes() } + } else null + } + } + } + + override fun loadRemoteBinaryAsset(url: String): () -> ByteArray { + return { + logger.d("AssetRepo", "Loading binary asset: $url") + val stream = openLocalCacheFileForRead(url) ?: openCachingStreamToRemoteFile(url) + stream.use { it.readBytes() } + } + } + + override fun loadImageAssetFromLocalAssets(url: String): () -> ByteArray { + return { + val filename = url.substringAfterLast('/') + context.assets.open("images/$filename").use { it.readBytes() } + } + } + + override fun primeRemoteBinaryAsset(url: String) { + if (!isRemoteBinaryAssetDownloaded(url)) { + // Otherwise, download it remotely and cache it locally. + logger.d("AssetRepo", "Downloading binary asset: $url") + val contents = openRemoteStream(url).use { it.readBytes() } + saveLocalCacheFile(url, contents) + } + } + + override fun isRemoteBinaryAssetDownloaded(url: String): Boolean { + return getLocalCacheFile(url).exists() + } + + private fun openRemoteStream(url: String): InputStream { + return URL(url).openStream() + } + + /** Returns an [InputStream] that also saves its results to a local file. */ + private fun openCachingStreamToRemoteFile(url: String): InputStream { + val urlInStream = openRemoteStream(url) + val fileOutStream = openLocalCacheFileForWrite(url) + return object : InputStream() { + override fun available(): Int { + return urlInStream.available() + } + + override fun read(): Int { + val byte = urlInStream.read() + if (byte != -1) { + fileOutStream.write(byte) + } + return byte + } + + override fun read(b: ByteArray?): Int { + return read(b, 0, b!!.size) + } + + override fun read(b: ByteArray?, off: Int, len: Int): Int { + val count = urlInStream.read(b, off, len) + if (count > -1) { + fileOutStream.write(b, off, count) + } + return count + } + + override fun close() { + super.close() + fileOutStream.flush() + fileOutStream.close() + urlInStream.close() + } + } + } + + private fun openLocalCacheFileForRead(identifier: String): InputStream? { + val cacheFile = getLocalCacheFile(identifier) + return if (cacheFile.exists()) cacheFile.inputStream() else null + } + + private fun saveLocalCacheFile(identifier: String, contents: ByteArray) { + getLocalCacheFile(identifier).writeBytes(contents) + } + + private fun openLocalCacheFileForWrite(identifier: String): OutputStream { + return getLocalCacheFile(identifier).outputStream() + } + + private fun getLocalCacheFile(identifier: String): File { + return File(context.cacheDir, convertIdentifierToCacheFileName(identifier)) + } + + private fun convertIdentifierToCacheFileName(identifier: String): String { + return "${identifier.hashCode()}.cache" + } +} diff --git a/utility/src/main/java/org/oppia/android/util/caching/BUILD.bazel b/utility/src/main/java/org/oppia/android/util/caching/BUILD.bazel index f665291fa39..46da552daa2 100644 --- a/utility/src/main/java/org/oppia/android/util/caching/BUILD.bazel +++ b/utility/src/main/java/org/oppia/android/util/caching/BUILD.bazel @@ -6,34 +6,68 @@ load("@dagger//:workspace_defs.bzl", "dagger_rules") load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_android_library") kt_android_library( - name = "assets", + name = "asset_repository", srcs = [ "AssetRepository.kt", + ], + visibility = ["//:oppia_api_visibility"], + deps = [ + "//third_party:com_google_protobuf_protobuf-javalite", + ], +) + +kt_android_library( + name = "annotations", + srcs = [ "CacheAssetsLocally.kt", "LoadImagesFromAssets.kt", "LoadLessonProtosFromAssets.kt", ], visibility = ["//:oppia_api_visibility"], deps = [ - "//third_party:com_google_protobuf_protobuf-javalite", "//third_party:javax_inject_javax_inject", + ], +) + +kt_android_library( + name = "impl", + srcs = [ + "AssetRepositoryImpl.kt", + ], + deps = [ + ":asset_repository", + ":dagger", + "//third_party:com_google_protobuf_protobuf-javalite", "//utility/src/main/java/org/oppia/android/util/logging:console_logger", ], ) kt_android_library( - name = "caching_module", + name = "caching_prod_module", srcs = [ "CachingModule.kt", ], visibility = ["//:oppia_api_visibility"], deps = [ - ":assets", + ":annotations", + ":asset_repository", ":dagger", ":topic_list_to_cache", ], ) +kt_android_library( + name = "asset_prod_module", + srcs = [ + "AssetModule.kt", + ], + visibility = ["//:oppia_api_visibility"], + deps = [ + ":dagger", + ":impl", + ], +) + kt_android_library( name = "topic_list_to_cache", srcs = [ diff --git a/utility/src/main/java/org/oppia/android/util/caching/testing/AssetTestNoOpModule.kt b/utility/src/main/java/org/oppia/android/util/caching/testing/AssetTestNoOpModule.kt new file mode 100644 index 00000000000..e46ccd83a42 --- /dev/null +++ b/utility/src/main/java/org/oppia/android/util/caching/testing/AssetTestNoOpModule.kt @@ -0,0 +1,12 @@ +package org.oppia.android.util.caching.testing + +import dagger.Module +import dagger.Provides +import org.oppia.android.util.caching.AssetRepository + +/** Test-only module for no-op loading assets. */ +@Module +class AssetTestNoOpModule { + @Provides + fun provideAssetRepository(impl: TestNoOpAssetRepository): AssetRepository = impl +} diff --git a/utility/src/main/java/org/oppia/android/util/caching/testing/BUILD.bazel b/utility/src/main/java/org/oppia/android/util/caching/testing/BUILD.bazel index 107ab22fbe4..9663a7c5bef 100644 --- a/utility/src/main/java/org/oppia/android/util/caching/testing/BUILD.bazel +++ b/utility/src/main/java/org/oppia/android/util/caching/testing/BUILD.bazel @@ -1,5 +1,5 @@ """ -Utilities corresponding to caching tests. +Testing utilities corresponding asset caching utilities. """ load("@dagger//:workspace_defs.bzl", "dagger_rules") @@ -7,15 +7,42 @@ load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_android_library") kt_android_library( name = "caching_test_module", + testonly = True, srcs = [ "CachingTestModule.kt", ], visibility = ["//:oppia_testing_visibility"], deps = [ ":dagger", - "//utility/src/main/java/org/oppia/android/util/caching:assets", + "//utility/src/main/java/org/oppia/android/util/caching:annotations", "//utility/src/main/java/org/oppia/android/util/caching:topic_list_to_cache", ], ) +kt_android_library( + name = "asset_test_no_op_module", + testonly = True, + srcs = [ + "AssetTestNoOpModule.kt", + ], + visibility = ["//:oppia_testing_visibility"], + deps = [ + ":dagger", + ":test_asset_repository", + ], +) + +kt_android_library( + name = "test_asset_repository", + testonly = True, + srcs = [ + "TestNoOpAssetRepository.kt", + ], + visibility = ["//:oppia_testing_visibility"], + deps = [ + ":dagger", + "//utility/src/main/java/org/oppia/android/util/caching:asset_repository", + ], +) + dagger_rules() diff --git a/utility/src/main/java/org/oppia/android/util/caching/testing/TestNoOpAssetRepository.kt b/utility/src/main/java/org/oppia/android/util/caching/testing/TestNoOpAssetRepository.kt new file mode 100644 index 00000000000..9cfdc1761bf --- /dev/null +++ b/utility/src/main/java/org/oppia/android/util/caching/testing/TestNoOpAssetRepository.kt @@ -0,0 +1,48 @@ +package org.oppia.android.util.caching.testing + +import com.google.protobuf.MessageLite +import org.oppia.android.util.caching.AssetRepository +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Test-only implementation of [AssetRepository] that provides no support for loading any assets + * (i.e. it sets up the environment as though there are no local or remote assets available). + * + * This class is safe to interact with across multiple simultaneous threads. + */ +@Singleton +class TestNoOpAssetRepository @Inject constructor() : AssetRepository { + override fun loadTextFileFromLocalAssets(assetName: String): String { + error("Local text asset doesn't exist: $assetName") + } + + override fun primeTextFileFromLocalAssets(assetName: String) { + // Do nothing. + } + + override fun loadProtoFromLocalAssets(assetName: String, baseMessage: T): T { + error("Local proto asset doesn't exist: $assetName") + } + + override fun tryLoadProtoFromLocalAssets( + assetName: String, + defaultMessage: T + ): T = defaultMessage // Just return default since the asset doesn't exist. + + override fun getLocalAssetProtoSize(assetName: String): Int = -1 // Asset doesn't exist. + + override fun loadRemoteBinaryAsset(url: String): () -> ByteArray { + error("Remote asset doesn't exist: $url") + } + + override fun loadImageAssetFromLocalAssets(url: String): () -> ByteArray { + error("Local image asset doesn't exist: $url") + } + + override fun primeRemoteBinaryAsset(url: String) { + // Do nothing. + } + + override fun isRemoteBinaryAssetDownloaded(url: String): Boolean = false // No local assets exist. +} 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 new file mode 100644 index 00000000000..b7e177b25c5 --- /dev/null +++ b/utility/src/main/java/org/oppia/android/util/locale/AndroidLocaleProfile.kt @@ -0,0 +1,112 @@ +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 + +/** + * 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 language code + * @property regionCode the IETF BCP 47 or ISO 3166 alpha-2 region code + */ +data class AndroidLocaleProfile(val languageCode: String, val regionCode: 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 + } + } + + companion object { + /** A wildcard that will match against any region when provided. */ + const val REGION_WILDCARD = "*" + + /** Returns a new [AndroidLocaleProfile] that represents the specified Android [Locale]. */ + fun createFrom(androidLocale: Locale): AndroidLocaleProfile = + AndroidLocaleProfile(androidLocale.language, androidLocale.country) + + /** + * Returns a new [AndroidLocaleProfile] using the IETF BCP 47 tag in the provided [LanguageId]. + * + * This will return null in a number of scenarios: + * - If the provided [LanguageId] doesn't have an IETF BCP 47 ID + * - If the IETF BCP 47 tag is malformed + * - If the provided [RegionSupportDefinition] doesn't have an IETF BCP 47 region ID + * + * Further, this method will only use the provided [regionDefinition] if the IETF BCP 47 + * language tag doesn't include a region component. If the [regionDefinition] is null then the + * returned [AndroidLocaleProfile] will have a wildcard match against any region (meaning only + * the language code needs to match). + */ + fun createFromIetfDefinitions( + languageId: LanguageId, + regionDefinition: RegionSupportDefinition? + ): AndroidLocaleProfile? { + if (!languageId.hasIetfBcp47Id()) return null + return when { + "-" in languageId.ietfBcp47Id.ietfLanguageTag -> { + val (languageCode, regionCode) = + languageId.ietfBcp47Id.ietfLanguageTag.divide("-") ?: return null + maybeConstructProfile(languageCode, regionCode) + } + regionDefinition != null -> { + if (!regionDefinition.hasRegionId()) return null + maybeConstructProfile( + languageId.ietfBcp47Id.ietfLanguageTag, regionDefinition.regionId.ietfRegionTag + ) + } + else -> { + maybeConstructProfile( + languageId.ietfBcp47Id.ietfLanguageTag, regionCode = "", emptyRegionAsWildcard = true + ) + } + } + } + + /** + * Returns a new [AndroidLocaleProfile] using the macaronic ID in the provided [LanguageId]. + * + * This will return null if the [LanguageId] either doesn't have a macaronic ID defined, or if + * it's malformed. Macaronic IDs are always expected to include language and region components, + * so both fields are guaranteed to be populated in a returned [AndroidLocaleProfile]. + */ + fun createFromMacaronicLanguage( + languageId: LanguageId + ): AndroidLocaleProfile? { + if (!languageId.hasMacaronicId()) return null + val (languageCode, regionCode) = + languageId.macaronicId.combinedLanguageCode.divide("-") ?: return null + return maybeConstructProfile(languageCode, regionCode) + } + + 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 + } + + private fun String.divide(delimiter: String): Pair? { + val results = split(delimiter) + return if (results.size == 2) { + results[0] to results[1] + } else null + } + } +} 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 new file mode 100644 index 00000000000..ce75c3997cb --- /dev/null +++ b/utility/src/main/java/org/oppia/android/util/locale/BUILD.bazel @@ -0,0 +1,82 @@ +""" +Generic utilities for managing languages & locales. +""" + +load("@dagger//:workspace_defs.bzl", "dagger_rules") +load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_android_library") + +kt_android_library( + name = "android_locale_profile", + srcs = [ + "AndroidLocaleProfile.kt", + ], + visibility = ["//:oppia_api_visibility"], + deps = [ + ":oppia_locale", + ], +) + +kt_android_library( + name = "oppia_bidi_formatter", + srcs = [ + "OppiaBidiFormatter.kt", + ], + visibility = [ + "//domain/src/main/java/org/oppia/android/domain/locale:__pkg__", + "//utility:utility_testing_visibility", + "//utility/src/main/java/org/oppia/android/util/locale/testing:__pkg__", + ], +) + +kt_android_library( + name = "oppia_locale", + srcs = [ + "OppiaLocale.kt", + ], + visibility = ["//:oppia_api_visibility"], + deps = [ + ":oppia_locale_context_extensions", + "//model:languages_java_proto_lite", + "//third_party:androidx_annotation_annotation", + ], +) + +kt_android_library( + name = "impl", + srcs = [ + "MachineLocaleImpl.kt", + "OppiaBidiFormatterImpl.kt", + ], + visibility = ["//utility/src/main/java/org/oppia/android/util/locale/testing:__pkg__"], + deps = [ + ":dagger", + ":oppia_bidi_formatter", + ":oppia_locale", + "//utility/src/main/java/org/oppia/android/util/system:oppia_clock", + ], +) + +kt_android_library( + name = "oppia_locale_context_extensions", + srcs = [ + "OppiaLocaleContextExtensions.kt", + ], + deps = [ + "//model:languages_java_proto_lite", + ], +) + +kt_android_library( + name = "prod_module", + srcs = [ + "LocaleProdModule.kt", + ], + visibility = ["//:oppia_prod_module_visibility"], + deps = [ + ":dagger", + ":impl", + ":oppia_locale", + ], +) + +dagger_rules() diff --git a/utility/src/main/java/org/oppia/android/util/locale/LocaleProdModule.kt b/utility/src/main/java/org/oppia/android/util/locale/LocaleProdModule.kt new file mode 100644 index 00000000000..8dc13a401d0 --- /dev/null +++ b/utility/src/main/java/org/oppia/android/util/locale/LocaleProdModule.kt @@ -0,0 +1,14 @@ +package org.oppia.android.util.locale + +import dagger.Binds +import dagger.Module + +/** Module for providing production implementations of locale utilities. */ +@Module +interface LocaleProdModule { + @Binds + fun bindMachineLocale(impl: MachineLocaleImpl): OppiaLocale.MachineLocale + + @Binds + fun bindBidiFormatterFactory(impl: OppiaBidiFormatterImpl.FactoryImpl): OppiaBidiFormatter.Factory +} diff --git a/utility/src/main/java/org/oppia/android/util/locale/MachineLocaleImpl.kt b/utility/src/main/java/org/oppia/android/util/locale/MachineLocaleImpl.kt new file mode 100644 index 00000000000..ff70d87c6b3 --- /dev/null +++ b/utility/src/main/java/org/oppia/android/util/locale/MachineLocaleImpl.kt @@ -0,0 +1,100 @@ +package org.oppia.android.util.locale + +import org.oppia.android.app.model.LanguageSupportDefinition +import org.oppia.android.app.model.OppiaLanguage +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.util.system.OppiaClock +import java.text.DateFormat +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.util.Locale +import javax.inject.Inject + +// TODO(#3766): Restrict to be 'internal'. +/** + * Implementation of [OppiaLocale.MachineLocale]. + * + * Note that this implementation is backed by the Android US [Locale] for consistency among + * machine-used strings across runtimes. This is per the advice documented here: + * https://developer.android.com/reference/java/util/Locale#default_locale and since the Oppia + * Android team generally uses the US locale for identifiers & code. + */ +class MachineLocaleImpl @Inject constructor( + private val oppiaClock: OppiaClock +) : OppiaLocale.MachineLocale(machineLocaleContext) { + private val parsableDateFormat by lazy { SimpleDateFormat("yyyy-MM-dd", machineAndroidLocale) } + private val timeFormat by lazy { + DateFormat.getTimeInstance(DateFormat.MEDIUM, machineAndroidLocale) + } + + override fun String.formatForMachines(vararg args: Any?): String = + format(machineAndroidLocale, *args) + + override fun String.toMachineLowerCase(): String = toLowerCase(machineAndroidLocale) + + override fun String.toMachineUpperCase(): String = toUpperCase(machineAndroidLocale) + + override fun String.capitalizeForMachines(): String = capitalize(machineAndroidLocale) + + override fun String.decapitalizeForMachines(): String = decapitalize(machineAndroidLocale) + + override fun String.endsWithIgnoreCase(suffix: String): Boolean = + toMachineLowerCase().endsWith(suffix.toMachineLowerCase()) + + override fun String?.equalsIgnoreCase(other: String?): Boolean = + this?.toMachineLowerCase() == other?.toMachineLowerCase() + + override fun getCurrentTimeOfDay(): TimeOfDay { + return when (oppiaClock.getCurrentCalendar().get(Calendar.HOUR_OF_DAY)) { + in 4..11 -> TimeOfDay.MORNING + in 12..16 -> TimeOfDay.AFTERNOON + in 0..3, in 17..23 -> TimeOfDay.EVENING + else -> TimeOfDay.UNKNOWN + } + } + + override fun parseOppiaDate(dateString: String): OppiaDate? { + val parsedDate = try { + parsableDateFormat.parse(dateString) + } catch (e: ParseException) { + null + } + return parsedDate?.let { OppiaDateImpl(it, oppiaClock.getCurrentDate()) } + } + + override fun computeCurrentTimeString(): String = + timeFormat.format(Date(oppiaClock.getCurrentTimeMs())) + + override fun toString(): String = "MachineLocaleImpl[context=$machineLocaleContext]" + + override fun equals(other: Any?): Boolean { + return (other as? MachineLocaleImpl)?.let { locale -> + localeContext == locale.localeContext + } ?: false + } + + override fun hashCode(): Int = localeContext.hashCode() + + private class OppiaDateImpl(private val date: Date, private val today: Date) : OppiaDate { + override fun isBeforeToday(): Boolean = date.before(today) + } + + private companion object { + private val machineLocaleContext by lazy { + OppiaLocaleContext.newBuilder().apply { + languageDefinition = LanguageSupportDefinition.newBuilder().apply { + language = OppiaLanguage.ENGLISH + }.build() + regionDefinition = RegionSupportDefinition.newBuilder().apply { + region = OppiaRegion.UNITED_STATES + }.build() + }.build() + } + + private val machineAndroidLocale by lazy { Locale.US } + } +} diff --git a/utility/src/main/java/org/oppia/android/util/locale/OppiaBidiFormatter.kt b/utility/src/main/java/org/oppia/android/util/locale/OppiaBidiFormatter.kt new file mode 100644 index 00000000000..d85f1d93a44 --- /dev/null +++ b/utility/src/main/java/org/oppia/android/util/locale/OppiaBidiFormatter.kt @@ -0,0 +1,27 @@ +package org.oppia.android.util.locale + +import org.oppia.android.util.locale.OppiaBidiFormatter.Factory +import java.util.Locale + +/** + * A custom wrapper to Android's bidirectional formatter so that interactions with this class can be + * tested. + * + * Instances of this class are created via its [Factory]. + * + * Note that this class is only meant to be used by select packages, not broadly. Use [OppiaLocale] + * for actual locale-safe string formatting. + */ +interface OppiaBidiFormatter { + /** Wraps the provided text for bidirectional formatting & returns the result. */ + fun wrapText(unicode: CharSequence): CharSequence + + /** + * Factory for creating new [OppiaBidiFormatter]s. This class can be injected at the application + * component and below. + */ + interface Factory { + /** Returns a new [OppiaBidiFormatter] corresponding to the specified Android locale. */ + fun createFormatter(locale: Locale): OppiaBidiFormatter + } +} diff --git a/utility/src/main/java/org/oppia/android/util/locale/OppiaBidiFormatterImpl.kt b/utility/src/main/java/org/oppia/android/util/locale/OppiaBidiFormatterImpl.kt new file mode 100644 index 00000000000..5940339f9cf --- /dev/null +++ b/utility/src/main/java/org/oppia/android/util/locale/OppiaBidiFormatterImpl.kt @@ -0,0 +1,18 @@ +package org.oppia.android.util.locale + +import android.text.BidiFormatter +import java.util.Locale +import javax.inject.Inject + +/** Production implementation of [OppiaBidiFormatter]. */ +class OppiaBidiFormatterImpl private constructor(locale: Locale) : OppiaBidiFormatter { + private val bidiFormatter by lazy { BidiFormatter.getInstance(locale) } + + override fun wrapText(unicode: CharSequence): CharSequence = bidiFormatter.unicodeWrap(unicode) + + /** Implementation of [OppiaBidiFormatter.Factory]. */ + class FactoryImpl @Inject constructor() : OppiaBidiFormatter.Factory { + override fun createFormatter(locale: Locale): OppiaBidiFormatter = + OppiaBidiFormatterImpl(locale) + } +} diff --git a/utility/src/main/java/org/oppia/android/util/locale/OppiaLocale.kt b/utility/src/main/java/org/oppia/android/util/locale/OppiaLocale.kt new file mode 100644 index 00000000000..b4aa0fa3439 --- /dev/null +++ b/utility/src/main/java/org/oppia/android/util/locale/OppiaLocale.kt @@ -0,0 +1,353 @@ +package org.oppia.android.util.locale + +import android.content.res.Resources +import androidx.annotation.ArrayRes +import androidx.annotation.PluralsRes +import androidx.annotation.StringRes +import org.oppia.android.app.model.LanguageSupportDefinition.LanguageId +import org.oppia.android.app.model.OppiaLanguage +import org.oppia.android.app.model.OppiaLocaleContext +import org.oppia.android.app.model.OppiaRegion + +/** + * Represents a locale in the app. This is similar to Android's locale in that all locale-based + * operations should go through this class. + * + * However, it's different in that: + * 1. Some locales must be retrieved asynchronously through domain controllers since the validity of + * the locale is verified against the app's own language & region configurations + * 2. Because of (1), instances of this class are guaranteed to be supported by the app + * 3. Different subclasses of the locale exist to serve different purposes (such as processing + * strings, or computing user-displayable strings). This is conceptually similar Android's locale + * categories introduced in API 24. + * 4. All code in the app must use this class for locale-based operations (as backed by enforced + * regex CI checks) to ensure locale correctness in different contexts + * + * See [org.oppia.android.domain.locale.LocaleController] for retrieving instances of this class, + * and at its subclasses below for the different locale APIs that are available. + * + * Finally, all implementations of this class will be hashable & have equals implementations such + * different instances of this class will be equivalent so long as they are _functionally_ the same + * locale (which generally means that they are of the same class type and have the same + * [localeContext]). Further, subclasses will have custom [toString] implementations for improved + * logging messaging. + */ +sealed class OppiaLocale { + /** The deterministic context that represents this exact locale instance. */ + abstract val localeContext: OppiaLocaleContext + + /** + * Returns the [OppiaLanguage] corresponding to this locale. Locale instances can only correspond + * to a single language. + */ + fun getCurrentLanguage(): OppiaLanguage = localeContext.languageDefinition.language + + /** + * Returns the primary [LanguageId] corresponding to this locale (to be used when making + * interoperable decisions about matching a language-specific string or resource). + */ + fun getLanguageId(): LanguageId = localeContext.getLanguageId() + + /** + * Returns the fallback [LanguageId] corresponding to this locale which may be used as a + * replacement to [getLanguageId] in contexts where the [LanguageId] returned by that method is + * incompatible or unavailable. + */ + fun getFallbackLanguageId(): LanguageId = localeContext.getFallbackLanguageId() + + /** + * Returns the [OppiaRegion] corresponding to this locale. Note that this region may not always + * represent where the user actually lives. Instead, it's a best approximation for the user's + * region based on the system-reported locale & the list of officially supported regions by the + * app. Further, the user may always change their region based on system settings (which means + * it's entirely up to the user's discretion to indicate that the locale-reported region actually + * corresponds to their geographical location). + * + * There are other methods of representing or computing regions that may be more generic and + * accurate (such as using region codes for the former and telephony services for the latter), but + * the trade-off here is predictability and simpler implementation. + */ + fun getCurrentRegion(): OppiaRegion = localeContext.regionDefinition.region + + abstract override fun equals(other: Any?): Boolean + + abstract override fun hashCode(): Int + + abstract override fun toString(): String + + /** + * An [OppiaLocale] that is used only for machine-readable strings and should *never* be used for + * human-readable strings (with the exception of logging statements since those are only ever read + * by developers). This locale exists solely for string processing. All user-displayable strings + * should be processed using [DisplayLocale]. + * + * Implementations of this class guarantee consistency in string formatting across instances of + * the app (including app upgrades), and regardless of whatever locale the user has set up on + * their system or the particular version of Android or Android device that they're using. + * + * Unless otherwise noted, all methods of this class will not perform any bidirectional wrapping + * or locale-aware case changing/checking. + * + * Implementations of this class are available for injection starting that the application + * component. All code that depends on this locale can also leverage the fake Oppia clock to + * influence test-only behavior for time & date specific operations. + * + * Finally, some of the methods of this class are extension methods which means instances of the + * locale should be run in a contextual way to ensure the function receivers are set up correctly, + * e.g.: + * + * ```kt + * val stringsAreEqual = machineLocale.run { + * string.equalsIgnoreCase(other) + * } + * ``` + */ + abstract class MachineLocale(override val localeContext: OppiaLocaleContext) : OppiaLocale() { + /** Returns a formatted version of this string by interpolating the specified arguments. */ + abstract fun String.formatForMachines(vararg args: Any?): String + + /** Returns the lowercase version of this string. */ + abstract fun String.toMachineLowerCase(): String + + /** Returns the uppercase version of this string. */ + abstract fun String.toMachineUpperCase(): String + + /** Returns the capitalized version of this string. */ + abstract fun String.capitalizeForMachines(): String + + /** Returns the decapitalized version of this string. */ + abstract fun String.decapitalizeForMachines(): String + + /** Returns whether this string ends with the specified suffix, ignoring case differences. */ + abstract fun String.endsWithIgnoreCase(suffix: String): Boolean + + /** + * Returns whether this string is the same as the specified string, ignoring case differences. + */ + abstract fun String?.equalsIgnoreCase(other: String?): Boolean + + /** + * Returns the current [TimeOfDay]. + * + * Note that the returned date object is always corresponding to the local timezone of the + * device which may not relate to the user's defined locale. + */ + abstract fun getCurrentTimeOfDay(): TimeOfDay + + /** + * Returns an [OppiaDate] object representing the specified date string, or null if the string + * is incorrectly formatted. This has the same timezone caveat as [getCurrentTimeOfDay]. + * + * Dates are expected in the format: YYYY-MM-DD. + */ + abstract fun parseOppiaDate(dateString: String): OppiaDate? + + /** + * Returns a time string corresponding to the current wall clock time. Note that, as with other + * methods in this class, this should never be used for UI-displayed strings. It's intended for + * developer-facing strings, instead, such as log statements. + * + * The returned string is guaranteed to include at least the hour, minute, and second pertaining + * to the current wall time (though no guarantees are made to other time information being + * included, or in how this information is presented). + */ + abstract fun computeCurrentTimeString(): String + + /** Represents different times of day. */ + enum class TimeOfDay { + /** Corresponds to the user's morning time. */ + MORNING, + + /** Corresponds to the user's afternoon time. */ + AFTERNOON, + + /** Corresponds to the user's evening time. */ + EVENING, + + /** + * Corresponds to an unknown time of day (implying that something might have gone wrong during + * the time-of-day computation). + */ + UNKNOWN + } + + /** An abstract representation of a date. */ + interface OppiaDate { + /** + * Returns whether this date occurred before today. + * + * Note that the internal implementation is not required to guarantee day alignment, so it + * must be assigned that this check relates to the time at which the _object itself is + * created_ rather than the day on which it was created. + */ + fun isBeforeToday(): Boolean + } + } + + /** + * An [OppiaLocale] which can be used to compute user-displayable strings. Generally, this only + * applies to app strings (other content-based localization decisions are handled by + * [ContentLocale]). + * + * Instances of this class must be retrieved through domain layer controllers (generally via data + * providers) since the locale object to use may change if the user decides to change which + * language that they want to use within the app. Because of this, instances of this class are not + * available outside of the app layer. + * + * This class should only be used for strings that will eventually be shown to the user, or to + * make decisions regarding the layout of views shown to the user. Other strings should be + * processed using [MachineLocale] to ensure consistency across app instances. + * + * Note that some of the methods of this class use string receivers. See the documentation for + * [MachineLocale] for a code sample on how to wrap this locale in a receiver context to call + * those methods. + * + * Finally, note that resource-based methods do not guarantee that the strings returned are tied + * to this locale. Only the active locale can affect the strings returned (see + * [org.oppia.android.domain.locale.LocaleController.setAsDefault]). + */ + abstract class DisplayLocale(override val localeContext: OppiaLocaleContext) : OppiaLocale() { + /** + * Returns a locally formatted date string representing the specified Unix timestamp. + * + * No assumptions can be made regarding the formatting of the returned string. Further, the + * implementation aims to return a string that includes a medium amount of information + * corresponding to the time (i.e. 'January 1, 2019'). + */ + abstract fun computeDateString(timestampMillis: Long): String + + /** + * Returns a locally formatted time string representing the specified Unix timestamp. + * + * Similar to [computeDateString], no assumptions can be made about the format returned. + * Implementations should return enough information to convey hours and minutes (i.e. 4:45pm or + * 16:45). + */ + abstract fun computeTimeString(timestampMillis: Long): String + + /** + * Returns a locally formatted date/time string representing the specified Unix timestamp. + * + * Similar to [computeDateString], no assumptions can be made about the format returned. The + * information included corresponds to the documented behaviors of [computeDateString] and + * [computeTimeString]. + */ + abstract fun computeDateTimeString(timestampMillis: Long): String + + /** + * Returns the [androidx.core.view.ViewCompat] layout direction that should be used in layouts + * that display app strings localized by this locale. + */ + abstract fun getLayoutDirection(): Int + + /** + * Returns a formatted version of this string by interpolating the specified arguments, taking + * according to this locale. + * + * This method attempts to ensure bidirectional consistency by wrapping certain arguments with + * bidirectional markers so that they appear in the correct position in the returned string. See + * https://developer.android.com/training/basics/supporting-devices/languages#FormatTextExplanationSolution + * for specific cases when this can occur. Note that the method only supports taking + * CharSequences since all other types are likely machine-readable and need corresponding + * resource strings to represent their contents (except potentially integers which can either be + * directly converted to a string or formatted using [MachineLocale]). Further, null types are + * prohibited since 'null' is not a user-readable concept. + * + * This method should generally only be used for strings that are about to be immediately shown + * to the user. For strings that are intermediaries that will be part of other strings shown to + * the user, use [formatInLocaleWithoutWrapping] & one of the other 'withWrapping' methods to + * ensure the result is wrapped (strings shouldn't need to be wrapped multiple times for + * bidirectional correctness). + */ + abstract fun String.formatInLocaleWithWrapping(vararg args: CharSequence): String + + /** + * Returns a formatted version of this string by interpolating the specified arguments, taking + * according to this locale. + * + * Unlike [formatInLocaleWithWrapping], this method does not perform any bidirectional wrapping + * for the supplied arguments. However, it has the same argument restrictions as + * [formatInLocaleWithWrapping] since it's expected to produce strings that are meant to be + * eventually shown to the user (it's very likely the returned value by this method will need to + * be wrapped for bidirectional correctness by passing it to a subsequent 'withWrapping' method. + */ + abstract fun String.formatInLocaleWithoutWrapping(vararg args: CharSequence): String + + /** Returns a locale-aware capitalized version of this string suitable for displaying in UIs. */ + abstract fun String.capitalizeForHumans(): String + + /** + * Returns the exact resource string from this [Resources] object per the specified string + * resource ID. + */ + abstract fun Resources.getStringInLocale(@StringRes id: Int): String + + /** + * Returns a formatted string as described in [formatInLocaleWithWrapping] except for a string + * format retrieved from this [Resources] object (according to this locale). For this reason, + * strings that include non-string parameters are not supported for wrapping. Instead, arguments + * should be correctly converted to human-readable strings before being passed to this method. + * This also means that the strings themselves should only ever take string types since + * non-strings cannot be passed in. + */ + abstract fun Resources.getStringInLocaleWithWrapping( + @StringRes id: Int, + vararg formatArgs: CharSequence + ): String + + /** + * Returns a formatted string as described in [formatInLocaleWithoutWrapping] for a resource + * string. Unlike [getStringInLocaleWithWrapping], this method does not perform bidirectional + * wrapping to passed arguments. + */ + abstract fun Resources.getStringInLocaleWithoutWrapping( + @StringRes id: Int, + vararg formatArgs: CharSequence + ): String + + /** Returns the string array corresponding to the specified ID from this [Resources] object. */ + abstract fun Resources.getStringArrayInLocale(@ArrayRes id: Int): List + + /** + * Returns the quantity string specified ID and for the specified [quantity] from this + * [Resources] object. + */ + abstract fun Resources.getQuantityStringInLocale(@PluralsRes id: Int, quantity: Int): String + + /** + * Returns a quantity string with formatting per [getQuantityStringInLocale] and + * [getStringInLocaleWithWrapping]. + */ + abstract fun Resources.getQuantityStringInLocaleWithWrapping( + @PluralsRes id: Int, + quantity: Int, + vararg formatArgs: CharSequence + ): String + + /** + * Returns a quantity string with formatting per [getQuantityStringInLocale] and + * [getStringInLocaleWithoutWrapping]. + */ + abstract fun Resources.getQuantityStringInLocaleWithoutWrapping( + @PluralsRes id: Int, + quantity: Int, + vararg formatArgs: CharSequence + ): String + + /** + * Returns a similar result as [getQuantityStringInLocale] except as a [CharSequence] instead of + * as a [String]. See [Resources.getQuantityText] for more details. + */ + abstract fun Resources.getQuantityTextInLocale(@PluralsRes id: Int, quantity: Int): CharSequence + } + + /** + * An [OppiaLocale] representing content-based localization (such as for written translation + * strings in lessons or audio voiceovers. + * + * This generally doesn't perform operations directly on strings and instead acts as a wrapper for + * an [OppiaLocaleContext] which provides details on how to make language-based decisions + * corresponding to content localization. + */ + abstract class ContentLocale(override val localeContext: OppiaLocaleContext) : OppiaLocale() +} diff --git a/utility/src/main/java/org/oppia/android/util/locale/OppiaLocaleContextExtensions.kt b/utility/src/main/java/org/oppia/android/util/locale/OppiaLocaleContextExtensions.kt new file mode 100644 index 00000000000..81135e2375f --- /dev/null +++ b/utility/src/main/java/org/oppia/android/util/locale/OppiaLocaleContextExtensions.kt @@ -0,0 +1,29 @@ +package org.oppia.android.util.locale + +import org.oppia.android.app.model.LanguageSupportDefinition.LanguageId +import org.oppia.android.app.model.OppiaLocaleContext +import org.oppia.android.app.model.OppiaLocaleContext.LanguageUsageMode.APP_STRINGS +import org.oppia.android.app.model.OppiaLocaleContext.LanguageUsageMode.AUDIO_TRANSLATIONS +import org.oppia.android.app.model.OppiaLocaleContext.LanguageUsageMode.CONTENT_STRINGS +import org.oppia.android.app.model.OppiaLocaleContext.LanguageUsageMode.UNRECOGNIZED +import org.oppia.android.app.model.OppiaLocaleContext.LanguageUsageMode.USAGE_MODE_UNSPECIFIED + +/** Returns the primary [LanguageId] corresponding to this context, based on its intended usage. */ +fun OppiaLocaleContext.getLanguageId(): LanguageId { + return when (usageMode) { + APP_STRINGS -> languageDefinition.appStringId + CONTENT_STRINGS -> languageDefinition.contentStringId + AUDIO_TRANSLATIONS -> languageDefinition.audioTranslationId + USAGE_MODE_UNSPECIFIED, UNRECOGNIZED, null -> LanguageId.getDefaultInstance() + } +} + +/** Returns the fallback [LanguageId] corresponding to this context, based on its intended usage. */ +fun OppiaLocaleContext.getFallbackLanguageId(): LanguageId { + return when (usageMode) { + APP_STRINGS -> fallbackLanguageDefinition.appStringId + CONTENT_STRINGS -> fallbackLanguageDefinition.contentStringId + AUDIO_TRANSLATIONS -> fallbackLanguageDefinition.audioTranslationId + USAGE_MODE_UNSPECIFIED, UNRECOGNIZED, null -> LanguageId.getDefaultInstance() + } +} diff --git a/utility/src/main/java/org/oppia/android/util/locale/testing/BUILD.bazel b/utility/src/main/java/org/oppia/android/util/locale/testing/BUILD.bazel new file mode 100644 index 00000000000..15136e7c69e --- /dev/null +++ b/utility/src/main/java/org/oppia/android/util/locale/testing/BUILD.bazel @@ -0,0 +1,36 @@ +""" +Testing utilities for language & locale general utilities. +""" + +load("@dagger//:workspace_defs.bzl", "dagger_rules") +load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_android_library") + +kt_android_library( + name = "test_oppia_bidi_formatter", + testonly = True, + srcs = [ + "TestOppiaBidiFormatter.kt", + ], + visibility = ["//:oppia_testing_visibility"], + deps = [ + ":dagger", + "//utility/src/main/java/org/oppia/android/util/locale:impl", + "//utility/src/main/java/org/oppia/android/util/locale:oppia_bidi_formatter", + ], +) + +kt_android_library( + name = "test_module", + testonly = True, + srcs = [ + "LocaleTestModule.kt", + ], + visibility = ["//:oppia_testing_visibility"], + deps = [ + ":dagger", + ":test_oppia_bidi_formatter", + "//utility/src/main/java/org/oppia/android/util/locale:impl", + ], +) + +dagger_rules() diff --git a/utility/src/main/java/org/oppia/android/util/locale/testing/LocaleTestModule.kt b/utility/src/main/java/org/oppia/android/util/locale/testing/LocaleTestModule.kt new file mode 100644 index 00000000000..1f2e611ba1b --- /dev/null +++ b/utility/src/main/java/org/oppia/android/util/locale/testing/LocaleTestModule.kt @@ -0,0 +1,17 @@ +package org.oppia.android.util.locale.testing + +import dagger.Binds +import dagger.Module +import org.oppia.android.util.locale.MachineLocaleImpl +import org.oppia.android.util.locale.OppiaBidiFormatter +import org.oppia.android.util.locale.OppiaLocale + +/** Module for providing testing implementations of locale utilities. */ +@Module +interface LocaleTestModule { + @Binds + fun bindMachineLocale(impl: MachineLocaleImpl): OppiaLocale.MachineLocale + + @Binds + fun bindBidiFormatterFactory(impl: TestOppiaBidiFormatter.FactoryImpl): OppiaBidiFormatter.Factory +} diff --git a/utility/src/main/java/org/oppia/android/util/locale/testing/TestOppiaBidiFormatter.kt b/utility/src/main/java/org/oppia/android/util/locale/testing/TestOppiaBidiFormatter.kt new file mode 100644 index 00000000000..210ac897bbf --- /dev/null +++ b/utility/src/main/java/org/oppia/android/util/locale/testing/TestOppiaBidiFormatter.kt @@ -0,0 +1,89 @@ +package org.oppia.android.util.locale.testing + +import org.oppia.android.util.locale.OppiaBidiFormatter +import org.oppia.android.util.locale.OppiaBidiFormatterImpl +import org.oppia.android.util.locale.testing.TestOppiaBidiFormatter.Checker +import java.util.Locale +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Test-only implementation of [OppiaBidiFormatter] for verifying calls to the formatter. + * + * See [Checker.isTextWrapped] for details on how to validate string wrapping. Further, note that + * this formatter delegates to the production formatter to ensure that wrapping still happens, and + * it will validate that strings are not wrapped multiple times. + * + * Implementations of this class are available by injecting [OppiaBidiFormatter.Factory] at the test + * scope & constructing a new formatter. + */ +class TestOppiaBidiFormatter private constructor( + private val prodFormatter: OppiaBidiFormatter, + private val checker: Checker +) : OppiaBidiFormatter { + override fun wrapText(unicode: CharSequence): CharSequence { + check(unicode !is WrappedStringMarker) { + "Error: encountered string that's already been wrapped: $unicode" + } + checker.wrappedTexts += unicode + return WrappedStringMarker(prodFormatter.wrapText(unicode)) + } + + /** Implementation of [OppiaBidiFormatter.Factory] for test formatters. */ + class FactoryImpl @Inject constructor( + private val prodFactoryImpl: OppiaBidiFormatterImpl.FactoryImpl, + private val checker: Checker + ) : OppiaBidiFormatter.Factory { + override fun createFormatter(locale: Locale): OppiaBidiFormatter = + TestOppiaBidiFormatter(prodFactoryImpl.createFormatter(locale), checker) + } + + /** + * Checker utility for determining whether a [CharSequence] has been wrapped for bidirectional + * formatting. + * + * This class can be injected at the test application scope & be used for any strings wrapped by + * any [TestOppiaBidiFormatter]s. + */ + @Singleton + class Checker @Inject constructor() { + /** The list of texts wrapped using [wrapText] across all formatters. */ + internal val wrappedTexts = mutableListOf() + + /** + * Returns whether the specified unicode sequence has been wrapped for formatting by this class + * (i.e. by a call to [wrapText]). + * + * Note that [wrapText] will ensure that already wrapped sequences are not wrapped again. + * Further, certain operations will break this check (particularly, conversion to a string or + * anything that returns a different char scequence from the original). + * + * Finally, this method will not return true for a string formatted by the original production + * implementation or Android's bidirectional formatter directly. This is only meant to be used + * on char sequences returned recently from a call to [wrapText]. + */ + fun isTextWrapped(unicode: CharSequence): Boolean = unicode is WrappedStringMarker + + /** + * Returns the list of all sequences wrapped using [wrapText] across all formatters. Care should + * be taken if multiple formatters are used in succession since this list does not necessarily + * guarantee order. + */ + fun getAllWrappedUnicodeTexts(): List = wrappedTexts + } + + private class WrappedStringMarker(private val wrappedSeq: CharSequence) : CharSequence { + override val length: Int = wrappedSeq.length + + override fun get(index: Int): Char = wrappedSeq[index] + + override fun subSequence(startIndex: Int, endIndex: Int): CharSequence = + wrappedSeq.subSequence(startIndex, endIndex) + + override fun toString(): String = wrappedSeq.toString() + + override fun hashCode(): Int = wrappedSeq.hashCode() + + override fun equals(other: Any?): Boolean = toString() == other + } +} diff --git a/utility/src/main/java/org/oppia/android/util/parser/image/RepositoryGlideModule.kt b/utility/src/main/java/org/oppia/android/util/parser/image/RepositoryGlideModule.kt index 03b7a1415de..b11652e0207 100644 --- a/utility/src/main/java/org/oppia/android/util/parser/image/RepositoryGlideModule.kt +++ b/utility/src/main/java/org/oppia/android/util/parser/image/RepositoryGlideModule.kt @@ -5,7 +5,6 @@ import com.bumptech.glide.Glide import com.bumptech.glide.Registry import com.bumptech.glide.annotation.GlideModule import com.bumptech.glide.module.AppGlideModule -import org.oppia.android.util.caching.AssetRepository import org.oppia.android.util.parser.svg.BlockPictureDrawable import org.oppia.android.util.parser.svg.BlockSvgDrawableTranscoder import org.oppia.android.util.parser.svg.ScalableVectorGraphic @@ -13,7 +12,10 @@ import org.oppia.android.util.parser.svg.SvgDecoder import org.oppia.android.util.parser.svg.TextSvgDrawableTranscoder import java.io.InputStream -/** Custom [AppGlideModule] to enable loading images from [AssetRepository] via Glide. */ +/** + * Custom [AppGlideModule] to enable loading images from + * [org.oppia.android.util.caching.AssetRepository] via Glide. + */ @GlideModule class RepositoryGlideModule : AppGlideModule() { override fun registerComponents(context: Context, glide: Glide, registry: Registry) { diff --git a/utility/src/main/java/org/oppia/android/util/system/OppiaClock.kt b/utility/src/main/java/org/oppia/android/util/system/OppiaClock.kt index a1a7902a07f..88d8d0d541b 100644 --- a/utility/src/main/java/org/oppia/android/util/system/OppiaClock.kt +++ b/utility/src/main/java/org/oppia/android/util/system/OppiaClock.kt @@ -1,6 +1,7 @@ package org.oppia.android.util.system import java.util.Calendar +import java.util.Date /** Utility to get the current date/time. Tests should use the fake version of this class. */ interface OppiaClock { @@ -18,5 +19,13 @@ interface OppiaClock { * Returns the current date and time as a [Calendar]. Unlike [getCurrentTimeMs], the returned * [Calendar] takes into account the user's local time zone. */ - fun getCurrentCalendar(): Calendar + fun getCurrentCalendar(): Calendar = Calendar.getInstance().apply { + timeInMillis = getCurrentTimeMs() + } + + /** + * Returns the [Date] corresponding to the current instant in time, according to + * [getCurrentTimeMs]. + */ + fun getCurrentDate(): Date = Date(getCurrentTimeMs()) } diff --git a/utility/src/main/java/org/oppia/android/util/system/OppiaClockImpl.kt b/utility/src/main/java/org/oppia/android/util/system/OppiaClockImpl.kt index ab7a331e806..4c73b311447 100644 --- a/utility/src/main/java/org/oppia/android/util/system/OppiaClockImpl.kt +++ b/utility/src/main/java/org/oppia/android/util/system/OppiaClockImpl.kt @@ -1,15 +1,8 @@ package org.oppia.android.util.system -import java.util.Calendar import javax.inject.Inject /** Implementation of [OppiaClock] that uses real time dependencies. */ class OppiaClockImpl @Inject constructor() : OppiaClock { override fun getCurrentTimeMs(): Long = System.currentTimeMillis() - - override fun getCurrentCalendar(): Calendar { - val calendar = Calendar.getInstance() - calendar.timeInMillis = getCurrentTimeMs() - return calendar - } } diff --git a/utility/src/test/java/org/oppia/android/util/caching/AssetModuleTest.kt b/utility/src/test/java/org/oppia/android/util/caching/AssetModuleTest.kt new file mode 100644 index 00000000000..a962b41fa77 --- /dev/null +++ b/utility/src/test/java/org/oppia/android/util/caching/AssetModuleTest.kt @@ -0,0 +1,79 @@ +package org.oppia.android.util.caching + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import dagger.BindsInstance +import dagger.Component +import dagger.Module +import dagger.Provides +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.oppia.android.testing.robolectric.RobolectricModule +import org.oppia.android.testing.threading.TestDispatcherModule +import org.oppia.android.util.logging.LoggerModule +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import javax.inject.Inject +import javax.inject.Singleton + +/** Tests for [AssetModule]. */ +// FunctionName: test names are conventionally named with underscores. +@Suppress("FunctionName") +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +@Config(manifest = Config.NONE) +class AssetModuleTest { + @Inject + lateinit var assetRepository: AssetRepository + + @Before + fun setUp() { + setUpTestApplicationComponent() + } + + @Test + fun testModule_injectsProductionImplementationOfAssetRepository() { + assertThat(assetRepository).isInstanceOf(AssetRepositoryImpl::class.java) + } + + private fun setUpTestApplicationComponent() { + DaggerAssetModuleTest_TestApplicationComponent.builder() + .setApplication(ApplicationProvider.getApplicationContext()) + .build() + .inject(this) + } + + // TODO(#89): Move this to a common test application component. + @Module + class TestModule { + @Provides + @Singleton + fun provideContext(application: Application): Context { + return application + } + } + + // TODO(#89): Move this to a common test application component. + @Singleton + @Component( + modules = [ + TestModule::class, AssetModule::class, LoggerModule::class, TestDispatcherModule::class, + RobolectricModule::class + ] + ) + interface TestApplicationComponent { + @Component.Builder + interface Builder { + @BindsInstance + fun setApplication(application: Application): Builder + + fun build(): TestApplicationComponent + } + + fun inject(assetModuleTest: AssetModuleTest) + } +} diff --git a/utility/src/test/java/org/oppia/android/util/caching/BUILD.bazel b/utility/src/test/java/org/oppia/android/util/caching/BUILD.bazel new file mode 100644 index 00000000000..41366f342d3 --- /dev/null +++ b/utility/src/test/java/org/oppia/android/util/caching/BUILD.bazel @@ -0,0 +1,29 @@ +""" +Tests for caching utilities. +""" + +load("@dagger//:workspace_defs.bzl", "dagger_rules") +load("//:oppia_android_test.bzl", "oppia_android_test") + +oppia_android_test( + name = "AssetModuleTest", + srcs = ["AssetModuleTest.kt"], + custom_package = "org.oppia.android.util.caching", + test_class = "org.oppia.android.util.caching.AssetModuleTest", + test_manifest = "//utility:test_manifest", + deps = [ + ":dagger", + "//testing/src/main/java/org/oppia/android/testing/robolectric:test_module", + "//testing/src/main/java/org/oppia/android/testing/threading:test_module", + "//third_party:androidx_test_ext_junit", + "//third_party:com_google_truth_truth", + "//third_party:junit_junit", + "//third_party:org_robolectric_robolectric", + "//third_party:robolectric_android-all", + "//utility/src/main/java/org/oppia/android/util/caching:asset_prod_module", + "//utility/src/main/java/org/oppia/android/util/caching:asset_repository", + "//utility/src/main/java/org/oppia/android/util/logging:prod_module", + ], +) + +dagger_rules() diff --git a/utility/src/test/java/org/oppia/android/util/caching/testing/AssetTestNoOpModuleTest.kt b/utility/src/test/java/org/oppia/android/util/caching/testing/AssetTestNoOpModuleTest.kt new file mode 100644 index 00000000000..d577afad117 --- /dev/null +++ b/utility/src/test/java/org/oppia/android/util/caching/testing/AssetTestNoOpModuleTest.kt @@ -0,0 +1,76 @@ +package org.oppia.android.util.caching.testing + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import dagger.BindsInstance +import dagger.Component +import dagger.Module +import dagger.Provides +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.oppia.android.util.caching.AssetRepository +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import javax.inject.Inject +import javax.inject.Singleton + +/** Tests for [AssetTestNoOpModule]. */ +// FunctionName: test names are conventionally named with underscores. +@Suppress("FunctionName") +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +@Config(manifest = Config.NONE) +class AssetTestNoOpModuleTest { + @Inject + lateinit var assetRepository: AssetRepository + + @Before + fun setUp() { + setUpTestApplicationComponent() + } + + @Test + fun testModule_injectsTestImplementationOfAssetRepository() { + assertThat(assetRepository).isInstanceOf(TestNoOpAssetRepository::class.java) + } + + private fun setUpTestApplicationComponent() { + DaggerAssetTestNoOpModuleTest_TestApplicationComponent.builder() + .setApplication(ApplicationProvider.getApplicationContext()) + .build() + .inject(this) + } + + // TODO(#89): Move this to a common test application component. + @Module + class TestModule { + @Provides + @Singleton + fun provideContext(application: Application): Context { + return application + } + } + + // TODO(#89): Move this to a common test application component. + @Singleton + @Component( + modules = [ + TestModule::class, AssetTestNoOpModule::class + ] + ) + interface TestApplicationComponent { + @Component.Builder + interface Builder { + @BindsInstance + fun setApplication(application: Application): Builder + + fun build(): TestApplicationComponent + } + + fun inject(assetTestNoOpModuleTest: AssetTestNoOpModuleTest) + } +} diff --git a/utility/src/test/java/org/oppia/android/util/caching/testing/BUILD.bazel b/utility/src/test/java/org/oppia/android/util/caching/testing/BUILD.bazel new file mode 100644 index 00000000000..a4187f0dede --- /dev/null +++ b/utility/src/test/java/org/oppia/android/util/caching/testing/BUILD.bazel @@ -0,0 +1,47 @@ +""" +Tests for testing utilities corresponding asset caching utilities. +""" + +load("@dagger//:workspace_defs.bzl", "dagger_rules") +load("//:oppia_android_test.bzl", "oppia_android_test") + +oppia_android_test( + name = "AssetTestNoOpModuleTest", + srcs = ["AssetTestNoOpModuleTest.kt"], + custom_package = "org.oppia.android.util.caching.testing", + test_class = "org.oppia.android.util.caching.testing.AssetTestNoOpModuleTest", + test_manifest = "//utility:test_manifest", + deps = [ + ":dagger", + "//third_party:androidx_test_ext_junit", + "//third_party:com_google_truth_truth", + "//third_party:junit_junit", + "//third_party:org_robolectric_robolectric", + "//third_party:robolectric_android-all", + "//utility/src/main/java/org/oppia/android/util/caching:asset_repository", + "//utility/src/main/java/org/oppia/android/util/caching/testing:asset_test_no_op_module", + ], +) + +oppia_android_test( + name = "TestNoOpAssetRepositoryTest", + srcs = ["TestNoOpAssetRepositoryTest.kt"], + custom_package = "org.oppia.android.util.caching.testing", + test_class = "org.oppia.android.util.caching.testing.TestNoOpAssetRepositoryTest", + test_manifest = "//utility:test_manifest", + deps = [ + ":dagger", + "//model:test_models", + "//testing", + "//third_party:androidx_test_ext_junit", + "//third_party:com_google_truth_extensions_truth-liteproto-extension", + "//third_party:com_google_truth_truth", + "//third_party:junit_junit", + "//third_party:org_robolectric_robolectric", + "//third_party:robolectric_android-all", + "//utility/src/main/java/org/oppia/android/util/caching:asset_repository", + "//utility/src/main/java/org/oppia/android/util/caching/testing:asset_test_no_op_module", + ], +) + +dagger_rules() diff --git a/utility/src/test/java/org/oppia/android/util/caching/testing/TestNoOpAssetRepositoryTest.kt b/utility/src/test/java/org/oppia/android/util/caching/testing/TestNoOpAssetRepositoryTest.kt new file mode 100644 index 00000000000..1bc640a5c8e --- /dev/null +++ b/utility/src/test/java/org/oppia/android/util/caching/testing/TestNoOpAssetRepositoryTest.kt @@ -0,0 +1,187 @@ +package org.oppia.android.util.caching.testing + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.extensions.proto.LiteProtoTruth.assertThat +import dagger.BindsInstance +import dagger.Component +import dagger.Module +import dagger.Provides +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.oppia.android.app.model.TestMessage +import org.oppia.android.testing.assertThrows +import org.oppia.android.util.caching.AssetRepository +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import javax.inject.Inject +import javax.inject.Singleton + +/** Tests for [TestNoOpAssetRepository]. */ +// FunctionName: test names are conventionally named with underscores. +@Suppress("FunctionName") +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +@Config(manifest = Config.NONE) +class TestNoOpAssetRepositoryTest { + @Inject + lateinit var assetRepository: AssetRepository + + @Before + fun setUp() { + setUpTestApplicationComponent() + } + + @Test + fun testLoadTextFileFromLocalAssets_throwsException() { + val exception = assertThrows(IllegalStateException::class) { + assetRepository.loadTextFileFromLocalAssets("asset.json") + } + + assertThat(exception).hasMessageThat().contains("Local text asset doesn't exist: asset.json") + } + + @Test + fun testPrimeTextFileFromLocalAssets_doesNotThrowException() { + assetRepository.primeTextFileFromLocalAssets("asset.json") + + // Nothing happens since priming no-ops. + } + + @Test + fun testPrimeTextFileFromLocalAssets_thenLoadAsset_throwsException() { + assetRepository.primeTextFileFromLocalAssets("asset.json") + + // Priming doesn't do anything, so the exception is still thrown. + val exception = assertThrows(IllegalStateException::class) { + assetRepository.loadTextFileFromLocalAssets("asset.json") + } + + assertThat(exception).hasMessageThat().contains("Local text asset doesn't exist: asset.json") + } + + @Test + fun testLoadProtoFromLocalAssets_throwsException() { + val exception = assertThrows(IllegalStateException::class) { + assetRepository.loadProtoFromLocalAssets("test", TestMessage.getDefaultInstance()) + } + + assertThat(exception).hasMessageThat().contains("Local proto asset doesn't exist: test") + } + + @Test + fun testTryLoadProtoFromLocalAssets_returnsDefaultProto() { + val testMessage = TestMessage.newBuilder().apply { + intValue = 12 + }.build() + + val result = assetRepository.tryLoadProtoFromLocalAssets("test", testMessage) + + // tryLoad() will always return the default message provided since no local assets exist. + assertThat(result).isEqualTo(testMessage) + } + + @Test + fun testGetLocalAssetProtoSize_returnsNegativeOne() { + val size = assetRepository.getLocalAssetProtoSize("test") + + // The size is always -1 since no local assets exist. + assertThat(size).isEqualTo(-1) + } + + @Test + fun testLoadRemoteBinaryAsset_throwsException() { + val exception = assertThrows(IllegalStateException::class) { + assetRepository.loadRemoteBinaryAsset("https://example.com/test.pb") + } + + assertThat(exception).hasMessageThat() + .contains("Remote asset doesn't exist: https://example.com/test.pb") + } + + @Test + fun testLoadImageAssetFromLocalAssets_throwsException() { + val exception = assertThrows(IllegalStateException::class) { + assetRepository.loadImageAssetFromLocalAssets("https://example.com/test.png") + } + + assertThat(exception).hasMessageThat() + .contains("Local image asset doesn't exist: https://example.com/test.png") + } + + @Test + fun testPrimeRemoteBinaryAsset_doesNotThrowException() { + assetRepository.primeRemoteBinaryAsset("https://example.com/test.pb") + + // Nothing happens since priming no-ops. + } + + @Test + fun testPrimeRemoteBinaryAsset_thenLoad_throwsException() { + assetRepository.primeRemoteBinaryAsset("https://example.com/test.pb") + + // Priming doesn't do anything, so the exception is still thrown. + val exception = assertThrows(IllegalStateException::class) { + assetRepository.loadRemoteBinaryAsset("https://example.com/test.pb") + } + + assertThat(exception).hasMessageThat() + .contains("Remote asset doesn't exist: https://example.com/test.pb") + } + + @Test + fun testIsRemoteBinaryAssetDownloaded_returnsFalse() { + val exists = assetRepository.isRemoteBinaryAssetDownloaded("https://example.com/test.pb") + + assertThat(exists).isFalse() // Nothing exists. + } + + @Test + fun testIsRemoteBinaryAssetDownloaded_afterPriming_returnsFalse() { + assetRepository.primeRemoteBinaryAsset("https://example.com/test.pb") + + val exists = assetRepository.isRemoteBinaryAssetDownloaded("https://example.com/test.pb") + + assertThat(exists).isFalse() // Nothing still exists since priming does nothing. + } + + private fun setUpTestApplicationComponent() { + DaggerTestNoOpAssetRepositoryTest_TestApplicationComponent.builder() + .setApplication(ApplicationProvider.getApplicationContext()) + .build() + .inject(this) + } + + // TODO(#89): Move this to a common test application component. + @Module + class TestModule { + @Provides + @Singleton + fun provideContext(application: Application): Context { + return application + } + } + + // TODO(#89): Move this to a common test application component. + @Singleton + @Component( + modules = [ + TestModule::class, AssetTestNoOpModule::class + ] + ) + interface TestApplicationComponent { + @Component.Builder + interface Builder { + @BindsInstance + fun setApplication(application: Application): Builder + + fun build(): TestApplicationComponent + } + + fun inject(testNoOpAssetRepositoryTest: TestNoOpAssetRepositoryTest) + } +} 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 new file mode 100644 index 00000000000..6a83b6a051d --- /dev/null +++ b/utility/src/test/java/org/oppia/android/util/locale/AndroidLocaleProfileTest.kt @@ -0,0 +1,544 @@ +package org.oppia.android.util.locale + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import dagger.BindsInstance +import dagger.Component +import dagger.Module +import dagger.Provides +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.oppia.android.app.model.LanguageSupportDefinition.IetfBcp47LanguageId +import org.oppia.android.app.model.LanguageSupportDefinition.LanguageId +import org.oppia.android.app.model.LanguageSupportDefinition.MacaronicLanguageId +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.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import java.util.Locale +import javax.inject.Inject +import javax.inject.Singleton + +/** Tests for [AndroidLocaleProfile]. */ +// FunctionName: test names are conventionally named with underscores. +@Suppress("FunctionName") +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +@Config(manifest = Config.NONE) +class AndroidLocaleProfileTest { + @Inject + lateinit var machineLocale: OppiaLocale.MachineLocale + + private val portugueseLocale by lazy { Locale("pt") } + private val brazilianPortugueseLocale by lazy { Locale("pt", "BR") } + + @Before + fun setUp() { + setUpTestApplicationComponent() + } + + /* Tests for createFrom */ + + @Test + fun testCreateProfile_fromRootLocale_returnsProfileWithoutLanguageAndRegionCode() { + val profile = AndroidLocaleProfile.createFrom(Locale.ROOT) + + assertThat(profile.languageCode).isEmpty() + assertThat(profile.regionCode).isEmpty() + } + + @Test + fun testCreateProfile_fromEnglishLocale_returnsProfileWithLanguageAndWithoutRegion() { + val profile = AndroidLocaleProfile.createFrom(Locale.ENGLISH) + + assertThat(profile.languageCode).isEqualTo("en") + assertThat(profile.regionCode).isEmpty() + } + + @Test + fun testCreateProfile_fromBrazilianPortuguese_returnsProfileWithLanguageAndRegion() { + val profile = AndroidLocaleProfile.createFrom(brazilianPortugueseLocale) + + assertThat(profile.languageCode).isEqualTo("pt") + assertThat(profile.regionCode).isEqualTo("BR") + } + + /* Tests for createFromIetfDefinitions */ + + @Test + fun testCreateProfileFromIetf_defaultLanguageId_nullRegion_returnsNull() { + val profile = + AndroidLocaleProfile.createFromIetfDefinitions( + languageId = LanguageId.getDefaultInstance(), regionDefinition = null + ) + + assertThat(profile).isNull() + } + + @Test + fun testCreateProfileFromIetf_languageIdWithoutIetf_withRegion_returnsNull() { + val languageWithoutIetf = LanguageId.newBuilder().apply { + macaronicId = MacaronicLanguageId.newBuilder().apply { + combinedLanguageCode = "hi-en" + }.build() + }.build() + + val profile = AndroidLocaleProfile.createFromIetfDefinitions(languageWithoutIetf, REGION_INDIA) + + // The language ID needs to have an IETF BCP 47 ID defined. + assertThat(profile).isNull() + } + + @Test + fun testCreateProfileFromIetf_languageIdWithEmptyIetf_withRegion_returnsNull() { + val languageWithIetf = LanguageId.newBuilder().apply { + ietfBcp47Id = IetfBcp47LanguageId.newBuilder().apply { + ietfLanguageTag = "" + }.build() + }.build() + + val profile = AndroidLocaleProfile.createFromIetfDefinitions(languageWithIetf, REGION_INDIA) + + // The language ID needs to have an IETF BCP 47 ID defined. + assertThat(profile).isNull() + } + + @Test + fun testCreateProfileFromIetf_languageIdWithMalformedIetf_withRegion_returnsNull() { + val languageWithIetf = LanguageId.newBuilder().apply { + ietfBcp47Id = IetfBcp47LanguageId.newBuilder().apply { + ietfLanguageTag = "mal-form-ed" + }.build() + }.build() + + val profile = AndroidLocaleProfile.createFromIetfDefinitions(languageWithIetf, REGION_INDIA) + + // The language ID needs to have a well-formed IETF BCP 47 ID defined. + assertThat(profile).isNull() + } + + @Test + fun testCreateProfileFromIetf_languageIdWithIetfLanguageCode_withRegion_returnsCombinedProfile() { + val languageWithIetf = LanguageId.newBuilder().apply { + ietfBcp47Id = IetfBcp47LanguageId.newBuilder().apply { + ietfLanguageTag = "pt" + }.build() + }.build() + + val profile = AndroidLocaleProfile.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") + } + + @Test + fun testCreateProfileFromIetf_withIetfLanguageRegionTag_withRegion_returnsIetfRegionProfile() { + val languageWithIetf = LanguageId.newBuilder().apply { + ietfBcp47Id = IetfBcp47LanguageId.newBuilder().apply { + ietfLanguageTag = "pt-BR" + }.build() + }.build() + + val profile = AndroidLocaleProfile.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") + } + + @Test + fun testCreateProfileFromIetf_languageIdWithIetfLanguageCode_withDefaultRegion_returnsNull() { + val languageWithIetf = LanguageId.newBuilder().apply { + ietfBcp47Id = IetfBcp47LanguageId.newBuilder().apply { + ietfLanguageTag = "pt" + }.build() + }.build() + + val profile = + AndroidLocaleProfile.createFromIetfDefinitions( + languageWithIetf, regionDefinition = RegionSupportDefinition.getDefaultInstance() + ) + + // The region is needed in this case, so it needs to be provided. + assertThat(profile).isNull() + } + + @Test + fun testCreateProfileFromIetf_languageIdWithIetfLanguageCode_withEmptyRegion_returnsNull() { + val languageWithIetf = LanguageId.newBuilder().apply { + ietfBcp47Id = IetfBcp47LanguageId.newBuilder().apply { + ietfLanguageTag = "pt" + }.build() + }.build() + + val profile = + AndroidLocaleProfile.createFromIetfDefinitions( + languageWithIetf, regionDefinition = RegionSupportDefinition.getDefaultInstance() + ) + + // The region is needed in this case, so a valid one needs to be provided. + assertThat(profile).isNull() + } + + @Test + fun testCreateProfileFromIetf_withIetfLanguageCode_withNullRegion_returnsWildcardProfile() { + val languageWithIetf = LanguageId.newBuilder().apply { + ietfBcp47Id = IetfBcp47LanguageId.newBuilder().apply { + ietfLanguageTag = "pt" + }.build() + }.build() + + val profile = + AndroidLocaleProfile.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) + } + + /* Tests for createFromMacaronicLanguage */ + + @Test + fun testCreateProfileFromMacaronic_defaultLanguageId_returnsNull() { + val profile = + AndroidLocaleProfile.createFromMacaronicLanguage(languageId = LanguageId.getDefaultInstance()) + + assertThat(profile).isNull() + } + + @Test + fun testCreateProfileFromMacaronic_languageIdWithoutMacaronic_returnsNull() { + val languageWithoutMacaronic = LanguageId.newBuilder().apply { + ietfBcp47Id = IetfBcp47LanguageId.newBuilder().apply { + ietfLanguageTag = "pt" + }.build() + }.build() + + val profile = + AndroidLocaleProfile.createFromMacaronicLanguage(languageWithoutMacaronic) + + // The provided language ID must have a macaronic ID defined. + assertThat(profile).isNull() + } + + @Test + fun testCreateProfileFromMacaronic_languageIdWithEmptyMacaronic_returnsNull() { + val languageWithMacaronic = LanguageId.newBuilder().apply { + macaronicId = MacaronicLanguageId.newBuilder().apply { + combinedLanguageCode = "" + }.build() + }.build() + + val profile = + AndroidLocaleProfile.createFromMacaronicLanguage(languageWithMacaronic) + + // The provided language ID must have a macaronic ID defined. + assertThat(profile).isNull() + } + + @Test + fun testCreateProfileFromMacaronic_languageIdWithMalformedMacaronic_extraFields__returnsNull() { + val languageWithMacaronic = LanguageId.newBuilder().apply { + macaronicId = MacaronicLanguageId.newBuilder().apply { + combinedLanguageCode = "mal-form-ed" + }.build() + }.build() + + val profile = + AndroidLocaleProfile.createFromMacaronicLanguage(languageWithMacaronic) + + // The provided language ID must have a well-formed macaronic ID defined. + assertThat(profile).isNull() + } + + @Test + fun testCreateProfileFromMacaronic_languageId_malformedMacaronic_missingSecondLang_returnsNull() { + val languageWithMacaronic = LanguageId.newBuilder().apply { + macaronicId = MacaronicLanguageId.newBuilder().apply { + combinedLanguageCode = "hi" + }.build() + }.build() + + val profile = + AndroidLocaleProfile.createFromMacaronicLanguage(languageWithMacaronic) + + // The provided language ID must have a well-formed macaronic ID defined, that is, it must have + // two language parts defined. + assertThat(profile).isNull() + } + + @Test + fun testCreateProfileFromMacaronic_languageId_emptyMacaronicRegion_returnsNull() { + val languageWithMacaronic = LanguageId.newBuilder().apply { + macaronicId = MacaronicLanguageId.newBuilder().apply { + combinedLanguageCode = "hi-" + }.build() + }.build() + + val profile = + AndroidLocaleProfile.createFromMacaronicLanguage(languageWithMacaronic) + + // The macaronic ID has two parts as expected, but the second language ID must be filled in. + assertThat(profile).isNull() + } + + @Test + fun testCreateProfileFromMacaronic_languageIdWithValidMacaronic_returnsProfile() { + val languageWithMacaronic = LanguageId.newBuilder().apply { + macaronicId = MacaronicLanguageId.newBuilder().apply { + combinedLanguageCode = "hi-en" + }.build() + }.build() + + val profile = + AndroidLocaleProfile.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") + } + + /* Tests for matches() */ + + @Test + fun testMatchProfile_rootProfile_withItself_match() { + val profile = AndroidLocaleProfile.createFrom(Locale.ROOT) + + val matches = profile.matches(machineLocale, profile) + + assertThat(matches).isTrue() + } + + @Test + fun testMatchProfile_englishProfile_withItself_match() { + val profile = AndroidLocaleProfile.createFrom(Locale.ENGLISH) + + val matches = profile.matches(machineLocale, profile) + + assertThat(matches).isTrue() + } + + @Test + fun testMatchProfile_brazilianPortuguese_withItself_match() { + val profile = AndroidLocaleProfile.createFrom(brazilianPortugueseLocale) + + val matches = profile.matches(machineLocale, profile) + + assertThat(matches).isTrue() + } + + @Test + fun testMatchProfile_englishProfile_withItselfInDifferentCase_match() { + val englishProfileLowercase = AndroidLocaleProfile(languageCode = "en", regionCode = "") + val englishProfileUppercase = AndroidLocaleProfile(languageCode = "EN", regionCode = "") + + val matches = englishProfileLowercase.matches(machineLocale, englishProfileUppercase) + + assertThat(matches).isTrue() + } + + @Test + fun testMatchProfile_englishProfile_withItselfInDifferentCase_reversed_match() { + val englishProfileLowercase = AndroidLocaleProfile(languageCode = "en", regionCode = "") + val englishProfileUppercase = AndroidLocaleProfile(languageCode = "EN", regionCode = "") + + val matches = englishProfileUppercase.matches(machineLocale, englishProfileLowercase) + + assertThat(matches).isTrue() + } + + @Test + fun testMatchProfile_brazilianPortuguese_withItselfInDifferentCase_match() { + val brazilianPortugueseProfileLowercase = + AndroidLocaleProfile(languageCode = "pt", regionCode = "br") + val brazilianPortugueseProfileUppercase = + AndroidLocaleProfile(languageCode = "PT", regionCode = "BR") + + val matches = + brazilianPortugueseProfileLowercase.matches( + machineLocale, brazilianPortugueseProfileUppercase + ) + + assertThat(matches).isTrue() + } + + fun testMatchProfile_brazilianPortuguese_withItselfInDifferentCase_reversed_match() { + val brazilianPortugueseProfileLowercase = + AndroidLocaleProfile(languageCode = "pt", regionCode = "br") + val brazilianPortugueseProfileUppercase = + AndroidLocaleProfile(languageCode = "PT", regionCode = "BR") + + val matches = + brazilianPortugueseProfileUppercase.matches( + machineLocale, brazilianPortugueseProfileLowercase + ) + + assertThat(matches).isTrue() + } + + @Test + fun testMatchProfile_rootProfile_english_doNotMatch() { + val rootProfile = AndroidLocaleProfile.createFrom(Locale.ROOT) + val englishProfile = AndroidLocaleProfile.createFrom(Locale.ENGLISH) + + val matches = rootProfile.matches(machineLocale, englishProfile) + + assertThat(matches).isFalse() + } + + @Test + fun testMatchProfile_rootProfile_brazilianPortuguese_doNotMatch() { + val rootProfile = AndroidLocaleProfile.createFrom(Locale.ROOT) + val brazilianPortugueseProfile = AndroidLocaleProfile.createFrom(brazilianPortugueseLocale) + + val matches = rootProfile.matches(machineLocale, brazilianPortugueseProfile) + + assertThat(matches).isFalse() + } + + @Test + fun testMatchProfile_english_brazilianPortuguese_doNotMatch() { + val englishProfile = AndroidLocaleProfile.createFrom(Locale.ENGLISH) + val brazilianPortugueseProfile = AndroidLocaleProfile.createFrom(brazilianPortugueseLocale) + + val matches = englishProfile.matches(machineLocale, brazilianPortugueseProfile) + + assertThat(matches).isFalse() + } + + @Test + fun testMatchProfile_rootProfile_englishWithWildcard_doNotMatch() { + val rootProfile = AndroidLocaleProfile.createFrom(Locale.ROOT) + val englishWithWildcardProfile = createProfileWithWildcard(languageCode = "en") + + val matches = rootProfile.matches(machineLocale, englishWithWildcardProfile) + + assertThat(matches).isFalse() + } + + @Test + fun testMatchProfile_rootProfile_rootProfileWithWildcard_match() { + val rootProfile = AndroidLocaleProfile.createFrom(Locale.ROOT) + val rootProfileWithWildcardProfile = createProfileWithWildcard(languageCode = "") + + val matches = rootProfile.matches(machineLocale, rootProfileWithWildcardProfile) + + assertThat(matches).isTrue() + } + + @Test + fun testMatchProfile_rootProfileWithWildcard_rootProfile_match() { + val rootProfile = AndroidLocaleProfile.createFrom(Locale.ROOT) + val rootProfileWithWildcardProfile = createProfileWithWildcard(languageCode = "") + + val matches = rootProfileWithWildcardProfile.matches(machineLocale, rootProfile) + + assertThat(matches).isTrue() + } + + @Test + fun testMatchProfile_englishProfile_rootProfileWithWildcard_doNotMatch() { + val englishProfile = AndroidLocaleProfile.createFrom(Locale.ENGLISH) + val rootProfileWithWildcardProfile = createProfileWithWildcard(languageCode = "") + + val matches = englishProfile.matches(machineLocale, rootProfileWithWildcardProfile) + + assertThat(matches).isFalse() + } + + @Test + fun testMatchProfile_englishWithWildcard_brazilianPortuguese_doNotMatch() { + val englishWithWildcardProfile = createProfileWithWildcard(languageCode = "en") + val brazilianPortugueseProfile = AndroidLocaleProfile.createFrom(brazilianPortugueseLocale) + + val matches = englishWithWildcardProfile.matches(machineLocale, brazilianPortugueseProfile) + + assertThat(matches).isFalse() + } + + @Test + fun testMatchProfile_brazilianPortuguese_portuguese_doNotMatch() { + val portugueseProfile = AndroidLocaleProfile.createFrom(portugueseLocale) + val brazilianPortugueseProfile = AndroidLocaleProfile.createFrom(brazilianPortugueseLocale) + + val matches = portugueseProfile.matches(machineLocale, brazilianPortugueseProfile) + + assertThat(matches).isFalse() + } + + @Test + fun testMatchProfile_brazilianPortuguese_portugueseWithWildcard_match() { + val portugueseWithWildcardProfile = createProfileWithWildcard(languageCode = "pt") + val brazilianPortugueseProfile = AndroidLocaleProfile.createFrom(brazilianPortugueseLocale) + + val matches = brazilianPortugueseProfile.matches(machineLocale, portugueseWithWildcardProfile) + + assertThat(matches).isTrue() + } + + @Test + fun testMatchProfile_portugueseWithWildcard_brazilianPortuguese_match() { + val portugueseWithWildcardProfile = createProfileWithWildcard(languageCode = "pt") + val brazilianPortugueseProfile = AndroidLocaleProfile.createFrom(brazilianPortugueseLocale) + + val matches = portugueseWithWildcardProfile.matches(machineLocale, brazilianPortugueseProfile) + + assertThat(matches).isTrue() + } + + private fun createProfileWithWildcard(languageCode: String): AndroidLocaleProfile = + AndroidLocaleProfile(languageCode, regionCode = AndroidLocaleProfile.REGION_WILDCARD) + + private fun setUpTestApplicationComponent() { + DaggerAndroidLocaleProfileTest_TestApplicationComponent.builder() + .setApplication(ApplicationProvider.getApplicationContext()) + .build() + .inject(this) + } + + // TODO(#89): Move this to a common test application component. + @Module + class TestModule { + @Provides + @Singleton + fun provideContext(application: Application): Context { + return application + } + } + + // TODO(#89): Move this to a common test application component. + @Singleton + @Component( + modules = [ + TestModule::class, LocaleProdModule::class, FakeOppiaClockModule::class + ] + ) + interface TestApplicationComponent { + @Component.Builder + interface Builder { + @BindsInstance + fun setApplication(application: Application): Builder + + fun build(): TestApplicationComponent + } + + fun inject(androidLocaleProfileTest: AndroidLocaleProfileTest) + } + + private companion object { + private val REGION_INDIA = RegionSupportDefinition.newBuilder().apply { + region = OppiaRegion.INDIA + regionId = IetfBcp47RegionId.newBuilder().apply { + ietfRegionTag = "IN" + }.build() + }.build() + } +} 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 new file mode 100644 index 00000000000..43c6ea60c0c --- /dev/null +++ b/utility/src/test/java/org/oppia/android/util/locale/BUILD.bazel @@ -0,0 +1,100 @@ +""" +Tests for language & locale utilities. +""" + +load("@dagger//:workspace_defs.bzl", "dagger_rules") +load("//:oppia_android_test.bzl", "oppia_android_test") + +oppia_android_test( + name = "AndroidLocaleProfileTest", + srcs = ["AndroidLocaleProfileTest.kt"], + custom_package = "org.oppia.android.util.locale", + test_class = "org.oppia.android.util.locale.AndroidLocaleProfileTest", + test_manifest = "//utility:test_manifest", + deps = [ + ":dagger", + "//testing/src/main/java/org/oppia/android/testing/time:test_module", + "//third_party:androidx_test_ext_junit", + "//third_party:com_google_truth_truth", + "//third_party:junit_junit", + "//third_party:org_robolectric_robolectric", + "//third_party:robolectric_android-all", + "//utility/src/main/java/org/oppia/android/util/locale:android_locale_profile", + "//utility/src/main/java/org/oppia/android/util/locale:prod_module", + ], +) + +oppia_android_test( + name = "LocaleProdModuleTest", + srcs = ["LocaleProdModuleTest.kt"], + custom_package = "org.oppia.android.util.locale", + test_class = "org.oppia.android.util.locale.LocaleProdModuleTest", + test_manifest = "//utility:test_manifest", + deps = [ + ":dagger", + "//testing/src/main/java/org/oppia/android/testing/time:test_module", + "//third_party:androidx_test_ext_junit", + "//third_party:com_google_truth_truth", + "//third_party:junit_junit", + "//third_party:org_robolectric_robolectric", + "//third_party:robolectric_android-all", + "//utility/src/main/java/org/oppia/android/util/locale:prod_module", + ], +) + +oppia_android_test( + name = "MachineLocaleImplTest", + srcs = ["MachineLocaleImplTest.kt"], + custom_package = "org.oppia.android.util.locale", + test_class = "org.oppia.android.util.locale.MachineLocaleImplTest", + test_manifest = "//utility:test_manifest", + deps = [ + ":dagger", + "//testing/src/main/java/org/oppia/android/testing/time:test_module", + "//third_party:androidx_test_ext_junit", + "//third_party:com_google_truth_truth", + "//third_party:junit_junit", + "//third_party:org_robolectric_robolectric", + "//third_party:robolectric_android-all", + "//utility/src/main/java/org/oppia/android/util/locale:prod_module", + ], +) + +oppia_android_test( + name = "OppiaBidiFormatterImplTest", + srcs = ["OppiaBidiFormatterImplTest.kt"], + custom_package = "org.oppia.android.util.locale", + test_class = "org.oppia.android.util.locale.OppiaBidiFormatterImplTest", + test_manifest = "//utility:test_manifest", + deps = [ + ":dagger", + "//testing/src/main/java/org/oppia/android/testing/robolectric:shadow_bidi_formatter", + "//third_party:androidx_test_ext_junit", + "//third_party:com_google_truth_truth", + "//third_party:junit_junit", + "//third_party:org_robolectric_robolectric", + "//third_party:robolectric_android-all", + "//utility/src/main/java/org/oppia/android/util/locale:oppia_bidi_formatter", + "//utility/src/main/java/org/oppia/android/util/locale:prod_module", + ], +) + +oppia_android_test( + name = "OppiaLocaleContextExtensionsTest", + srcs = ["OppiaLocaleContextExtensionsTest.kt"], + custom_package = "org.oppia.android.util.locale", + test_class = "org.oppia.android.util.locale.OppiaLocaleContextExtensionsTest", + test_manifest = "//utility:test_manifest", + deps = [ + ":dagger", + "//model:languages_java_proto_lite", + "//third_party:androidx_test_ext_junit", + "//third_party:com_google_truth_extensions_truth-liteproto-extension", + "//third_party:junit_junit", + "//third_party:org_robolectric_robolectric", + "//third_party:robolectric_android-all", + "//utility/src/main/java/org/oppia/android/util/locale:oppia_locale", + ], +) + +dagger_rules() diff --git a/utility/src/test/java/org/oppia/android/util/locale/LocaleProdModuleTest.kt b/utility/src/test/java/org/oppia/android/util/locale/LocaleProdModuleTest.kt new file mode 100644 index 00000000000..33cb92ef7cb --- /dev/null +++ b/utility/src/test/java/org/oppia/android/util/locale/LocaleProdModuleTest.kt @@ -0,0 +1,84 @@ +package org.oppia.android.util.locale + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import dagger.BindsInstance +import dagger.Component +import dagger.Module +import dagger.Provides +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.oppia.android.testing.time.FakeOppiaClockModule +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import javax.inject.Inject +import javax.inject.Singleton + +/** Tests for [LocaleProdModule]. */ +// FunctionName: test names are conventionally named with underscores. +@Suppress("FunctionName") +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +@Config(manifest = Config.NONE) +class LocaleProdModuleTest { + @Inject + lateinit var machineLocale: OppiaLocale.MachineLocale + + @Inject + lateinit var formatterFactory: OppiaBidiFormatter.Factory + + @Before + fun setUp() { + setUpTestApplicationComponent() + } + + @Test + fun testModule_injectsProductionImplementationOfMachineLocale() { + assertThat(machineLocale).isInstanceOf(MachineLocaleImpl::class.java) + } + + @Test + fun testModule_injectsProductionImplementationOfBidiFormatterFactory() { + assertThat(formatterFactory).isInstanceOf(OppiaBidiFormatterImpl.FactoryImpl::class.java) + } + + private fun setUpTestApplicationComponent() { + DaggerLocaleProdModuleTest_TestApplicationComponent.builder() + .setApplication(ApplicationProvider.getApplicationContext()) + .build() + .inject(this) + } + + // TODO(#89): Move this to a common test application component. + @Module + class TestModule { + @Provides + @Singleton + fun provideContext(application: Application): Context { + return application + } + } + + // TODO(#89): Move this to a common test application component. + @Singleton + @Component( + modules = [ + TestModule::class, LocaleProdModule::class, FakeOppiaClockModule::class + ] + ) + interface TestApplicationComponent { + @Component.Builder + interface Builder { + @BindsInstance + fun setApplication(application: Application): Builder + + fun build(): TestApplicationComponent + } + + fun inject(localeProdModuleTest: LocaleProdModuleTest) + } +} diff --git a/utility/src/test/java/org/oppia/android/util/locale/MachineLocaleImplTest.kt b/utility/src/test/java/org/oppia/android/util/locale/MachineLocaleImplTest.kt new file mode 100644 index 00000000000..888aa2cdc1c --- /dev/null +++ b/utility/src/test/java/org/oppia/android/util/locale/MachineLocaleImplTest.kt @@ -0,0 +1,308 @@ +package org.oppia.android.util.locale + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import dagger.BindsInstance +import dagger.Component +import dagger.Module +import dagger.Provides +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.oppia.android.testing.time.FakeOppiaClock +import org.oppia.android.testing.time.FakeOppiaClockModule +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import java.util.Locale +import javax.inject.Inject +import javax.inject.Singleton + +/** Tests for [MachineLocaleImpl]. */ +// FunctionName: test names are conventionally named with underscores. +@Suppress("FunctionName") +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +@Config(manifest = Config.NONE) +class MachineLocaleImplTest { + @Inject + lateinit var machineLocale: OppiaLocale.MachineLocale + + @Inject + lateinit var fakeOppiaClock: FakeOppiaClock + + @Before + fun setUp() { + setUpTestApplicationComponent() + fakeOppiaClock.setFakeTimeMode(FakeOppiaClock.FakeTimeMode.MODE_FIXED_FAKE_TIME) + } + + @Test + fun testFormatForMachines_formatStringWithArgs_returnsCorrectlyFormattedString() { + val formatted = machineLocale.run { "Test with %s and %d".formatForMachines("string", 11) } + + assertThat(formatted).isEqualTo("Test with string and 11") + } + + @Test + fun testToMachineLowerCase_lowerCaseString_returnsSameString() { + val formatted = machineLocale.run { "lowercase string".toMachineLowerCase() } + + assertThat(formatted).isEqualTo("lowercase string") + } + + @Test + fun testToMachineLowerCase_mixedCaseString_returnsLowerCaseString() { + val formatted = machineLocale.run { "MiXeD CaSe StriNG".toMachineLowerCase() } + + assertThat(formatted).isEqualTo("mixed case string") + } + + @Test + fun testToMachineUpperCase_upperCaseString_returnsSameString() { + val formatted = machineLocale.run { "UPPERCASE STRING".toMachineUpperCase() } + + assertThat(formatted).isEqualTo("UPPERCASE STRING") + } + + @Test + fun testToMachineUpperCase_mixedCaseString_returnsUpperCaseString() { + val formatted = machineLocale.run { "MiXeD CaSe StriNG".toMachineUpperCase() } + + assertThat(formatted).isEqualTo("MIXED CASE STRING") + } + + @Test + fun testToMachineLowerCase_englishLocale_localeSensitiveChar_returnsConvertedCase() { + Locale.setDefault(Locale.ENGLISH) + + val formatted = machineLocale.run { "TITLE".toMachineLowerCase() } + + assertThat(formatted).isEqualTo("title") + } + + @Test + fun testToMachineLowerCase_turkishLocale_localeSensitiveChar_returnsSameConversion() { + Locale.setDefault(Locale("tr", "tr")) + + val formatted = machineLocale.run { "TITLE".toMachineLowerCase() } + + // See https://stackoverflow.com/a/11063161 for context on why this is correct behavior. The + // machine locale guarantees a consistent lowercase experience regardless of the current locale + // default (as comparable with the display locale which will perform case changing based on the + // locale). + assertThat(formatted).isEqualTo("title") + } + + @Test + fun testCapitalizeForMachines_capitalizedString_returnsSameString() { + val formatted = machineLocale.run { "Capital".capitalizeForMachines() } + + assertThat(formatted).isEqualTo("Capital") + } + + @Test + fun testCapitalizeForMachines_uncapitalizedString_returnsCapitalized() { + val formatted = machineLocale.run { "uncapital".capitalizeForMachines() } + + assertThat(formatted).isEqualTo("Uncapital") + } + + @Test + fun testDecapitalizeForMachines_decapitalizedString_returnsSameString() { + val formatted = machineLocale.run { "uncapital".decapitalizeForMachines() } + + assertThat(formatted).isEqualTo("uncapital") + } + + @Test + fun testDecapitalizeForMachines_capitalizedString_returnsDecapitalized() { + val formatted = machineLocale.run { "Capital".decapitalizeForMachines() } + + assertThat(formatted).isEqualTo("capital") + } + + @Test + fun testEndsWithIgnoreCase_stringWithoutSuffix_returnsFalse() { + val matches = machineLocale.run { "string".endsWithIgnoreCase("aw") } + + assertThat(matches).isFalse() + } + + @Test + fun testEndsWithIgnoreCase_stringWithSuffix_matchingCases_returnsTrue() { + val matches = machineLocale.run { "straw".endsWithIgnoreCase("aw") } + + assertThat(matches).isTrue() + } + + @Test + fun testEndsWithIgnoreCase_stringWithSuffix_differingCases_returnsTrue() { + val matches = machineLocale.run { "STrAw".endsWithIgnoreCase("aW") } + + assertThat(matches).isTrue() + } + + @Test + fun testEqualsIgnoreCase_differentString_returnsFalse() { + val matches = machineLocale.run { "string".equalsIgnoreCase("other") } + + assertThat(matches).isFalse() + } + + @Test + fun testEqualsIgnoreCase_differentString_reversed_returnsFalse() { + val matches = machineLocale.run { "other".equalsIgnoreCase("string") } + + assertThat(matches).isFalse() + } + + @Test + fun testEqualsIgnoreCase_sameString_matchingCases_returnsTrue() { + val matches = machineLocale.run { "string".equalsIgnoreCase("string") } + + assertThat(matches).isTrue() + } + + @Test + fun testEqualsIgnoreCase_sameString_differingCases_returnsTrue() { + val matches = machineLocale.run { "sTRInG".equalsIgnoreCase("StrINg") } + + assertThat(matches).isTrue() + } + + @Test + fun testEqualsIgnoreCase_sameString_differingCases_reversed_returnsTrue() { + val matches = machineLocale.run { "StrINg".equalsIgnoreCase("sTRInG") } + + assertThat(matches).isTrue() + } + + @Test + fun testGetCurrentTimeOfDay_inMorningHour_returnsMorning() { + fakeOppiaClock.setCurrentTimeMs(MORNING_UTC_TIMESTAMP_MILLIS) + + val timeOfDay = machineLocale.getCurrentTimeOfDay() + + assertThat(timeOfDay).isEqualTo(OppiaLocale.MachineLocale.TimeOfDay.MORNING) + } + + @Test + fun testGetCurrentTimeOfDay_inAfternoonHour_returnsAfternoon() { + fakeOppiaClock.setCurrentTimeMs(AFTERNOON_UTC_TIMESTAMP_MILLIS) + + val timeOfDay = machineLocale.getCurrentTimeOfDay() + + assertThat(timeOfDay).isEqualTo(OppiaLocale.MachineLocale.TimeOfDay.AFTERNOON) + } + + @Test + fun testGetCurrentTimeOfDay_inEveningHour_returnsEvening() { + fakeOppiaClock.setCurrentTimeMs(EVENING_UTC_TIMESTAMP_MILLIS) + + val timeOfDay = machineLocale.getCurrentTimeOfDay() + + assertThat(timeOfDay).isEqualTo(OppiaLocale.MachineLocale.TimeOfDay.EVENING) + } + + @Test + fun testParseOppiaDate_emptyString_returnsNull() { + fakeOppiaClock.setCurrentTimeMs(MORNING_UTC_TIMESTAMP_MILLIS) + + val date = machineLocale.parseOppiaDate(dateString = "") + + assertThat(date).isNull() + } + + @Test + fun testParseOppiaDate_invalidDateFormat_returnsNull() { + val date = machineLocale.parseOppiaDate(dateString = "24 April 2019") + + // The date is in an invalid/unexpected format. + assertThat(date).isNull() + } + + @Test + fun testParseOppiaDate_validDateFormat_returnsDateObject() { + val date = machineLocale.parseOppiaDate(dateString = "2019-04-24") + + assertThat(date).isNotNull() + } + + @Test + fun testOppiaDateIsBeforeToday_validDateObject_forYesterday_returnsTrue() { + fakeOppiaClock.setCurrentTimeMs(MORNING_UTC_TIMESTAMP_MILLIS) + + val date = machineLocale.parseOppiaDate(dateString = "2019-04-23") + + assertThat(date?.isBeforeToday()).isTrue() + } + + @Test + fun testOppiaDateIsBeforeToday_validDateObject_forTomorrow_returnsFalse() { + fakeOppiaClock.setCurrentTimeMs(MORNING_UTC_TIMESTAMP_MILLIS) + + val date = machineLocale.parseOppiaDate(dateString = "2019-04-25") + + assertThat(date?.isBeforeToday()).isFalse() + } + + @Test + fun testComputeCurrentTimeString_forFixedTime_returnsHourMinuteSecondParts() { + fakeOppiaClock.setCurrentTimeMs(MORNING_UTC_TIMESTAMP_MILLIS) + + val timeString = machineLocale.computeCurrentTimeString() + + // Verify that the individual hour, minute, and second components are present (though don't + // actually verify formatting since that's implementation specific). + val numbers = "\\d+".toRegex().findAll(timeString).flatMap { it.groupValues }.toList() + assertThat(numbers).containsExactly("8", "22", "03") + } + + private fun setUpTestApplicationComponent() { + DaggerMachineLocaleImplTest_TestApplicationComponent.builder() + .setApplication(ApplicationProvider.getApplicationContext()) + .build() + .inject(this) + } + + // TODO(#89): Move this to a common test application component. + @Module + class TestModule { + @Provides + @Singleton + fun provideContext(application: Application): Context { + return application + } + } + + // TODO(#89): Move this to a common test application component. + @Singleton + @Component( + modules = [ + TestModule::class, LocaleProdModule::class, FakeOppiaClockModule::class + ] + ) + interface TestApplicationComponent { + @Component.Builder + interface Builder { + @BindsInstance + fun setApplication(application: Application): Builder + + fun build(): TestApplicationComponent + } + + fun inject(machineLocaleImplTest: MachineLocaleImplTest) + } + + private companion object { + // Date & time: Wed Apr 24 2019 08:22:03 GMT. + private const val MORNING_UTC_TIMESTAMP_MILLIS = 1556094123000 + // Date & time: Tue Apr 23 2019 14:22:00 GMT. + private const val AFTERNOON_UTC_TIMESTAMP_MILLIS = 1556029320000 + // Date & time: Tue Apr 23 2019 23:22:00 GMT. + private const val EVENING_UTC_TIMESTAMP_MILLIS = 1556061720000 + } +} diff --git a/utility/src/test/java/org/oppia/android/util/locale/OppiaBidiFormatterImplTest.kt b/utility/src/test/java/org/oppia/android/util/locale/OppiaBidiFormatterImplTest.kt new file mode 100644 index 00000000000..d8b5c05dbf5 --- /dev/null +++ b/utility/src/test/java/org/oppia/android/util/locale/OppiaBidiFormatterImplTest.kt @@ -0,0 +1,162 @@ +package org.oppia.android.util.locale + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import dagger.BindsInstance +import dagger.Component +import dagger.Module +import dagger.Provides +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.oppia.android.testing.robolectric.ShadowBidiFormatter +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import java.util.Locale +import javax.inject.Inject +import javax.inject.Singleton + +/** Tests for [OppiaBidiFormatterImpl]. */ +// FunctionName: test names are conventionally named with underscores. +@Suppress("FunctionName") +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +@Config(manifest = Config.NONE, shadows = [ShadowBidiFormatter::class]) +class OppiaBidiFormatterImplTest { + @Inject + lateinit var formatterFactory: OppiaBidiFormatter.Factory + + @Before + fun setUp() { + setUpTestApplicationComponent() + } + + @After + fun tearDown() { + // Make sure this is reset between tests. + ShadowBidiFormatter.reset() + } + + @Test + fun testInjectFactory_noFormattersCreator_hasZeroTrackedFormatters() { + assertThat(ShadowBidiFormatter.lookUpFormatters()).isEmpty() + assertThat(ShadowBidiFormatter.lookUpFormatter(Locale.US)).isNull() + } + + @Test + fun testFactory_rootLocale_doNothingWithFormatter_doesNotCreateNewBidiFormatter() { + formatterFactory.createFormatter(Locale.ROOT) + + // The wrapper only creates the formatter when it's needed. + assertThat(ShadowBidiFormatter.lookUpFormatter(Locale.ROOT)).isNull() + } + + @Test + fun testFactory_rootLocale_createsNewFormatterForRootLocale() { + // Some text needs to be wrapped to guarantee the formatter was created. + formatterFactory.createFormatter(Locale.ROOT).wrapText("") + + assertThat(ShadowBidiFormatter.lookUpFormatter(Locale.ROOT)).isNotNull() + } + + @Test + fun testFactory_usLocale_createsNewFormatterForUsLocale() { + // Some text needs to be wrapped to guarantee the formatter was created. + formatterFactory.createFormatter(Locale.US).wrapText("") + + assertThat(ShadowBidiFormatter.lookUpFormatter(Locale.US)).isNotNull() + } + + @Test + fun testFactory_multipleLocales_createsFormatterForEach() { + formatterFactory.createFormatter(INDIA_HINDI_LOCALE).wrapText("") + formatterFactory.createFormatter(BRAZIL_PORTUGUESE_LOCALE).wrapText("") + formatterFactory.createFormatter(Locale.US).wrapText("") + + // Note that this more verifies the shadow since Android caches the actual bidi formatter + // instance internally. + assertThat(ShadowBidiFormatter.lookUpFormatter(INDIA_HINDI_LOCALE)).isNotNull() + assertThat(ShadowBidiFormatter.lookUpFormatter(BRAZIL_PORTUGUESE_LOCALE)).isNotNull() + assertThat(ShadowBidiFormatter.lookUpFormatter(Locale.US)).isNotNull() + } + + @Test + fun testFormatter_wrapString_indiaLocale_callsFormatterUnicodeWrap() { + val formatter = formatterFactory.createFormatter(INDIA_HINDI_LOCALE) + + formatter.wrapText("test str") + + val shadow = ShadowBidiFormatter.lookUpFormatter(INDIA_HINDI_LOCALE) + assertThat(shadow?.getLastWrappedSequence()).isEqualTo("test str") + } + + @Test + fun testFormatter_wrapString_rtlLocale_doesNotCallFormatterUnicodeWrapForLtrLocale() { + val ltrFormatter = formatterFactory.createFormatter(INDIA_HINDI_LOCALE) + val rtlFormatter = formatterFactory.createFormatter(EGYPT_ARABIC_LOCALE) + + ltrFormatter.wrapText("test LTR string") + rtlFormatter.wrapText("test RTL string (sort of)") + + val shadow = ShadowBidiFormatter.lookUpFormatter(INDIA_HINDI_LOCALE) + assertThat(shadow?.getLastWrappedSequence()).isEqualTo("test LTR string") + } + + @Test + fun testFormatter_wrapMultipleString_brazilLocale_callsUnicodeWrapForEach() { + val formatter = formatterFactory.createFormatter(INDIA_HINDI_LOCALE) + + formatter.wrapText("test str one") + formatter.wrapText("test str two") + + val shadow = ShadowBidiFormatter.lookUpFormatter(INDIA_HINDI_LOCALE) + assertThat(shadow?.getAllWrappedSequences()) + .containsExactly("test str one", "test str two") + } + + private fun setUpTestApplicationComponent() { + DaggerOppiaBidiFormatterImplTest_TestApplicationComponent.builder() + .setApplication(ApplicationProvider.getApplicationContext()) + .build() + .inject(this) + } + + // TODO(#89): Move this to a common test application component. + @Module + class TestModule { + @Provides + @Singleton + fun provideContext(application: Application): Context { + return application + } + } + + // TODO(#89): Move this to a common test application component. + @Singleton + @Component( + modules = [ + TestModule::class, LocaleProdModule::class + ] + ) + interface TestApplicationComponent { + @Component.Builder + interface Builder { + @BindsInstance + fun setApplication(application: Application): Builder + + fun build(): TestApplicationComponent + } + + fun inject(oppiaBidiFormatterImplTest: OppiaBidiFormatterImplTest) + } + + private companion object { + private val INDIA_HINDI_LOCALE = Locale("hi", "IN") + private val BRAZIL_PORTUGUESE_LOCALE = Locale("pt", "BR") + private val EGYPT_ARABIC_LOCALE = Locale("ar", "EG") + } +} diff --git a/utility/src/test/java/org/oppia/android/util/locale/OppiaLocaleContextExtensionsTest.kt b/utility/src/test/java/org/oppia/android/util/locale/OppiaLocaleContextExtensionsTest.kt new file mode 100644 index 00000000000..c14734a7e43 --- /dev/null +++ b/utility/src/test/java/org/oppia/android/util/locale/OppiaLocaleContextExtensionsTest.kt @@ -0,0 +1,650 @@ +package org.oppia.android.util.locale + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.extensions.proto.LiteProtoTruth.assertThat +import dagger.BindsInstance +import dagger.Component +import dagger.Module +import dagger.Provides +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.oppia.android.app.model.LanguageSupportDefinition +import org.oppia.android.app.model.LanguageSupportDefinition.LanguageId +import org.oppia.android.app.model.OppiaLanguage +import org.oppia.android.app.model.OppiaLocaleContext +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import javax.inject.Singleton + +/** Tests for [OppiaLocaleContext] extensions. */ +// FunctionName: test names are conventionally named with underscores. +// SameParameterValue: tests should have specific context included/excluded for readability. +@Suppress("FunctionName", "SameParameterValue") +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +@Config(manifest = Config.NONE) +class OppiaLocaleContextExtensionsTest { + @Before + fun setUp() { + setUpTestApplicationComponent() + } + + /* Tests for getLanguageId() */ + + @Test + fun testGetLanguageId_defaultInstance_returnsDefaultInstance() { + val localeContext = OppiaLocaleContext.getDefaultInstance() + + val languageId = localeContext.getLanguageId() + + assertThat(languageId).isEqualToDefaultInstance() + } + + @Test + fun testGetLanguageId_appStringUsage_noAppStringId_returnsDefaultInstance() { + val localeContext = createContextWithoutLanguageDefinition( + usageMode = OppiaLocaleContext.LanguageUsageMode.APP_STRINGS + ) + + val languageId = localeContext.getLanguageId() + + assertThat(languageId).isEqualToDefaultInstance() + } + + @Test + fun testGetLanguageId_appStringUsage_definedAppStringId_returnsAppStringId() { + val localeContext = createAppStringsContext( + language = OppiaLanguage.ENGLISH, + appStringId = ENGLISH_LANGUAGE_ID + ) + + val languageId = localeContext.getLanguageId() + + assertThat(languageId).isEqualTo(ENGLISH_LANGUAGE_ID) + } + + @Test + fun testGetLanguageId_contentStringUsage_noContentStringId_returnsDefaultInstance() { + val localeContext = createContextWithoutLanguageDefinition( + usageMode = OppiaLocaleContext.LanguageUsageMode.CONTENT_STRINGS + ) + + val languageId = localeContext.getLanguageId() + + assertThat(languageId).isEqualToDefaultInstance() + } + + @Test + fun testGetLanguageId_contentStringUsage_definedContentStringId_returnsContentId() { + val localeContext = createContentStringsContext( + language = OppiaLanguage.ENGLISH, + contentStringId = ENGLISH_LANGUAGE_ID + ) + + val languageId = localeContext.getLanguageId() + + assertThat(languageId).isEqualTo(ENGLISH_LANGUAGE_ID) + } + + @Test + fun testGetLanguageId_audioSubsUsage_noAudioSubId_returnsDefaultInstance() { + val localeContext = createContextWithoutLanguageDefinition( + usageMode = OppiaLocaleContext.LanguageUsageMode.AUDIO_TRANSLATIONS + ) + + val languageId = localeContext.getLanguageId() + + assertThat(languageId).isEqualToDefaultInstance() + } + + @Test + fun testGetLanguageId_audioSubsUsage_definedAudioSubId_returnsAudioSubId() { + val localeContext = createAudioSubContext( + language = OppiaLanguage.ENGLISH, + audioTranslationId = ENGLISH_LANGUAGE_ID + ) + + val languageId = localeContext.getLanguageId() + + assertThat(languageId).isEqualTo(ENGLISH_LANGUAGE_ID) + } + + @Test + fun testGetLanguageId_fullDefinition_appStringUsage_returnsAppStringId() { + val localeContext = createCompleteLocaleContext( + usageMode = OppiaLocaleContext.LanguageUsageMode.APP_STRINGS, + language = OppiaLanguage.ENGLISH, + appStringId = ENGLISH_LANGUAGE_ID, + contentStringId = HINGLISH_LANGUAGE_ID, + audioTranslationId = BRAZILIAN_PORTUGUESE_LANGUAGE_ID + ) + + val languageId = localeContext.getLanguageId() + + assertThat(languageId).isEqualTo(ENGLISH_LANGUAGE_ID) + } + + @Test + fun testGetLanguageId_fullDefinition_contentStringUsage_returnsContentStringId() { + val localeContext = createCompleteLocaleContext( + usageMode = OppiaLocaleContext.LanguageUsageMode.CONTENT_STRINGS, + language = OppiaLanguage.ENGLISH, + appStringId = ENGLISH_LANGUAGE_ID, + contentStringId = HINGLISH_LANGUAGE_ID, + audioTranslationId = BRAZILIAN_PORTUGUESE_LANGUAGE_ID + ) + + val languageId = localeContext.getLanguageId() + + assertThat(languageId).isEqualTo(HINGLISH_LANGUAGE_ID) + } + + @Test + fun testGetLanguageId_fullDefinition_audioSubUsage_returnsAudioSubId() { + val localeContext = createCompleteLocaleContext( + usageMode = OppiaLocaleContext.LanguageUsageMode.AUDIO_TRANSLATIONS, + language = OppiaLanguage.ENGLISH, + appStringId = ENGLISH_LANGUAGE_ID, + contentStringId = HINGLISH_LANGUAGE_ID, + audioTranslationId = BRAZILIAN_PORTUGUESE_LANGUAGE_ID + ) + + val languageId = localeContext.getLanguageId() + + assertThat(languageId).isEqualTo(BRAZILIAN_PORTUGUESE_LANGUAGE_ID) + } + + @Test + fun testGetLanguageId_fullDefinition_unspecifiedUsage_returnsDefaultInstance() { + val localeContext = createCompleteLocaleContext( + usageMode = OppiaLocaleContext.LanguageUsageMode.USAGE_MODE_UNSPECIFIED, + language = OppiaLanguage.ENGLISH, + appStringId = ENGLISH_LANGUAGE_ID, + contentStringId = HINGLISH_LANGUAGE_ID, + audioTranslationId = BRAZILIAN_PORTUGUESE_LANGUAGE_ID + ) + + val languageId = localeContext.getLanguageId() + + assertThat(languageId).isEqualToDefaultInstance() + } + + @Test + fun testGetLanguageId_fullDefinitionWithFallback_appStringUsage_returnsAppStringId() { + val localeContext = createCompleteLocaleWithFallbackContext( + usageMode = OppiaLocaleContext.LanguageUsageMode.APP_STRINGS, + language = OppiaLanguage.ENGLISH, + appStringId = ENGLISH_LANGUAGE_ID, + contentStringId = HINGLISH_LANGUAGE_ID, + audioTranslationId = BRAZILIAN_PORTUGUESE_LANGUAGE_ID, + fallbackAppStringId = QQ_LANGUAGE_ID, + fallbackContentStringId = ZZ_LANGUAGE_ID, + fallbackAudioTranslationId = FAKE_LANGUAGE_ID + ) + + val languageId = localeContext.getLanguageId() + + // The fallback language should be ignored. + assertThat(languageId).isEqualTo(ENGLISH_LANGUAGE_ID) + } + + @Test + fun testGetLanguageId_fullDefinitionWithFallback_contentStringUsage_returnsContentStringId() { + val localeContext = createCompleteLocaleWithFallbackContext( + usageMode = OppiaLocaleContext.LanguageUsageMode.CONTENT_STRINGS, + language = OppiaLanguage.ENGLISH, + appStringId = ENGLISH_LANGUAGE_ID, + contentStringId = HINGLISH_LANGUAGE_ID, + audioTranslationId = BRAZILIAN_PORTUGUESE_LANGUAGE_ID, + fallbackAppStringId = QQ_LANGUAGE_ID, + fallbackContentStringId = ZZ_LANGUAGE_ID, + fallbackAudioTranslationId = FAKE_LANGUAGE_ID + ) + + val languageId = localeContext.getLanguageId() + + // The fallback language should be ignored. + assertThat(languageId).isEqualTo(HINGLISH_LANGUAGE_ID) + } + + @Test + fun testGetLanguageId_fullDefinitionWithFallback_audioSubUsage_returnsAudioSubId() { + val localeContext = createCompleteLocaleWithFallbackContext( + usageMode = OppiaLocaleContext.LanguageUsageMode.AUDIO_TRANSLATIONS, + language = OppiaLanguage.ENGLISH, + appStringId = ENGLISH_LANGUAGE_ID, + contentStringId = HINGLISH_LANGUAGE_ID, + audioTranslationId = BRAZILIAN_PORTUGUESE_LANGUAGE_ID, + fallbackAppStringId = QQ_LANGUAGE_ID, + fallbackContentStringId = ZZ_LANGUAGE_ID, + fallbackAudioTranslationId = FAKE_LANGUAGE_ID + ) + + val languageId = localeContext.getLanguageId() + + // The fallback language should be ignored. + assertThat(languageId).isEqualTo(BRAZILIAN_PORTUGUESE_LANGUAGE_ID) + } + + /* Tests for getFallbackLanguageId() */ + + @Test + fun testGetFallbackId_defaultInstance_returnsDefaultInstance() { + val localeContext = OppiaLocaleContext.getDefaultInstance() + + val fallbackLanguageId = localeContext.getFallbackLanguageId() + + assertThat(fallbackLanguageId).isEqualToDefaultInstance() + } + + @Test + fun testGetFallbackId_appStringUsage_noAppStringId_returnsDefaultInstance() { + val localeContext = createContextWithoutLanguageDefinition( + usageMode = OppiaLocaleContext.LanguageUsageMode.APP_STRINGS + ) + + val fallbackLanguageId = localeContext.getFallbackLanguageId() + + assertThat(fallbackLanguageId).isEqualToDefaultInstance() + } + + @Test + fun testGetFallbackId_appStringUsage_definedFallbackAppStringId_returnsAppStringId() { + val localeContext = createAppStringsWithFallbackContext( + language = OppiaLanguage.ENGLISH, + appStringId = QQ_LANGUAGE_ID, + fallbackStringId = ENGLISH_LANGUAGE_ID + ) + + val fallbackLanguageId = localeContext.getFallbackLanguageId() + + // The fallback ID should be selected. + assertThat(fallbackLanguageId).isEqualTo(ENGLISH_LANGUAGE_ID) + } + + @Test + fun testGetFallbackId_contentStringUsage_noContentStringId_returnsDefaultInstance() { + val localeContext = createContextWithoutLanguageDefinition( + usageMode = OppiaLocaleContext.LanguageUsageMode.CONTENT_STRINGS + ) + + val fallbackLanguageId = localeContext.getFallbackLanguageId() + + assertThat(fallbackLanguageId).isEqualToDefaultInstance() + } + + @Test + fun testGetFallbackId_contentStringUsage_definedFallbackContentStringId_returnsContentId() { + val localeContext = createContentStringsWithFallbackContext( + language = OppiaLanguage.ENGLISH, + contentStringId = QQ_LANGUAGE_ID, + fallbackContentStringId = ENGLISH_LANGUAGE_ID + ) + + val fallbackLanguageId = localeContext.getFallbackLanguageId() + + // The fallback ID should be selected. + assertThat(fallbackLanguageId).isEqualTo(ENGLISH_LANGUAGE_ID) + } + + @Test + fun testGetFallbackId_audioSubsUsage_noAudioSubId_returnsDefaultInstance() { + val localeContext = createContextWithoutLanguageDefinition( + usageMode = OppiaLocaleContext.LanguageUsageMode.AUDIO_TRANSLATIONS + ) + + val fallbackLanguageId = localeContext.getFallbackLanguageId() + + assertThat(fallbackLanguageId).isEqualToDefaultInstance() + } + + @Test + fun testGetFallbackId_audioSubsUsage_definedFallbackAudioSubId_returnsAudioSubId() { + val localeContext = createAudioSubWithFallbackContext( + language = OppiaLanguage.ENGLISH, + audioTranslationId = QQ_LANGUAGE_ID, + fallbackAudioTranslationId = ENGLISH_LANGUAGE_ID + ) + + val fallbackLanguageId = localeContext.getFallbackLanguageId() + + // The fallback ID should be selected. + assertThat(fallbackLanguageId).isEqualTo(ENGLISH_LANGUAGE_ID) + } + + @Test + fun testGetFallbackId_fullDefinition_appStringUsage_returnsAppStringId() { + val localeContext = createCompleteLocaleWithFallbackContext( + usageMode = OppiaLocaleContext.LanguageUsageMode.APP_STRINGS, + language = OppiaLanguage.ENGLISH, + appStringId = QQ_LANGUAGE_ID, + contentStringId = ZZ_LANGUAGE_ID, + audioTranslationId = FAKE_LANGUAGE_ID, + fallbackAppStringId = ENGLISH_LANGUAGE_ID, + fallbackContentStringId = HINGLISH_LANGUAGE_ID, + fallbackAudioTranslationId = BRAZILIAN_PORTUGUESE_LANGUAGE_ID + ) + + val fallbackLanguageId = localeContext.getFallbackLanguageId() + + // The fallback ID should match the specified usage mode. + assertThat(fallbackLanguageId).isEqualTo(ENGLISH_LANGUAGE_ID) + } + + @Test + fun testGetFallbackId_fullDefinition_contentStringUsage_returnsContentStringId() { + val localeContext = createCompleteLocaleWithFallbackContext( + usageMode = OppiaLocaleContext.LanguageUsageMode.CONTENT_STRINGS, + language = OppiaLanguage.ENGLISH, + appStringId = QQ_LANGUAGE_ID, + contentStringId = ZZ_LANGUAGE_ID, + audioTranslationId = FAKE_LANGUAGE_ID, + fallbackAppStringId = ENGLISH_LANGUAGE_ID, + fallbackContentStringId = HINGLISH_LANGUAGE_ID, + fallbackAudioTranslationId = BRAZILIAN_PORTUGUESE_LANGUAGE_ID + ) + + val fallbackLanguageId = localeContext.getFallbackLanguageId() + + // The fallback ID should match the specified usage mode. + assertThat(fallbackLanguageId).isEqualTo(HINGLISH_LANGUAGE_ID) + } + + @Test + fun testGetFallbackId_fullDefinition_audioSubUsage_returnsAudioSubId() { + val localeContext = createCompleteLocaleWithFallbackContext( + usageMode = OppiaLocaleContext.LanguageUsageMode.AUDIO_TRANSLATIONS, + language = OppiaLanguage.ENGLISH, + appStringId = QQ_LANGUAGE_ID, + contentStringId = ZZ_LANGUAGE_ID, + audioTranslationId = FAKE_LANGUAGE_ID, + fallbackAppStringId = ENGLISH_LANGUAGE_ID, + fallbackContentStringId = HINGLISH_LANGUAGE_ID, + fallbackAudioTranslationId = BRAZILIAN_PORTUGUESE_LANGUAGE_ID + ) + + val fallbackLanguageId = localeContext.getFallbackLanguageId() + + // The fallback ID should match the specified usage mode. + assertThat(fallbackLanguageId).isEqualTo(BRAZILIAN_PORTUGUESE_LANGUAGE_ID) + } + + @Test + fun testGetFallbackId_fullDefinition_unspecifiedUsage_returnsDefaultInstance() { + val localeContext = createCompleteLocaleWithFallbackContext( + usageMode = OppiaLocaleContext.LanguageUsageMode.USAGE_MODE_UNSPECIFIED, + language = OppiaLanguage.ENGLISH, + appStringId = QQ_LANGUAGE_ID, + contentStringId = ZZ_LANGUAGE_ID, + audioTranslationId = FAKE_LANGUAGE_ID, + fallbackAppStringId = ENGLISH_LANGUAGE_ID, + fallbackContentStringId = HINGLISH_LANGUAGE_ID, + fallbackAudioTranslationId = BRAZILIAN_PORTUGUESE_LANGUAGE_ID + ) + + val fallbackLanguageId = localeContext.getFallbackLanguageId() + + assertThat(fallbackLanguageId).isEqualToDefaultInstance() + } + + @Test + fun testGetFallbackId_fullDefinitionNoFallback_appStringUsage_returnsDefaultInstance() { + val localeContext = createCompleteLocaleContext( + usageMode = OppiaLocaleContext.LanguageUsageMode.APP_STRINGS, + language = OppiaLanguage.ENGLISH, + appStringId = ENGLISH_LANGUAGE_ID, + contentStringId = HINGLISH_LANGUAGE_ID, + audioTranslationId = BRAZILIAN_PORTUGUESE_LANGUAGE_ID + ) + + val fallbackLanguageId = localeContext.getFallbackLanguageId() + + // No fallback is defined. + assertThat(fallbackLanguageId).isEqualToDefaultInstance() + } + + @Test + fun testGetFallbackId_fullDefinitionNoFallback_contentStringUsage_returnsDefaultInstance() { + val localeContext = createCompleteLocaleContext( + usageMode = OppiaLocaleContext.LanguageUsageMode.CONTENT_STRINGS, + language = OppiaLanguage.ENGLISH, + appStringId = ENGLISH_LANGUAGE_ID, + contentStringId = HINGLISH_LANGUAGE_ID, + audioTranslationId = BRAZILIAN_PORTUGUESE_LANGUAGE_ID + ) + + val fallbackLanguageId = localeContext.getFallbackLanguageId() + + // No fallback is defined. + assertThat(fallbackLanguageId).isEqualToDefaultInstance() + } + + @Test + fun testGetFallbackId_fullDefinitionNoFallback_audioSubUsage_returnsDefaultInstance() { + val localeContext = createCompleteLocaleContext( + usageMode = OppiaLocaleContext.LanguageUsageMode.AUDIO_TRANSLATIONS, + language = OppiaLanguage.ENGLISH, + appStringId = ENGLISH_LANGUAGE_ID, + contentStringId = HINGLISH_LANGUAGE_ID, + audioTranslationId = BRAZILIAN_PORTUGUESE_LANGUAGE_ID + ) + + val fallbackLanguageId = localeContext.getFallbackLanguageId() + + // No fallback is defined. + assertThat(fallbackLanguageId).isEqualToDefaultInstance() + } + + private fun createContextWithoutLanguageDefinition( + usageMode: OppiaLocaleContext.LanguageUsageMode + ): OppiaLocaleContext { + return OppiaLocaleContext.newBuilder().apply { + this.usageMode = usageMode + }.build() + } + + private fun createAppStringsContext( + language: OppiaLanguage, + appStringId: LanguageId + ): OppiaLocaleContext { + return createContextWithoutLanguageDefinition( + usageMode = OppiaLocaleContext.LanguageUsageMode.APP_STRINGS + ).toBuilder().apply { + languageDefinition = LanguageSupportDefinition.newBuilder().apply { + this.language = language + this.appStringId = appStringId + }.build() + }.build() + } + + private fun createAppStringsWithFallbackContext( + language: OppiaLanguage, + appStringId: LanguageId, + fallbackStringId: LanguageId + ): OppiaLocaleContext { + return createAppStringsContext(language, appStringId).toBuilder().apply { + fallbackLanguageDefinition = LanguageSupportDefinition.newBuilder().apply { + this.language = language + this.appStringId = fallbackStringId + }.build() + }.build() + } + + private fun createContentStringsContext( + language: OppiaLanguage, + contentStringId: LanguageId + ): OppiaLocaleContext { + return createContextWithoutLanguageDefinition( + usageMode = OppiaLocaleContext.LanguageUsageMode.CONTENT_STRINGS + ).toBuilder().apply { + languageDefinition = LanguageSupportDefinition.newBuilder().apply { + this.language = language + this.contentStringId = contentStringId + }.build() + }.build() + } + + private fun createContentStringsWithFallbackContext( + language: OppiaLanguage, + contentStringId: LanguageId, + fallbackContentStringId: LanguageId + ): OppiaLocaleContext { + return createContentStringsContext(language, contentStringId).toBuilder().apply { + fallbackLanguageDefinition = LanguageSupportDefinition.newBuilder().apply { + this.language = language + this.contentStringId = fallbackContentStringId + }.build() + }.build() + } + + private fun createAudioSubContext( + language: OppiaLanguage, + audioTranslationId: LanguageId + ): OppiaLocaleContext { + return createContextWithoutLanguageDefinition( + usageMode = OppiaLocaleContext.LanguageUsageMode.AUDIO_TRANSLATIONS + ).toBuilder().apply { + languageDefinition = LanguageSupportDefinition.newBuilder().apply { + this.language = language + this.audioTranslationId = audioTranslationId + }.build() + }.build() + } + + private fun createAudioSubWithFallbackContext( + language: OppiaLanguage, + audioTranslationId: LanguageId, + fallbackAudioTranslationId: LanguageId + ): OppiaLocaleContext { + return createAudioSubContext(language, audioTranslationId).toBuilder().apply { + fallbackLanguageDefinition = LanguageSupportDefinition.newBuilder().apply { + this.language = language + this.audioTranslationId = fallbackAudioTranslationId + }.build() + }.build() + } + + private fun createCompleteLocaleContext( + usageMode: OppiaLocaleContext.LanguageUsageMode, + language: OppiaLanguage, + appStringId: LanguageId, + contentStringId: LanguageId, + audioTranslationId: LanguageId + ): OppiaLocaleContext { + return createContextWithoutLanguageDefinition(usageMode).toBuilder().apply { + languageDefinition = LanguageSupportDefinition.newBuilder().apply { + this.language = language + this.appStringId = appStringId + this.contentStringId = contentStringId + this.audioTranslationId = audioTranslationId + }.build() + }.build() + } + + private fun createCompleteLocaleWithFallbackContext( + usageMode: OppiaLocaleContext.LanguageUsageMode, + language: OppiaLanguage, + appStringId: LanguageId, + contentStringId: LanguageId, + audioTranslationId: LanguageId, + fallbackAppStringId: LanguageId, + fallbackContentStringId: LanguageId, + fallbackAudioTranslationId: LanguageId + ): OppiaLocaleContext { + return createCompleteLocaleContext( + usageMode, language, appStringId, contentStringId, audioTranslationId + ).toBuilder().apply { + fallbackLanguageDefinition = LanguageSupportDefinition.newBuilder().apply { + this.language = language + this.appStringId = fallbackAppStringId + this.contentStringId = fallbackContentStringId + this.audioTranslationId = fallbackAudioTranslationId + }.build() + }.build() + } + + private fun setUpTestApplicationComponent() { + DaggerOppiaLocaleContextExtensionsTest_TestApplicationComponent.builder() + .setApplication(ApplicationProvider.getApplicationContext()) + .build() + .inject(this) + } + + // TODO(#89): Move this to a common test application component. + @Module + class TestModule { + @Provides + @Singleton + fun provideContext(application: Application): Context { + return application + } + } + + // TODO(#89): Move this to a common test application component. + @Singleton + @Component( + modules = [ + TestModule::class + ] + ) + interface TestApplicationComponent { + @Component.Builder + interface Builder { + @BindsInstance + fun setApplication(application: Application): Builder + + fun build(): TestApplicationComponent + } + + fun inject(oppiaLocaleContextExtensionsTest: OppiaLocaleContextExtensionsTest) + } + + private companion object { + private val ENGLISH_LANGUAGE_ID = LanguageId.newBuilder().apply { + ietfBcp47Id = LanguageSupportDefinition.IetfBcp47LanguageId.newBuilder().apply { + ietfLanguageTag = "en" + }.build() + androidResourcesLanguageId = LanguageSupportDefinition.AndroidLanguageId.newBuilder().apply { + languageCode = "en" + }.build() + }.build() + + private val HINGLISH_LANGUAGE_ID = LanguageId.newBuilder().apply { + macaronicId = LanguageSupportDefinition.MacaronicLanguageId.newBuilder().apply { + combinedLanguageCode = "hi-en" + }.build() + }.build() + + private val BRAZILIAN_PORTUGUESE_LANGUAGE_ID = LanguageId.newBuilder().apply { + ietfBcp47Id = LanguageSupportDefinition.IetfBcp47LanguageId.newBuilder().apply { + ietfLanguageTag = "pt-BR" + }.build() + androidResourcesLanguageId = LanguageSupportDefinition.AndroidLanguageId.newBuilder().apply { + languageCode = "pt" + regionCode = "BR" + }.build() + }.build() + + private val QQ_LANGUAGE_ID = LanguageId.newBuilder().apply { + ietfBcp47Id = LanguageSupportDefinition.IetfBcp47LanguageId.newBuilder().apply { + ietfLanguageTag = "qq" + }.build() + }.build() + + private val ZZ_LANGUAGE_ID = LanguageId.newBuilder().apply { + ietfBcp47Id = LanguageSupportDefinition.IetfBcp47LanguageId.newBuilder().apply { + ietfLanguageTag = "zz" + }.build() + }.build() + + private val FAKE_LANGUAGE_ID = LanguageId.newBuilder().apply { + ietfBcp47Id = LanguageSupportDefinition.IetfBcp47LanguageId.newBuilder().apply { + ietfLanguageTag = "fake" + }.build() + }.build() + } +} diff --git a/utility/src/test/java/org/oppia/android/util/locale/testing/BUILD.bazel b/utility/src/test/java/org/oppia/android/util/locale/testing/BUILD.bazel new file mode 100644 index 00000000000..e8457492b52 --- /dev/null +++ b/utility/src/test/java/org/oppia/android/util/locale/testing/BUILD.bazel @@ -0,0 +1,46 @@ +""" +Tests for testing utilities for language & locale general utilities. +""" + +load("@dagger//:workspace_defs.bzl", "dagger_rules") +load("//:oppia_android_test.bzl", "oppia_android_test") + +oppia_android_test( + name = "LocaleTestModuleTest", + srcs = ["LocaleTestModuleTest.kt"], + custom_package = "org.oppia.android.util.locale.testing", + test_class = "org.oppia.android.util.locale.testing.LocaleTestModuleTest", + test_manifest = "//utility:test_manifest", + deps = [ + ":dagger", + "//testing/src/main/java/org/oppia/android/testing/time:test_module", + "//third_party:androidx_test_ext_junit", + "//third_party:com_google_truth_truth", + "//third_party:junit_junit", + "//third_party:org_robolectric_robolectric", + "//third_party:robolectric_android-all", + "//utility/src/main/java/org/oppia/android/util/locale/testing:test_module", + ], +) + +oppia_android_test( + name = "TestOppiaBidiFormatterTest", + srcs = ["TestOppiaBidiFormatterTest.kt"], + custom_package = "org.oppia.android.util.locale.testing", + test_class = "org.oppia.android.util.locale.testing.TestOppiaBidiFormatterTest", + test_manifest = "//utility:test_manifest", + deps = [ + ":dagger", + "//testing", + "//testing/src/main/java/org/oppia/android/testing/robolectric:shadow_bidi_formatter", + "//third_party:androidx_test_ext_junit", + "//third_party:com_google_truth_truth", + "//third_party:junit_junit", + "//third_party:org_robolectric_robolectric", + "//third_party:robolectric_android-all", + "//utility/src/main/java/org/oppia/android/util/locale/testing:test_module", + "//utility/src/main/java/org/oppia/android/util/locale/testing:test_oppia_bidi_formatter", + ], +) + +dagger_rules() diff --git a/utility/src/test/java/org/oppia/android/util/locale/testing/LocaleTestModuleTest.kt b/utility/src/test/java/org/oppia/android/util/locale/testing/LocaleTestModuleTest.kt new file mode 100644 index 00000000000..159b8cee2bb --- /dev/null +++ b/utility/src/test/java/org/oppia/android/util/locale/testing/LocaleTestModuleTest.kt @@ -0,0 +1,87 @@ +package org.oppia.android.util.locale.testing + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import dagger.BindsInstance +import dagger.Component +import dagger.Module +import dagger.Provides +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.oppia.android.testing.time.FakeOppiaClockModule +import org.oppia.android.util.locale.MachineLocaleImpl +import org.oppia.android.util.locale.OppiaBidiFormatter +import org.oppia.android.util.locale.OppiaLocale +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import javax.inject.Inject +import javax.inject.Singleton + +/** Tests for [LocaleTestModule]. */ +// FunctionName: test names are conventionally named with underscores. +@Suppress("FunctionName") +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +@Config(manifest = Config.NONE) +class LocaleTestModuleTest { + @Inject + lateinit var machineLocale: OppiaLocale.MachineLocale + + @Inject + lateinit var formatterFactory: OppiaBidiFormatter.Factory + + @Before + fun setUp() { + setUpTestApplicationComponent() + } + + @Test + fun testModule_injectsProductionImplementationOfMachineLocale() { + assertThat(machineLocale).isInstanceOf(MachineLocaleImpl::class.java) + } + + @Test + fun testModule_injectsTestImplementationOfBidiFormatterFactory() { + assertThat(formatterFactory).isInstanceOf(TestOppiaBidiFormatter.FactoryImpl::class.java) + } + + private fun setUpTestApplicationComponent() { + DaggerLocaleTestModuleTest_TestApplicationComponent.builder() + .setApplication(ApplicationProvider.getApplicationContext()) + .build() + .inject(this) + } + + // TODO(#89): Move this to a common test application component. + @Module + class TestModule { + @Provides + @Singleton + fun provideContext(application: Application): Context { + return application + } + } + + // TODO(#89): Move this to a common test application component. + @Singleton + @Component( + modules = [ + TestModule::class, LocaleTestModule::class, FakeOppiaClockModule::class + ] + ) + interface TestApplicationComponent { + @Component.Builder + interface Builder { + @BindsInstance + fun setApplication(application: Application): Builder + + fun build(): TestApplicationComponent + } + + fun inject(localeTestModuleTest: LocaleTestModuleTest) + } +} diff --git a/utility/src/test/java/org/oppia/android/util/locale/testing/TestOppiaBidiFormatterTest.kt b/utility/src/test/java/org/oppia/android/util/locale/testing/TestOppiaBidiFormatterTest.kt new file mode 100644 index 00000000000..d56be1961ae --- /dev/null +++ b/utility/src/test/java/org/oppia/android/util/locale/testing/TestOppiaBidiFormatterTest.kt @@ -0,0 +1,167 @@ +package org.oppia.android.util.locale.testing + +import android.app.Application +import android.content.Context +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import dagger.BindsInstance +import dagger.Component +import dagger.Module +import dagger.Provides +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.oppia.android.testing.assertThrows +import org.oppia.android.testing.robolectric.ShadowBidiFormatter +import org.oppia.android.util.locale.OppiaBidiFormatter +import org.robolectric.annotation.Config +import org.robolectric.annotation.LooperMode +import java.util.Locale +import javax.inject.Inject +import javax.inject.Singleton + +/** Tests for [TestOppiaBidiFormatter]. */ +// FunctionName: test names are conventionally named with underscores. +@Suppress("FunctionName") +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +@Config(manifest = Config.NONE, shadows = [ShadowBidiFormatter::class]) +class TestOppiaBidiFormatterTest { + @Inject + lateinit var formatterFactory: OppiaBidiFormatter.Factory + + @Inject + lateinit var formatterChecker: TestOppiaBidiFormatter.Checker + + @Before + fun setUp() { + setUpTestApplicationComponent() + } + + @After + fun tearDown() { + ShadowBidiFormatter.reset() + } + + @Test + fun testFactory_createTwoInstances_sameLocale_returnsDifferentFormatters() { + val formatter1 = formatterFactory.createFormatter(Locale.ROOT) + val formatter2 = formatterFactory.createFormatter(Locale.US) + + assertThat(formatter1).isNotEqualTo(formatter2) + } + + @Test + fun testFormatter_wrapText_callsProdWrapText() { + val formatter = formatterFactory.createFormatter(Locale.US) + + formatter.wrapText("test str") + + // Verify via the custom shadow that the production BidiFormatter is called (doesn't necessarily + // guarantee that this class calls through to the prod implementation, but from the API + // perspective of OppiaBidiFormatter this distinction is unimportant). + val shadow = ShadowBidiFormatter.lookUpFormatter(Locale.US) + assertThat(shadow?.getLastWrappedSequence()).isEqualTo("test str") + } + + @Test + fun testFormatter_wrapText_returnsSequenceWithSameLength() { + val formatter = formatterFactory.createFormatter(Locale.US) + val strToWrap = "test str" + + val wrappedStr = formatter.wrapText(strToWrap) + + assertThat(wrappedStr.length).isEqualTo(wrappedStr.length) + } + + @Test + fun testFormatter_wrapText_returnsSequenceWithSameContents() { + val formatter = formatterFactory.createFormatter(Locale.US) + + val wrappedStr = formatter.wrapText("test str") + + assertThat(wrappedStr).isEqualTo("test str") + assertThat(wrappedStr.toString()).isEqualTo("test str") + } + + @Test + fun testFormatter_wrapText_twice_throwsException() { + val formatter = formatterFactory.createFormatter(Locale.US) + val wrappedStr = formatter.wrapText("test str") + + // Try to wrap the string again. + val exception = assertThrows(IllegalStateException::class) { formatter.wrapText(wrappedStr) } + + assertThat(exception).hasMessageThat() + .contains("Error: encountered string that's already been wrapped: test str") + } + + @Test + fun testChecker_isTextWrapped_unwrappedText_returnsFalse() { + val isWrapped = formatterChecker.isTextWrapped("test str") + + assertThat(isWrapped).isFalse() + } + + @Test + fun testChecker_isTextWrapped_wrappedText_returnsTrue() { + val formatter = formatterFactory.createFormatter(Locale.US) + val wrapped = formatter.wrapText("test str") + + val isWrapped = formatterChecker.isTextWrapped(wrapped) + + assertThat(isWrapped).isTrue() + } + + @Test + fun testChecker_isTextWrapped_wrappedText_fromDifferentFormatters_returnsTrue() { + val ltrFormatter = formatterFactory.createFormatter(Locale.US) + val rtlFormatter = formatterFactory.createFormatter(Locale("ar", "EG")) + val wrapped1 = ltrFormatter.wrapText("test str one") + val wrapped2 = rtlFormatter.wrapText("test str two") + + val isFirstWrapped = formatterChecker.isTextWrapped(wrapped1) + val isSecondWrapped = formatterChecker.isTextWrapped(wrapped2) + + assertThat(isFirstWrapped).isTrue() + assertThat(isSecondWrapped).isTrue() + } + + private fun setUpTestApplicationComponent() { + DaggerTestOppiaBidiFormatterTest_TestApplicationComponent.builder() + .setApplication(ApplicationProvider.getApplicationContext()) + .build() + .inject(this) + } + + // TODO(#89): Move this to a common test application component. + @Module + class TestModule { + @Provides + @Singleton + fun provideContext(application: Application): Context { + return application + } + } + + // TODO(#89): Move this to a common test application component. + @Singleton + @Component( + modules = [ + TestModule::class, LocaleTestModule::class + ] + ) + interface TestApplicationComponent { + @Component.Builder + interface Builder { + @BindsInstance + fun setApplication(application: Application): Builder + + fun build(): TestApplicationComponent + } + + fun inject(testOppiaBidiFormatterTest: TestOppiaBidiFormatterTest) + } +} diff --git a/utility/src/test/java/org/oppia/android/util/parser/image/UrlImageParserTest.kt b/utility/src/test/java/org/oppia/android/util/parser/image/UrlImageParserTest.kt index 469e9073623..a9dd9d111af 100644 --- a/utility/src/test/java/org/oppia/android/util/parser/image/UrlImageParserTest.kt +++ b/utility/src/test/java/org/oppia/android/util/parser/image/UrlImageParserTest.kt @@ -20,6 +20,7 @@ import org.oppia.android.testing.TestImageLoaderModule 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.caching.AssetModule import org.oppia.android.util.caching.testing.CachingTestModule import org.oppia.android.util.logging.LoggerModule import org.oppia.android.util.parser.html.CustomHtmlContentHandler.ImageRetriever @@ -135,7 +136,7 @@ class UrlImageParserTest { modules = [ TestModule::class, TestDispatcherModule::class, RobolectricModule::class, FakeOppiaClockModule::class, LoggerModule::class, TestImageLoaderModule::class, - CachingTestModule::class, ImageParsingModule::class + CachingTestModule::class, ImageParsingModule::class, AssetModule::class ] ) interface TestApplicationComponent {