From 3b67e0e81a1196d3314504bce636d26e62cc4187 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Mon, 27 Sep 2021 01:57:57 -0700 Subject: [PATCH] Fix part of #3728, #3729, #3730: Introduce infrastructure to support app & content string translations (#3794) * Add support for AABs, build flavors, and proguard. There are a lot of details to cover here--see the PR for the complete context. * Lint & codeowner fixes. * Fix failures. - Add missing codeowner - Add support for configuring base branch reference - Update CI for dev/alpha AAB builds to use 'develop' since there's no origin configured by default in the workflows * Different attempt to fix bad develop reference in CI. * Initial commit. This is needed to open a PR on GitHub. This commit is being made so that the PR can start off in a broken Actions state. This also initially disables most non-Bazel workflows to make workflow iteration faster and less impacting on other team members. * Introduce infrastructure for batching. This introduces a new mechanism for passing lists of tests to sharded test targets in CI, and hooks it up. No actual sharding is occurring yet. This led to some simplifications in the CI workflow since the script can be more dynamic in computing the full list of targets (which also works around a previous bug with instrumentation tests being run). Java proto lite also needed to be upgraded for the scripts to be able to use it. More testing/documentation needed as this functionality continues to expand. * Add bucketing strategy. This simply partitions bucketed groups of targets into chunks of 10 for each run. Only 3 buckets are currently retained to test sharding in CI before introducing full support. * Fix caching & stabilize builds. Fixes some caching bucket and output bugs. Also, introduces while loop & keep_going to introduce resilience against app test build failures (or just test failures in general). * Increase sharding & add randomization. Also, enable other workflows. Note that CI shouldn't fully pass yet since some documentation and testing needs to be added yet, but this is meant to be a more realistic test of the CI environment before the PR is finished. * Improving partitionin & readability. Adds a human-readable prefix to make the shards look a bit nicer. Also, adds more fine-tuned partitioning to bucket & reduce shard counts to improve overall timing. Will need to be tested in CI. * Add new tests & fix static analysis errors. * Fix script. A newly computed variable wasn't updated to be used in an earlier change. * Fix broken tests & test configuration. Add docstrings for proto. * Fix mistake from earlier commit. * Try 10 max parallel actions instead. See https://github.com/oppia/oppia-android/pull/3757#issuecomment-911460981 for context. * Fix another error from an earlier commit. * Localisation updates from https://translatewiki.net. * Fix mv command so it works on Linux & OSX. Neither 'mv -t' nor piping to mv work on OSX so we needed to find an alternative (in this case just trying to move everything). This actually works a bit better since it's doing a per-file move rather than accommodating for files that shouldn't be moved (which isn't an issue since the destination directory is different than the one containing the AAB file). * Introduce initial domain layer for translations. Documentation, thorough tests, and detailed description of these changes are still needed. * Domain changes needed per downstream UI changes. * Add needed domain changes for downstream branch. Also includes fixing circular dependency issue by splitting out some of the locale components to be part of utility rather than domain (so that utiltiy and other packages can depend on MachineLocale). * Fix regex checks for translated strings. Also, performance improvements for the regex check. * Lint-ish fix. * Add check for nested res subdirectories. * Clean up locale infra. Add some other needed functionality. * Attempt to delete strings to force history. * Make AAB builds/runs manual-only targets. * Fix broken tests. * Fix lint issues & add KDocs. Also, abstract ContentLocale for consistency & to disallow direct construction. * Add 6/11 test suites (& placeholders for other 4). Silence one file missing a test suite (since it doesn't need one). Also, some tweaks to the language support definitions. * Add more test suites for domain layers. Included introducing a new general purpose utility for testing data providers + its own test suite. * Introduce wrapper & fake for bidi wrapping. Also, add test version of AssetRepository. Add new placeholder tests & update all tests project-wide to make sure that they build. * Add remaining tests. Included some shadow refactoring, and introducing new test-only resources. * Fix Gradle builds. * Lint fixes. * Resolve remaining incomplete TODOs. * Add new codeowners. * Two fixes. 1. Introduce proper API compatibility for LocaleController 2. Ensure TranslationController is scoped (breaks test in downstream PR) Co-authored-by: translatewiki.net --- .github/CODEOWNERS | 6 + app/BUILD.bazel | 4 +- app/build.gradle | 1 + .../app/application/ApplicationComponent.kt | 3 +- .../AdministratorControlsActivityTest.kt | 4 +- .../AppVersionActivityTest.kt | 4 +- .../CompletedStoryListActivityTest.kt | 4 +- .../LessonThumbnailImageViewTest.kt | 4 +- .../databinding/MarginBindingAdaptersTest.kt | 4 +- ...StateAssemblerMarginBindingAdaptersTest.kt | 4 +- ...tateAssemblerPaddingBindingAdaptersTest.kt | 4 +- .../databinding/ViewBindingAdaptersTest.kt | 4 +- .../DeveloperOptionsActivityTest.kt | 4 +- .../DeveloperOptionsFragmentTest.kt | 4 +- .../MarkChaptersCompletedActivityTest.kt | 4 +- .../MarkChaptersCompletedFragmentTest.kt | 4 +- .../MarkStoriesCompletedActivityTest.kt | 4 +- .../MarkStoriesCompletedFragmentTest.kt | 4 +- .../MarkTopicsCompletedActivityTest.kt | 4 +- .../MarkTopicsCompletedFragmentTest.kt | 4 +- .../devoptions/ViewEventLogsActivityTest.kt | 4 +- .../devoptions/ViewEventLogsFragmentTest.kt | 4 +- .../ForceNetworkTypeActivityTest.kt | 4 +- .../ForceNetworkTypeFragmentTest.kt | 4 +- .../android/app/faq/FAQListFragmentTest.kt | 4 +- .../android/app/faq/FAQSingleActivityTest.kt | 4 +- .../android/app/faq/FaqListActivityTest.kt | 4 +- .../android/app/help/HelpActivityTest.kt | 4 +- .../android/app/help/HelpFragmentTest.kt | 4 +- .../android/app/home/HomeActivityTest.kt | 4 +- .../app/home/RecentlyPlayedFragmentTest.kt | 4 +- .../app/home/TopicSummaryViewModelTest.kt | 2 + .../android/app/home/WelcomeViewModelTest.kt | 2 + .../PromotedStoryListViewModelTest.kt | 2 + .../PromotedStoryViewModelTest.kt | 2 + .../mydownloads/MyDownloadsFragmentTest.kt | 4 +- .../app/onboarding/OnboardingActivityTest.kt | 4 +- .../app/onboarding/OnboardingFragmentTest.kt | 4 +- .../OngoingTopicListActivityTest.kt | 4 +- .../app/options/AppLanguageActivityTest.kt | 4 +- .../app/options/AppLanguageFragmentTest.kt | 2 + .../app/options/AudioLanguageActivityTest.kt | 4 +- .../app/options/AudioLanguageFragmentTest.kt | 2 + .../app/options/OptionsActivityTest.kt | 4 +- .../app/options/OptionsFragmentTest.kt | 4 +- .../options/ReadingTextSizeActivityTest.kt | 4 +- .../options/ReadingTextSizeFragmentTest.kt | 2 + .../app/parser/CustomBulletSpanTest.kt | 2 + .../android/app/parser/HtmlParserTest.kt | 4 +- .../app/player/audio/AudioFragmentTest.kt | 4 +- .../exploration/ExplorationActivityTest.kt | 4 +- .../app/player/state/StateFragmentTest.kt | 4 +- .../app/profile/AddProfileActivityTest.kt | 4 +- .../app/profile/AdminAuthActivityTest.kt | 4 +- .../app/profile/AdminPinActivityTest.kt | 4 +- .../app/profile/PinPasswordActivityTest.kt | 4 +- .../app/profile/ProfileChooserFragmentTest.kt | 4 +- .../ProfilePictureActivityTest.kt | 4 +- .../ProfileProgressActivityTest.kt | 4 +- .../ProfileProgressFragmentTest.kt | 4 +- .../app/recyclerview/BindableAdapterTest.kt | 4 +- .../resumelesson/ResumeLessonActivityTest.kt | 4 +- .../resumelesson/ResumeLessonFragmentTest.kt | 4 +- .../profile/ProfileEditActivityTest.kt | 4 +- .../profile/ProfileListActivityTest.kt | 4 +- .../profile/ProfileListFragmentTest.kt | 4 +- .../profile/ProfileRenameActivityTest.kt | 4 +- .../profile/ProfileResetPinActivityTest.kt | 4 +- .../android/app/splash/SplashActivityTest.kt | 4 +- .../android/app/story/StoryActivityTest.kt | 4 +- .../android/app/story/StoryFragmentTest.kt | 4 +- .../app/testing/DragDropTestActivityTest.kt | 4 +- ...ImageRegionSelectionInteractionViewTest.kt | 4 +- .../InputInteractionViewTestActivityTest.kt | 4 +- .../NavigationDrawerActivityDebugTest.kt | 4 +- .../NavigationDrawerActivityProdTest.kt | 2 + ...tFontScaleConfigurationUtilActivityTest.kt | 4 +- .../testing/TopicTestActivityForStoryTest.kt | 4 +- .../app/thirdparty/LicenseListActivityTest.kt | 4 +- .../app/thirdparty/LicenseListFragmentTest.kt | 4 +- .../LicenseTextViewerActivityTest.kt | 4 +- .../LicenseTextViewerFragmentTest.kt | 4 +- .../ThirdPartyDependencyListActivityTest.kt | 4 +- .../ThirdPartyDependencyListFragmentTest.kt | 4 +- .../android/app/topic/TopicActivityTest.kt | 4 +- .../android/app/topic/TopicFragmentTest.kt | 4 +- .../conceptcard/ConceptCardFragmentTest.kt | 4 +- .../app/topic/info/TopicInfoFragmentTest.kt | 4 +- .../topic/lessons/TopicLessonsFragmentTest.kt | 4 +- .../practice/TopicPracticeFragmentTest.kt | 4 +- .../QuestionPlayerActivityTest.kt | 4 +- .../revision/TopicRevisionFragmentTest.kt | 4 +- .../revisioncard/RevisionCardActivityTest.kt | 4 +- .../revisioncard/RevisionCardFragmentTest.kt | 4 +- .../app/utility/RatioExtensionsTest.kt | 4 +- .../walkthrough/WalkthroughActivityTest.kt | 4 +- .../WalkthroughFinalFragmentTest.kt | 4 +- .../WalkthroughTopicListFragmentTest.kt | 4 +- .../WalkthroughWelcomeFragmentTest.kt | 4 +- .../android/app/home/HomeActivityLocalTest.kt | 2 + .../app/parser/StringToFractionParserTest.kt | 4 +- .../app/parser/StringToRatioParserTest.kt | 4 +- .../ExplorationActivityLocalTest.kt | 2 + .../player/state/StateFragmentLocalTest.kt | 4 +- .../ProfileChooserFragmentLocalTest.kt | 4 +- .../app/story/StoryActivityLocalTest.kt | 4 +- .../app/testing/CompletedStoryListSpanTest.kt | 2 + .../oppia/android/app/testing/HomeSpanTest.kt | 2 + .../app/testing/OngoingTopicListSpanTest.kt | 2 + .../PlatformParameterIntegrationTest.kt | 3 +- .../app/testing/ProfileChooserSpanTest.kt | 2 + .../app/testing/ProfileProgressSpanCount.kt | 2 + .../app/testing/RecentlyPlayedSpanTest.kt | 2 + .../app/testing/TopicRevisionSpanTest.kt | 2 + .../AdministratorControlsFragmentTest.kt | 4 +- .../testing/options/OptionsFragmentTest.kt | 4 +- .../player/split/PlayerSplitScreenTesting.kt | 2 + .../state/StateFragmentAccessibilityTest.kt | 2 + .../topic/info/TopicInfoFragmentLocalTest.kt | 4 +- .../lessons/TopicLessonsFragmentLocalTest.kt | 4 +- .../QuestionPlayerActivityLocalTest.kt | 4 +- .../RevisionCardActivityLocalTest.kt | 4 +- .../app/utility/datetime/DateTimeUtilTest.kt | 3 +- build.gradle | 2 +- config/config_proto_assets.bzl | 30 + .../oppia/android/config/AndroidManifest.xml | 5 + .../java/org/oppia/android/config/BUILD.bazel | 38 + .../languages/supported_languages.textproto | 109 ++ .../languages/supported_regions.textproto | 23 + data/build.gradle | 2 +- domain/BUILD.bazel | 24 + domain/build.gradle | 21 + .../domain/locale/AndroidLocaleFactory.kt | 151 ++ .../oppia/android/domain/locale/BUILD.bazel | 89 + .../domain/locale/ContentLocaleImpl.kt | 13 + .../domain/locale/DisplayLocaleImpl.kt | 120 ++ .../domain/locale/LanguageConfigRetriever.kt | 29 + .../android/domain/locale/LocaleController.kt | 468 +++++ .../android/domain/translation/BUILD.bazel | 28 + .../translation/TranslationController.kt | 340 ++++ .../org/oppia/android/domain/util/BUILD.bazel | 3 +- .../domain/audio/AudioPlayerControllerTest.kt | 3 +- .../ModifyLessonProgressControllerTest.kt | 3 +- .../ExplorationDataControllerTest.kt | 12 +- .../ExplorationProgressControllerTest.kt | 8 +- .../ExplorationCheckpointControllerTest.kt | 3 +- .../ExplorationStorageModuleTest.kt | 3 +- .../HintHandlerDebugImplTest.kt | 3 +- .../HintHandlerProdImplTest.kt | 3 +- .../domain/locale/AndroidLocaleFactoryTest.kt | 1629 +++++++++++++++++ .../oppia/android/domain/locale/BUILD.bazel | 141 ++ .../domain/locale/ContentLocaleImplTest.kt | 174 ++ .../domain/locale/DisplayLocaleImplTest.kt | 658 +++++++ .../locale/LanguageConfigRetrieverTest.kt | 267 +++ ...anguageConfigRetrieverWithoutAssetsTest.kt | 96 + .../domain/locale/LocaleControllerTest.kt | 893 +++++++++ ...uestionAssessmentProgressControllerTest.kt | 3 +- .../QuestionTrainingControllerTest.kt | 3 +- .../domain/topic/TopicControllerTest.kt | 7 +- .../domain/topic/TopicListControllerTest.kt | 3 +- .../android/domain/translation/BUILD.bazel | 36 + .../translation/TranslationControllerTest.kt | 1146 ++++++++++++ .../android/domain/util/StateRetrieverTest.kt | 6 +- domain/src/test/res/values/strings.xml | 27 + .../instrumentation/application/BUILD.bazel | 2 +- .../application/TestApplicationComponent.kt | 3 +- model/BUILD.bazel | 14 + model/build.gradle | 30 +- model/src/main/proto/languages.proto | 265 +++ model/src/main/proto/translation.proto | 6 + oppia_android_application.bzl | 5 + .../file_content_validation_checks.textproto | 16 +- scripts/assets/test_file_exemptions.textproto | 3 + .../regex/RegexPatternValidationCheckTest.kt | 48 + testing/BUILD.bazel | 1 + testing/build.gradle | 1 + .../oppia/android/testing/data/BUILD.bazel | 26 + .../testing/data/DataProviderTestMonitor.kt | 179 ++ .../android/testing/robolectric/BUILD.bazel | 18 + .../robolectric/ShadowBidiFormatter.kt | 105 ++ .../android/testing/time/FakeOppiaClock.kt | 12 +- .../oppia/android/testing/data/BUILD.bazel | 37 + .../data/DataProviderTestMonitorTest.kt | 888 +++++++++ .../ExplorationCheckpointTestHelperTest.kt | 3 +- .../android/testing/robolectric/BUILD.bazel | 25 + .../robolectric/ShadowBidiFormatterTest.kt | 154 ++ .../story/StoryProgressTestHelperTest.kt | 4 +- .../testing/time/FakeOppiaClockTest.kt | 7 + third_party/versions.bzl | 2 +- utility/BUILD.bazel | 12 +- utility/build.gradle | 2 + .../oppia/android/util/caching/AssetModule.kt | 11 + .../android/util/caching/AssetRepository.kt | 168 +- .../util/caching/AssetRepositoryImpl.kt | 191 ++ .../oppia/android/util/caching/BUILD.bazel | 42 +- .../caching/testing/AssetTestNoOpModule.kt | 12 + .../android/util/caching/testing/BUILD.bazel | 31 +- .../testing/TestNoOpAssetRepository.kt | 48 + .../util/locale/AndroidLocaleProfile.kt | 112 ++ .../org/oppia/android/util/locale/BUILD.bazel | 82 + .../android/util/locale/LocaleProdModule.kt | 14 + .../android/util/locale/MachineLocaleImpl.kt | 100 + .../android/util/locale/OppiaBidiFormatter.kt | 27 + .../util/locale/OppiaBidiFormatterImpl.kt | 18 + .../oppia/android/util/locale/OppiaLocale.kt | 353 ++++ .../locale/OppiaLocaleContextExtensions.kt | 29 + .../android/util/locale/testing/BUILD.bazel | 36 + .../util/locale/testing/LocaleTestModule.kt | 17 + .../locale/testing/TestOppiaBidiFormatter.kt | 89 + .../parser/image/RepositoryGlideModule.kt | 6 +- .../oppia/android/util/system/OppiaClock.kt | 11 +- .../android/util/system/OppiaClockImpl.kt | 7 - .../android/util/caching/AssetModuleTest.kt | 79 + .../oppia/android/util/caching/BUILD.bazel | 29 + .../testing/AssetTestNoOpModuleTest.kt | 76 + .../android/util/caching/testing/BUILD.bazel | 47 + .../testing/TestNoOpAssetRepositoryTest.kt | 187 ++ .../util/locale/AndroidLocaleProfileTest.kt | 544 ++++++ .../org/oppia/android/util/locale/BUILD.bazel | 100 + .../util/locale/LocaleProdModuleTest.kt | 84 + .../util/locale/MachineLocaleImplTest.kt | 308 ++++ .../util/locale/OppiaBidiFormatterImplTest.kt | 162 ++ .../OppiaLocaleContextExtensionsTest.kt | 650 +++++++ .../android/util/locale/testing/BUILD.bazel | 46 + .../locale/testing/LocaleTestModuleTest.kt | 87 + .../testing/TestOppiaBidiFormatterTest.kt | 167 ++ .../util/parser/image/UrlImageParserTest.kt | 3 +- 227 files changed, 12582 insertions(+), 329 deletions(-) create mode 100644 config/config_proto_assets.bzl create mode 100644 config/src/java/org/oppia/android/config/AndroidManifest.xml create mode 100644 config/src/java/org/oppia/android/config/BUILD.bazel create mode 100644 config/src/java/org/oppia/android/config/languages/supported_languages.textproto create mode 100644 config/src/java/org/oppia/android/config/languages/supported_regions.textproto create mode 100644 domain/src/main/java/org/oppia/android/domain/locale/AndroidLocaleFactory.kt create mode 100644 domain/src/main/java/org/oppia/android/domain/locale/BUILD.bazel create mode 100644 domain/src/main/java/org/oppia/android/domain/locale/ContentLocaleImpl.kt create mode 100644 domain/src/main/java/org/oppia/android/domain/locale/DisplayLocaleImpl.kt create mode 100644 domain/src/main/java/org/oppia/android/domain/locale/LanguageConfigRetriever.kt create mode 100644 domain/src/main/java/org/oppia/android/domain/locale/LocaleController.kt create mode 100644 domain/src/main/java/org/oppia/android/domain/translation/BUILD.bazel create mode 100644 domain/src/main/java/org/oppia/android/domain/translation/TranslationController.kt create mode 100644 domain/src/test/java/org/oppia/android/domain/locale/AndroidLocaleFactoryTest.kt create mode 100644 domain/src/test/java/org/oppia/android/domain/locale/BUILD.bazel create mode 100644 domain/src/test/java/org/oppia/android/domain/locale/ContentLocaleImplTest.kt create mode 100644 domain/src/test/java/org/oppia/android/domain/locale/DisplayLocaleImplTest.kt create mode 100644 domain/src/test/java/org/oppia/android/domain/locale/LanguageConfigRetrieverTest.kt create mode 100644 domain/src/test/java/org/oppia/android/domain/locale/LanguageConfigRetrieverWithoutAssetsTest.kt create mode 100644 domain/src/test/java/org/oppia/android/domain/locale/LocaleControllerTest.kt create mode 100644 domain/src/test/java/org/oppia/android/domain/translation/BUILD.bazel create mode 100644 domain/src/test/java/org/oppia/android/domain/translation/TranslationControllerTest.kt create mode 100644 domain/src/test/res/values/strings.xml create mode 100644 model/src/main/proto/languages.proto create mode 100644 testing/src/main/java/org/oppia/android/testing/data/BUILD.bazel create mode 100644 testing/src/main/java/org/oppia/android/testing/data/DataProviderTestMonitor.kt create mode 100644 testing/src/main/java/org/oppia/android/testing/robolectric/ShadowBidiFormatter.kt create mode 100644 testing/src/test/java/org/oppia/android/testing/data/BUILD.bazel create mode 100644 testing/src/test/java/org/oppia/android/testing/data/DataProviderTestMonitorTest.kt create mode 100644 testing/src/test/java/org/oppia/android/testing/robolectric/BUILD.bazel create mode 100644 testing/src/test/java/org/oppia/android/testing/robolectric/ShadowBidiFormatterTest.kt create mode 100644 utility/src/main/java/org/oppia/android/util/caching/AssetModule.kt create mode 100644 utility/src/main/java/org/oppia/android/util/caching/AssetRepositoryImpl.kt create mode 100644 utility/src/main/java/org/oppia/android/util/caching/testing/AssetTestNoOpModule.kt create mode 100644 utility/src/main/java/org/oppia/android/util/caching/testing/TestNoOpAssetRepository.kt create mode 100644 utility/src/main/java/org/oppia/android/util/locale/AndroidLocaleProfile.kt create mode 100644 utility/src/main/java/org/oppia/android/util/locale/BUILD.bazel create mode 100644 utility/src/main/java/org/oppia/android/util/locale/LocaleProdModule.kt create mode 100644 utility/src/main/java/org/oppia/android/util/locale/MachineLocaleImpl.kt create mode 100644 utility/src/main/java/org/oppia/android/util/locale/OppiaBidiFormatter.kt create mode 100644 utility/src/main/java/org/oppia/android/util/locale/OppiaBidiFormatterImpl.kt create mode 100644 utility/src/main/java/org/oppia/android/util/locale/OppiaLocale.kt create mode 100644 utility/src/main/java/org/oppia/android/util/locale/OppiaLocaleContextExtensions.kt create mode 100644 utility/src/main/java/org/oppia/android/util/locale/testing/BUILD.bazel create mode 100644 utility/src/main/java/org/oppia/android/util/locale/testing/LocaleTestModule.kt create mode 100644 utility/src/main/java/org/oppia/android/util/locale/testing/TestOppiaBidiFormatter.kt create mode 100644 utility/src/test/java/org/oppia/android/util/caching/AssetModuleTest.kt create mode 100644 utility/src/test/java/org/oppia/android/util/caching/BUILD.bazel create mode 100644 utility/src/test/java/org/oppia/android/util/caching/testing/AssetTestNoOpModuleTest.kt create mode 100644 utility/src/test/java/org/oppia/android/util/caching/testing/BUILD.bazel create mode 100644 utility/src/test/java/org/oppia/android/util/caching/testing/TestNoOpAssetRepositoryTest.kt create mode 100644 utility/src/test/java/org/oppia/android/util/locale/AndroidLocaleProfileTest.kt create mode 100644 utility/src/test/java/org/oppia/android/util/locale/BUILD.bazel create mode 100644 utility/src/test/java/org/oppia/android/util/locale/LocaleProdModuleTest.kt create mode 100644 utility/src/test/java/org/oppia/android/util/locale/MachineLocaleImplTest.kt create mode 100644 utility/src/test/java/org/oppia/android/util/locale/OppiaBidiFormatterImplTest.kt create mode 100644 utility/src/test/java/org/oppia/android/util/locale/OppiaLocaleContextExtensionsTest.kt create mode 100644 utility/src/test/java/org/oppia/android/util/locale/testing/BUILD.bazel create mode 100644 utility/src/test/java/org/oppia/android/util/locale/testing/LocaleTestModuleTest.kt create mode 100644 utility/src/test/java/org/oppia/android/util/locale/testing/TestOppiaBidiFormatterTest.kt 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 {