diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ffd2ce60e3f..c326f4d01d0 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -95,7 +95,8 @@ LICENSE @BenHenning NOTICE @BenHenning # Language configuration files. -config/**/languages/*.textproto @BenHenning +config/**/alllanguages/*.textproto @BenHenning +config/**/productionlanguages/*.textproto @BenHenning # Configuration for KitKat-specific curated builds. config/kitkat_main_dex_class_list.txt @BenHenning diff --git a/app/BUILD.bazel b/app/BUILD.bazel index 5b8793cf70e..da739a6afb5 100644 --- a/app/BUILD.bazel +++ b/app/BUILD.bazel @@ -838,6 +838,7 @@ TEST_DEPS = [ "//app/src/main/java/org/oppia/android/app/testing/activity:test_activity", "//app/src/main/java/org/oppia/android/app/translation/testing:test_module", "//app/src/main/java/org/oppia/android/app/utility/math:math_expression_accessibility_util", + "//config/src/java/org/oppia/android/config:all_languages_config", "//domain", "//domain/src/main/java/org/oppia/android/domain/audio:audio_player_controller", "//domain/src/main/java/org/oppia/android/domain/classify/rules/algebraicexpressioninput:algebraic_expression_input_rule_module", diff --git a/app/src/sharedTest/java/org/oppia/android/app/player/exploration/BUILD.bazel b/app/src/sharedTest/java/org/oppia/android/app/player/exploration/BUILD.bazel index b63ad42bc76..38ccfce8e30 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/player/exploration/BUILD.bazel +++ b/app/src/sharedTest/java/org/oppia/android/app/player/exploration/BUILD.bazel @@ -57,6 +57,7 @@ app_test( "//app/src/main/java/org/oppia/android/app/application:common_application_modules", "//app/src/main/java/org/oppia/android/app/application/testing:testing_build_flavor_module", "//app/src/main/java/org/oppia/android/app/translation/testing:test_module", + "//config/src/java/org/oppia/android/config:all_languages_config", "//data/src/main/java/org/oppia/android/data/backends/gae:prod_module", "//domain/src/main/java/org/oppia/android/domain/onboarding/testing:retriever_test_module", "//testing", diff --git a/app/src/sharedTest/java/org/oppia/android/app/player/state/BUILD.bazel b/app/src/sharedTest/java/org/oppia/android/app/player/state/BUILD.bazel index 5f43c05d529..349cc632848 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/player/state/BUILD.bazel +++ b/app/src/sharedTest/java/org/oppia/android/app/player/state/BUILD.bazel @@ -20,6 +20,7 @@ app_test( "//app/src/main/java/org/oppia/android/app/application:common_application_modules", "//app/src/main/java/org/oppia/android/app/application/testing:testing_build_flavor_module", "//app/src/main/java/org/oppia/android/app/translation/testing:test_module", + "//config/src/java/org/oppia/android/config:all_languages_config", "//data/src/main/java/org/oppia/android/data/backends/gae:prod_module", "//domain/src/main/java/org/oppia/android/domain/onboarding/testing:retriever_test_module", "//domain/src/main/java/org/oppia/android/domain/translation:translation_controller", diff --git a/app/src/sharedTest/java/org/oppia/android/app/splash/BUILD.bazel b/app/src/sharedTest/java/org/oppia/android/app/splash/BUILD.bazel index 65dbb604e46..a5c0352eb1e 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/splash/BUILD.bazel +++ b/app/src/sharedTest/java/org/oppia/android/app/splash/BUILD.bazel @@ -19,6 +19,7 @@ app_test( "//app/src/main/java/org/oppia/android/app/application:application_injector_provider", "//app/src/main/java/org/oppia/android/app/application:common_application_modules", "//app/src/main/java/org/oppia/android/app/translation/testing:test_module", + "//config/src/java/org/oppia/android/config:all_languages_config", "//data/src/main/java/org/oppia/android/data/backends/gae:prod_module", "//domain/src/main/java/org/oppia/android/domain/onboarding/testing:retriever_test_module", "//domain/src/main/java/org/oppia/android/domain/translation:translation_controller", diff --git a/app/src/test/java/org/oppia/android/app/translation/BUILD.bazel b/app/src/test/java/org/oppia/android/app/translation/BUILD.bazel index 052cf78e9b4..83de617710d 100644 --- a/app/src/test/java/org/oppia/android/app/translation/BUILD.bazel +++ b/app/src/test/java/org/oppia/android/app/translation/BUILD.bazel @@ -14,6 +14,7 @@ oppia_android_test( deps = [ ":dagger", "//app/src/main/java/org/oppia/android/app/translation:app_language_locale_handler", + "//config/src/java/org/oppia/android/config:all_languages_config", "//domain", "//domain/src/main/java/org/oppia/android/domain/onboarding/testing:retriever_test_module", "//domain/src/main/java/org/oppia/android/domain/oppialogger:prod_module", @@ -106,6 +107,7 @@ oppia_android_test( "//app/src/main/java/org/oppia/android/app/application:common_application_modules", "//app/src/main/java/org/oppia/android/app/application/testing:testing_build_flavor_module", "//app/src/main/java/org/oppia/android/app/translation/testing:test_module", + "//config/src/java/org/oppia/android/config:all_languages_config", "//data/src/main/java/org/oppia/android/data/backends/gae:prod_module", "//domain/src/main/java/org/oppia/android/domain/onboarding/testing:retriever_test_module", "//domain/src/main/java/org/oppia/android/domain/oppialogger:prod_module", diff --git a/build_flavors.bzl b/build_flavors.bzl index ec541fe8046..55aec211b65 100644 --- a/build_flavors.bzl +++ b/build_flavors.bzl @@ -52,6 +52,7 @@ _FLAVOR_METADATA = { "production_release": False, "deps": [ "//app/src/main/java/org/oppia/android/app/application/dev:developer_application", + "//config/src/java/org/oppia/android/config:all_languages_config", ], "version_code": OPPIA_DEV_VERSION_CODE, "application_class": ".app.application.dev.DeveloperOppiaApplication", @@ -66,6 +67,7 @@ _FLAVOR_METADATA = { "production_release": False, "deps": [ "//app/src/main/java/org/oppia/android/app/application/dev:developer_application", + "//config/src/java/org/oppia/android/config:all_languages_config", ], "version_code": OPPIA_DEV_KITKAT_VERSION_CODE, "application_class": ".app.application.dev.DeveloperOppiaApplication", @@ -79,6 +81,7 @@ _FLAVOR_METADATA = { "production_release": True, "deps": [ "//app/src/main/java/org/oppia/android/app/application/alpha:alpha_application", + "//config/src/java/org/oppia/android/config:all_languages_config", ], "version_code": OPPIA_ALPHA_VERSION_CODE, "application_class": ".app.application.alpha.AlphaOppiaApplication", @@ -93,6 +96,7 @@ _FLAVOR_METADATA = { "production_release": True, "deps": [ "//app/src/main/java/org/oppia/android/app/application/alpha:alpha_application", + "//config/src/java/org/oppia/android/config:all_languages_config", ], "version_code": OPPIA_ALPHA_KITKAT_VERSION_CODE, "application_class": ".app.application.alpha.AlphaOppiaApplication", @@ -106,6 +110,7 @@ _FLAVOR_METADATA = { "production_release": True, "deps": [ "//app/src/main/java/org/oppia/android/app/application/alphakenya:alpha_kenya_application", + "//config/src/java/org/oppia/android/config:all_languages_config", ], "version_code": OPPIA_ALPHA_KENYA_VERSION_CODE, "application_class": ".app.application.alphakenya.AlphaKenyaOppiaApplication", @@ -119,6 +124,7 @@ _FLAVOR_METADATA = { "production_release": True, "deps": [ "//app/src/main/java/org/oppia/android/app/application/beta:beta_application", + "//config/src/java/org/oppia/android/config:production_languages_config", ], "version_code": OPPIA_BETA_VERSION_CODE, "application_class": ".app.application.beta.BetaOppiaApplication", @@ -132,6 +138,7 @@ _FLAVOR_METADATA = { "production_release": True, "deps": [ "//app/src/main/java/org/oppia/android/app/application/ga:general_availability_application", + "//config/src/java/org/oppia/android/config:production_languages_config", ], "version_code": OPPIA_GA_VERSION_CODE, "application_class": ".app.application.ga.GaOppiaApplication", diff --git a/config/src/java/org/oppia/android/config/BUILD.bazel b/config/src/java/org/oppia/android/config/BUILD.bazel index 8cc9ae4591c..cc24ce5240c 100644 --- a/config/src/java/org/oppia/android/config/BUILD.bazel +++ b/config/src/java/org/oppia/android/config/BUILD.bazel @@ -5,34 +5,55 @@ This package contains configuration libraries for defining & tweaking app-wide b 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/src/main/proto", - proto_dep_name = "languages", - proto_package = "model", - proto_type_name = "SupportedLanguages", -) +# Language support levels: +# - all: Indicates all languages/regions technically supported by the app, including ones that may +# not have complete translations. These will be included in prerelease versions of the app. +# - production: Indicates languages for which the team guarantees thorough support (generally nearly +# 100% written translations). +_LANGUAGE_SUPPORT_LEVELS = [ + "all", + "production", +] -_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/src/main/proto", - proto_dep_name = "languages", - proto_package = "model", - proto_type_name = "SupportedRegions", -) +_SUPPORTED_LANGUAGES_CONFIG_ASSETS = { + support_level: generate_proto_binary_assets( + name = "%s_supported_languages_config_assets" % support_level, + asset_dir = "%slanguages" % support_level, + name_prefix = "%s_supported_languages_config_assets" % support_level, + names = ["supported_languages"], + proto_dep_bazel_target_prefix = "//model/src/main/proto", + proto_dep_name = "languages", + proto_package = "model", + proto_type_name = "SupportedLanguages", + ) + for support_level in _LANGUAGE_SUPPORT_LEVELS +} -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__", - ], -) +_SUPPORTED_REGIONS_CONFIG_ASSETS = { + support_level: generate_proto_binary_assets( + name = "%s_supported_regions_config_assets" % support_level, + asset_dir = "%slanguages" % support_level, + name_prefix = "%s_supported_regions_config_assets" % support_level, + names = ["supported_regions"], + proto_dep_bazel_target_prefix = "//model/src/main/proto", + proto_dep_name = "languages", + proto_package = "model", + proto_type_name = "SupportedRegions", + ) + for support_level in _LANGUAGE_SUPPORT_LEVELS +} + +[ + android_library( + name = "%s_languages_config" % support_level, + assets = _SUPPORTED_LANGUAGES_CONFIG_ASSETS[support_level] + + _SUPPORTED_REGIONS_CONFIG_ASSETS[support_level], + assets_dir = "%slanguages/" % support_level, + manifest = "AndroidManifest.xml", + visibility = [ + "//:oppia_binary_visibility", + "//:oppia_testing_visibility", + ], + ) + for support_level in _LANGUAGE_SUPPORT_LEVELS +] diff --git a/config/src/java/org/oppia/android/config/languages/supported_languages.textproto b/config/src/java/org/oppia/android/config/alllanguages/supported_languages.textproto similarity index 100% rename from config/src/java/org/oppia/android/config/languages/supported_languages.textproto rename to config/src/java/org/oppia/android/config/alllanguages/supported_languages.textproto diff --git a/config/src/java/org/oppia/android/config/languages/supported_regions.textproto b/config/src/java/org/oppia/android/config/alllanguages/supported_regions.textproto similarity index 100% rename from config/src/java/org/oppia/android/config/languages/supported_regions.textproto rename to config/src/java/org/oppia/android/config/alllanguages/supported_regions.textproto diff --git a/config/src/java/org/oppia/android/config/productionlanguages/supported_languages.textproto b/config/src/java/org/oppia/android/config/productionlanguages/supported_languages.textproto new file mode 100644 index 00000000000..91f7833c04c --- /dev/null +++ b/config/src/java/org/oppia/android/config/productionlanguages/supported_languages.textproto @@ -0,0 +1,59 @@ +language_definitions { + language: ENGLISH + min_android_sdk_version: 1 + app_string_id { + ietf_bcp47_id { + ietf_language_tag: "en" + } + android_resources_language_id { + # Note that while English is the default language for Oppia & no special language code is + # needed for it, "en" is still used here for consistency. Technically this will match strings + # against "values-en", but because the team will never define strings with that qualifier they + # will always fallback to the default English strings under "values". + 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: PORTUGUESE + min_android_sdk_version: 1 + content_string_id { + ietf_bcp47_id { + ietf_language_tag: "pt" + } + } +} +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/productionlanguages/supported_regions.textproto b/config/src/java/org/oppia/android/config/productionlanguages/supported_regions.textproto new file mode 100644 index 00000000000..0400f40454c --- /dev/null +++ b/config/src/java/org/oppia/android/config/productionlanguages/supported_regions.textproto @@ -0,0 +1,22 @@ +region_definitions { + region: BRAZIL + region_id { + ietf_region_tag: "BR" + } + languages: PORTUGUESE + languages: BRAZILIAN_PORTUGUESE +} +region_definitions { + region: UNITED_STATES + region_id { + ietf_region_tag: "US" + } + languages: ENGLISH +} +region_definitions { + region: KENYA + region_id { + ietf_region_tag: "KE" + } + languages: ENGLISH +} diff --git a/domain/BUILD.bazel b/domain/BUILD.bazel index 68c59cf7560..9c5d20e740c 100755 --- a/domain/BUILD.bazel +++ b/domain/BUILD.bazel @@ -159,6 +159,7 @@ TEST_DEPS = [ "//app:crashlytics", "//app:crashlytics_deps", "//app/src/main/java/org/oppia/android/app/application/testing:testing_build_flavor_module", + "//config/src/java/org/oppia/android/config:all_languages_config", "//data/src/main/java/org/oppia/android/data/backends/gae:network_config_prod_module", "//data/src/main/java/org/oppia/android/data/backends/gae/model", "//data/src/main/java/org/oppia/android/data/persistence:cache_store", diff --git a/domain/build.gradle b/domain/build.gradle index 43270a0bc2f..861ea4ae207 100644 --- a/domain/build.gradle +++ b/domain/build.gradle @@ -72,6 +72,7 @@ android { // exclusion in order to properly work. def filesToExclude = [ '**/*LanguageConfigRetrieverTest*.kt', + '**/*LanguageConfigRetrieverProductionTest*.kt', '**/*LocaleControllerTest*.kt', '**/*TranslationControllerTest*.kt' ] 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 index 887be6c1efe..bfa81c3fcb7 100644 --- a/domain/src/main/java/org/oppia/android/domain/locale/BUILD.bazel +++ b/domain/src/main/java/org/oppia/android/domain/locale/BUILD.bazel @@ -50,7 +50,6 @@ kt_android_library( ], deps = [ ":dagger", - "//config/src/java/org/oppia/android/config:languages_config", "//model/src/main/proto: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", diff --git a/domain/src/test/java/org/oppia/android/domain/exploration/BUILD.bazel b/domain/src/test/java/org/oppia/android/domain/exploration/BUILD.bazel index 82a546b4876..b1a471f192b 100644 --- a/domain/src/test/java/org/oppia/android/domain/exploration/BUILD.bazel +++ b/domain/src/test/java/org/oppia/android/domain/exploration/BUILD.bazel @@ -56,6 +56,7 @@ oppia_android_test( test_manifest = "//domain:test_manifest", deps = [ ":dagger", + "//config/src/java/org/oppia/android/config:all_languages_config", "//domain", "//domain/src/main/java/org/oppia/android/domain/classify:interactions_module", "//domain/src/main/java/org/oppia/android/domain/classify/rules/algebraicexpressioninput:algebraic_expression_input_rule_module", 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 index bf2c5a0a70b..b631b4f16e3 100644 --- a/domain/src/test/java/org/oppia/android/domain/locale/BUILD.bazel +++ b/domain/src/test/java/org/oppia/android/domain/locale/BUILD.bazel @@ -13,6 +13,7 @@ oppia_android_test( test_manifest = "//domain:test_manifest", deps = [ ":dagger", + "//config/src/java/org/oppia/android/config:all_languages_config", "//domain/src/main/java/org/oppia/android/domain/locale:content_locale_impl", "//model/src/main/proto:languages_java_proto_lite", "//third_party:androidx_test_ext_junit", @@ -32,6 +33,32 @@ oppia_android_test( test_manifest = "//domain:test_manifest", deps = [ ":dagger", + "//config/src/java/org/oppia/android/config:all_languages_config", + "//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", + "//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/caching:asset_prod_module", + "//utility/src/main/java/org/oppia/android/util/locale:prod_module", + "//utility/src/main/java/org/oppia/android/util/logging:prod_module", + ], +) + +oppia_android_test( + name = "LanguageConfigRetrieverProductionTest", + srcs = ["LanguageConfigRetrieverProductionTest.kt"], + custom_package = "org.oppia.android.domain.locale", + test_class = "org.oppia.android.domain.locale.LanguageConfigRetrieverProductionTest", + test_manifest = "//domain:test_manifest", + deps = [ + ":dagger", + "//config/src/java/org/oppia/android/config:production_languages_config", "//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", @@ -56,6 +83,7 @@ oppia_android_test( test_manifest = "//domain:test_manifest", deps = [ ":dagger", + "//config/src/java/org/oppia/android/config:all_languages_config", "//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", @@ -77,6 +105,7 @@ oppia_android_test( test_manifest = "//domain:test_manifest", deps = [ ":dagger", + "//config/src/java/org/oppia/android/config:all_languages_config", "//domain/src/main/java/org/oppia/android/domain/locale:locale_controller", "//domain/src/main/java/org/oppia/android/domain/oppialogger:prod_module", "//domain/src/main/java/org/oppia/android/domain/oppialogger/analytics:prod_module", diff --git a/domain/src/test/java/org/oppia/android/domain/locale/LanguageConfigRetrieverProductionTest.kt b/domain/src/test/java/org/oppia/android/domain/locale/LanguageConfigRetrieverProductionTest.kt new file mode 100644 index 00000000000..fd2d49a9e45 --- /dev/null +++ b/domain/src/test/java/org/oppia/android/domain/locale/LanguageConfigRetrieverProductionTest.kt @@ -0,0 +1,247 @@ +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.testing.time.FakeOppiaClockModule +import org.oppia.android.util.caching.AssetModule +import org.oppia.android.util.locale.LocaleProdModule +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] that is restricted to only production-supported languages & + * regions. + */ +// FunctionName: test names are conventionally named with underscores. +@Suppress("FunctionName") +@RunWith(AndroidJUnit4::class) +@LooperMode(LooperMode.Mode.PAUSED) +@Config(manifest = Config.NONE) +class LanguageConfigRetrieverProductionTest { + @Inject lateinit var languageConfigRetriever: LanguageConfigRetriever + + @Before + fun setUp() { + setUpTestApplicationComponent() + } + + @Test + fun testLoadSupportedLanguages_loadsNonDefaultProtoFromAssets() { + val supportedLanguages = languageConfigRetriever.loadSupportedLanguages() + + assertThat(supportedLanguages).isNotEqualToDefaultInstance() + } + + @Test + fun testLoadSupportedLanguages_hasThreeSupportedLanguages() { + 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(3) + } + + @Test + fun testLoadSupportedLanguages_arabic_isNotSupported() { + val supportedLanguages = languageConfigRetriever.loadSupportedLanguages() + + val allLanguages = supportedLanguages.languageDefinitionsList.map { it.language } + assertThat(allLanguages).doesNotContain(OppiaLanguage.ARABIC) + } + + @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_isNotSupported() { + val supportedLanguages = languageConfigRetriever.loadSupportedLanguages() + + val allLanguages = supportedLanguages.languageDefinitionsList.map { it.language } + assertThat(allLanguages).doesNotContain(OppiaLanguage.HINDI) + } + + @Test + fun testLoadSupportedLanguages_hinglish_isNotSupported() { + val supportedLanguages = languageConfigRetriever.loadSupportedLanguages() + + val allLanguages = supportedLanguages.languageDefinitionsList.map { it.language } + assertThat(allLanguages).doesNotContain(OppiaLanguage.HINGLISH) + } + + @Test + fun testLoadSupportedLanguages_portuguese_hasOnlyContentStringSupport() { + val supportedLanguages = languageConfigRetriever.loadSupportedLanguages() + + val definition = supportedLanguages.lookUpLanguage(OppiaLanguage.PORTUGUESE) + assertThat(definition.hasAppStringId()).isFalse() + assertThat(definition.hasContentStringId()).isTrue() + 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 testLoadSupportedLangs_swahili_isNotSupported() { + val supportedLanguages = languageConfigRetriever.loadSupportedLanguages() + + val allLanguages = supportedLanguages.languageDefinitionsList.map { it.language } + assertThat(allLanguages).doesNotContain(OppiaLanguage.SWAHILI) + } + + @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_isNotSupported() { + val supportedRegions = languageConfigRetriever.loadSupportedRegions() + + val allRegions = supportedRegions.regionDefinitionsList.map { it.region } + assertThat(allRegions).doesNotContain(OppiaRegion.INDIA) + } + + @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) + } + + @Test + fun testLoadSupportedRegions_kenya_hasCorrectRegionIdAndSupportedLanguages() { + val supportedRegions = languageConfigRetriever.loadSupportedRegions() + + val definition = supportedRegions.lookUpRegion(OppiaRegion.KENYA) + assertThat(definition.regionId.ietfRegionTag).isEqualTo("KE") + 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() { + DaggerLanguageConfigRetrieverProductionTest_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, LocaleProdModule::class, FakeOppiaClockModule::class + ] + ) + interface TestApplicationComponent { + @Component.Builder + interface Builder { + @BindsInstance + fun setApplication(application: Application): Builder + + fun build(): TestApplicationComponent + } + + fun inject(test: LanguageConfigRetrieverProductionTest) + } +} 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 index 19953c7d198..cbfb8957e4b 100644 --- a/domain/src/test/java/org/oppia/android/domain/locale/LanguageConfigRetrieverTest.kt +++ b/domain/src/test/java/org/oppia/android/domain/locale/LanguageConfigRetrieverTest.kt @@ -30,15 +30,18 @@ import org.robolectric.annotation.LooperMode import javax.inject.Inject import javax.inject.Singleton -/** Tests for [LanguageConfigRetriever]. */ +/** + * Tests for [LanguageConfigRetriever] using all languages & regions supported by the app. + * + * See [LanguageConfigRetrieverProductionTest] for production-specific tests. + */ // 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 + @Inject lateinit var languageConfigRetriever: LanguageConfigRetriever @Before fun setUp() { @@ -168,6 +171,22 @@ class LanguageConfigRetrieverTest { assertThat(definition.audioTranslationId.ietfBcp47Id.ietfLanguageTag).isEqualTo("pt-BR") } + @Test + fun testLoadSupportedLangs_swahili_isSupportedForAppContentAudioTranslations() { + val supportedLanguages = languageConfigRetriever.loadSupportedLanguages() + + val definition = supportedLanguages.lookUpLanguage(OppiaLanguage.SWAHILI) + 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("sw") + assertThat(definition.appStringId.androidResourcesLanguageId.languageCode).isEqualTo("sw") + assertThat(definition.appStringId.androidResourcesLanguageId.regionCode).isEmpty() + assertThat(definition.contentStringId.ietfBcp47Id.ietfLanguageTag).isEqualTo("sw") + assertThat(definition.audioTranslationId.ietfBcp47Id.ietfLanguageTag).isEqualTo("sw") + } + @Test fun testLoadSupportedRegions_loadsNonDefaultProtoFromAssets() { val supportedRegions = languageConfigRetriever.loadSupportedRegions() @@ -214,6 +233,16 @@ class LanguageConfigRetrieverTest { assertThat(definition.languagesList).containsExactly(OppiaLanguage.ENGLISH) } + @Test + fun testLoadSupportedRegions_kenya_hasCorrectRegionIdAndSupportedLanguages() { + val supportedRegions = languageConfigRetriever.loadSupportedRegions() + + val definition = supportedRegions.lookUpRegion(OppiaRegion.KENYA) + assertThat(definition.regionId.ietfRegionTag).isEqualTo("KE") + assertThat(definition.languagesList) + .containsExactly(OppiaLanguage.ENGLISH, OppiaLanguage.SWAHILI) + } + private fun SupportedLanguages.lookUpLanguage( language: OppiaLanguage ): LanguageSupportDefinition { diff --git a/domain/src/test/java/org/oppia/android/domain/question/BUILD.bazel b/domain/src/test/java/org/oppia/android/domain/question/BUILD.bazel index e40ad2c2fc0..3989ef637a4 100644 --- a/domain/src/test/java/org/oppia/android/domain/question/BUILD.bazel +++ b/domain/src/test/java/org/oppia/android/domain/question/BUILD.bazel @@ -13,6 +13,7 @@ oppia_android_test( test_manifest = "//domain:test_manifest", deps = [ ":dagger", + "//config/src/java/org/oppia/android/config:all_languages_config", "//domain", "//domain/src/main/java/org/oppia/android/domain/classify:interactions_module", "//domain/src/main/java/org/oppia/android/domain/classify/rules/algebraicexpressioninput:algebraic_expression_input_rule_module", 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 index a589b1c6c51..1336cab8f7a 100644 --- a/domain/src/test/java/org/oppia/android/domain/translation/BUILD.bazel +++ b/domain/src/test/java/org/oppia/android/domain/translation/BUILD.bazel @@ -13,6 +13,7 @@ oppia_android_test( test_manifest = "//domain:test_manifest", deps = [ ":dagger", + "//config/src/java/org/oppia/android/config:all_languages_config", "//domain/src/main/java/org/oppia/android/domain/locale:locale_controller", "//domain/src/main/java/org/oppia/android/domain/oppialogger:prod_module", "//domain/src/main/java/org/oppia/android/domain/oppialogger/analytics:prod_module", diff --git a/model/src/main/proto/BUILD.bazel b/model/src/main/proto/BUILD.bazel index c6e8e9fe803..5e896022364 100644 --- a/model/src/main/proto/BUILD.bazel +++ b/model/src/main/proto/BUILD.bazel @@ -6,7 +6,7 @@ The oppia_proto_library() rule creates a proto file library to be used in multip The java_lite_proto_library() rule takes in a proto_library target and generates java code. """ -load("@rules_java//java:defs.bzl", "java_lite_proto_library") +load("@rules_java//java:defs.bzl", "java_lite_proto_library", "java_proto_library") load("//model:oppia_proto_library.bzl", "oppia_proto_library") # NOTE TO DEVELOPERS: When adding new protos, each proto will need to have both a proto_library @@ -95,6 +95,12 @@ oppia_proto_library( visibility = ["//:oppia_api_visibility"], ) +java_proto_library( + name = "languages_java_proto", + visibility = ["//scripts:oppia_script_library_visibility"], + deps = [":languages_proto"], +) + java_lite_proto_library( name = "languages_java_proto_lite", visibility = ["//:oppia_api_visibility"], diff --git a/oppia_android_application.bzl b/oppia_android_application.bzl index 2627fb86b8c..c1539e28e7e 100644 --- a/oppia_android_application.bzl +++ b/oppia_android_application.bzl @@ -69,6 +69,26 @@ def _convert_module_aab_to_structured_zip_impl(ctx): runfiles = ctx.runfiles(files = [output_file]), ) +def _restrict_languages_in_raw_module_zip_impl(ctx): + input_file = ctx.file.input_file + output_file = ctx.outputs.output_file + + arguments = ctx.actions.args() + arguments.add(input_file) + arguments.add(output_file) + ctx.actions.run( + outputs = [output_file], + inputs = [input_file], + tools = [ctx.executable._filter_per_language_resources_tool], + executable = ctx.executable._filter_per_language_resources_tool.path, + arguments = [arguments], + ) + + return DefaultInfo( + files = depset([output_file]), + runfiles = ctx.runfiles(files = [output_file]), + ) + def _bundle_module_zip_into_deployable_aab_impl(ctx): output_file = ctx.outputs.output_file input_file = ctx.attr.input_file.files.to_list()[0] @@ -226,6 +246,24 @@ _convert_module_aab_to_structured_zip = rule( implementation = _convert_module_aab_to_structured_zip_impl, ) +_restrict_languages_in_raw_module_zip = rule( + attrs = { + "input_file": attr.label( + allow_single_file = True, + mandatory = True, + ), + "output_file": attr.output( + mandatory = True, + ), + "_filter_per_language_resources_tool": attr.label( + executable = True, + cfg = "host", + default = "//scripts:filter_per_language_resources", + ), + }, + implementation = _restrict_languages_in_raw_module_zip_impl, +) + _bundle_module_zip_into_deployable_aab = rule( attrs = { "input_file": attr.label( @@ -300,7 +338,8 @@ def oppia_android_application(name, config_file, proguard_generate_mapping, **kw """ binary_name = "%s_binary" % name module_aab_name = "%s_module_aab" % name - module_zip_name = "%s_module_zip" % name + raw_module_zip_name = "%s_raw_module_zip" % name + language_restricted_module_zip_name = "%s_lang_restricted_module_zip" % name deployable_aab_name = "%s_deployable" % name native.android_binary( name = binary_name, @@ -315,15 +354,21 @@ def oppia_android_application(name, config_file, proguard_generate_mapping, **kw tags = ["manual"], ) _convert_module_aab_to_structured_zip( - name = module_zip_name, + name = raw_module_zip_name, input_file = ":%s.aab" % module_aab_name, - output_file = "%s.zip" % module_zip_name, + output_file = "%s.zip" % raw_module_zip_name, + tags = ["manual"], + ) + _restrict_languages_in_raw_module_zip( + name = language_restricted_module_zip_name, + input_file = "%s.zip" % raw_module_zip_name, + output_file = "%s.zip" % language_restricted_module_zip_name, tags = ["manual"], ) if proguard_generate_mapping: _bundle_module_zip_into_deployable_aab( name = deployable_aab_name, - input_file = ":%s.zip" % module_zip_name, + input_file = ":%s.zip" % language_restricted_module_zip_name, config_file = config_file, output_file = "%s.aab" % deployable_aab_name, tags = ["manual"], @@ -339,7 +384,7 @@ def oppia_android_application(name, config_file, proguard_generate_mapping, **kw # No extra package step is needed if there's no Proguard map file. _bundle_module_zip_into_deployable_aab( name = name, - input_file = ":%s.zip" % module_zip_name, + input_file = ":%s.zip" % language_restricted_module_zip_name, config_file = config_file, output_file = "%s.aab" % name, tags = ["manual"], diff --git a/scripts/BUILD.bazel b/scripts/BUILD.bazel index 4e8fca5d41e..50a0cbf452f 100644 --- a/scripts/BUILD.bazel +++ b/scripts/BUILD.bazel @@ -240,3 +240,14 @@ java_binary( "//scripts/src/java/org/oppia/android/scripts/build:transform_android_manifest_lib", ], ) + +java_binary( + name = "filter_per_language_resources", + main_class = "org.oppia.android.scripts.build.FilterPerLanguageResourcesKt", + visibility = [ + "//:oppia_binary_visibility", + ], + runtime_deps = [ + "//scripts/src/java/org/oppia/android/scripts/build:filter_per_language_resources_lib", + ], +) diff --git a/scripts/src/java/org/oppia/android/scripts/build/BUILD.bazel b/scripts/src/java/org/oppia/android/scripts/build/BUILD.bazel index 92ad121078e..9daa0c3551a 100644 --- a/scripts/src/java/org/oppia/android/scripts/build/BUILD.bazel +++ b/scripts/src/java/org/oppia/android/scripts/build/BUILD.bazel @@ -4,6 +4,16 @@ Libraries corresponding to build pipeline scripts. load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_jvm_library") +kt_jvm_library( + name = "filter_per_language_resources_lib", + srcs = ["FilterPerLanguageResources.kt"], + visibility = ["//scripts:oppia_script_binary_visibility"], + deps = [ + "//model/src/main/proto:languages_java_proto", + "//third_party:com_android_tools_build_aapt2-proto", + ], +) + kt_jvm_library( name = "transform_android_manifest_lib", srcs = ["TransformAndroidManifest.kt"], diff --git a/scripts/src/java/org/oppia/android/scripts/build/FilterPerLanguageResources.kt b/scripts/src/java/org/oppia/android/scripts/build/FilterPerLanguageResources.kt new file mode 100644 index 00000000000..5da2e00c56b --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/build/FilterPerLanguageResources.kt @@ -0,0 +1,150 @@ +package org.oppia.android.scripts.build + +import com.android.aapt.Resources.ConfigValue +import com.android.aapt.Resources.Entry +import com.android.aapt.Resources.Package +import com.android.aapt.Resources.ResourceTable +import com.android.aapt.Resources.Type +import org.oppia.android.app.model.LanguageSupportDefinition +import org.oppia.android.app.model.SupportedLanguages +import java.io.File +import java.util.Locale +import java.util.zip.ZipEntry +import java.util.zip.ZipFile +import java.util.zip.ZipOutputStream + +/** + * Script for filtering unsupported languages out of Android app bundle resource tables. + * + * Usage: + * bazel run //scripts:filter_per_language_resources -- \\ + * + * + * The input module is expected to be a well-formed AAB module zip archive, and include a + * support_languages.pb asset to provide guidance on which languages should be kept. + * + * Arguments: + * - path_to_input_module: path to the input AAB module zip to be processed. + * - path_to_output_module: path to the output AAB module zip that will be written. + * + * Example: + * bazel run //scripts:filter_per_language_resources -- \\ + * $(pwd)/bazel-bin/oppia_dev_raw_module.zip $(pwd)/oppia_dev_updated_module.zip + */ +fun main(vararg args: String) { + require(args.size == 2) { + "Usage: bazel run //scripts:filter_per_language_resources --" + + " " + + " " + } + FilterPerLanguageResources().filterPerLanguageResources( + inputModuleZip = File(args[0]), outputModuleZip = File(args[1]) + ) +} + +private class FilterPerLanguageResources { + /** + * Filters the resources for the given input module & writes an updated copy of it to the + * specified output module file. + */ + fun filterPerLanguageResources(inputModuleZip: File, outputModuleZip: File) { + val (resourceTable, supportedLanguages) = ZipFile(inputModuleZip).use { zipFile -> + val resourceTableEntry = checkNotNull(zipFile.getEntry("resources.pb")) { + "Expected resources.pb in input zip file: $inputModuleZip." + } + val supportedLangsEntry = checkNotNull(zipFile.getEntry("assets/supported_languages.pb")) { + "Expected assets/supported_languages.pb in input zip file: $inputModuleZip." + } + val resourceTableData = zipFile.getInputStream(resourceTableEntry).readBytes() + val supportedLanguages = zipFile.getInputStream(supportedLangsEntry).readBytes() + ResourceTable.parseFrom(resourceTableData) to SupportedLanguages.parseFrom(supportedLanguages) + } + val pkg = resourceTable.packageList.single().also { + check(it.packageName == "org.oppia.android") { + "Expected Oppia package, not: ${it.packageName}." + } + } + + val allReferencedLanguageCodes = + pkg.typeList.flatMap { it.entryList } + .flatMap { it.configValueList } + .map { it.config } + .map { it.locale } + .toSortedSet() + val supportedLanguageCodes = + supportedLanguages.languageDefinitionsList.mapNotNull { + it.toAndroidBcp47Locale() + }.toSortedSet() + val removedLanguageCodes = allReferencedLanguageCodes - supportedLanguageCodes + val updatedResourceTable = resourceTable.recompute(supportedLanguageCodes) + println( + "${removedLanguageCodes.size} resources are being removed that are tied to unsupported" + + " languages: $removedLanguageCodes (size reduction:" + + " ${resourceTable.serializedSize - updatedResourceTable.serializedSize} bytes)." + ) + + ZipOutputStream(outputModuleZip.outputStream()).use { outputStream -> + ZipFile(inputModuleZip).use { zipFile -> + zipFile.entries().asSequence().forEach { entry -> + outputStream.putNextEntry(ZipEntry(entry.name)) + if (entry.name == "resources.pb") { + updatedResourceTable.writeTo(outputStream) + } else zipFile.getInputStream(entry).use { it.copyTo(outputStream) } + } + } + } + } + + private fun ResourceTable.recompute(allowedLanguageCodes: Set): ResourceTable { + val updatedPackages = packageList.mapNotNull { it.recompute(allowedLanguageCodes) } + return toBuilder().apply { + clearPackage() + addAllPackage(updatedPackages) + }.build() + } + + private fun Package.recompute(allowedLanguageCodes: Set): Package? { + val updatedTypes = typeList.mapNotNull { it.recompute(allowedLanguageCodes) } + return if (updatedTypes.isNotEmpty()) { + toBuilder().apply { + clearType() + addAllType(updatedTypes) + }.build() + } else null + } + + private fun Type.recompute(allowedLanguageCodes: Set): Type? { + val updatedEntries = entryList.mapNotNull { it.recompute(allowedLanguageCodes) } + return if (updatedEntries.isNotEmpty()) { + toBuilder().apply { + clearEntry() + addAllEntry(updatedEntries) + }.build() + } else null + } + + private fun Entry.recompute(allowedLanguageCodes: Set): Entry? { + val updatedConfigValues = configValueList.filter { it.isKept(allowedLanguageCodes) } + return if (updatedConfigValues.isNotEmpty()) { + toBuilder().apply { + clearConfigValue() + addAllConfigValue(updatedConfigValues) + }.build() + } else null + } + + private fun ConfigValue.isKept(allowedLanguageCodes: Set) = + config.locale in allowedLanguageCodes + + private fun LanguageSupportDefinition.toAndroidBcp47Locale(): String? { + val androidLanguageId = appStringId.androidResourcesLanguageId + val language = androidLanguageId.languageCode.toLowerCase(Locale.US) + val region = androidLanguageId.regionCode.toUpperCase(Locale.US) + return when { + language.isEmpty() -> null // Unsupported. + language == "en" -> "" // English is the default language code on Android. + region.isEmpty() -> language + else -> "$language-$region" + } + } +} diff --git a/scripts/src/javatests/org/oppia/android/scripts/build/BUILD.bazel b/scripts/src/javatests/org/oppia/android/scripts/build/BUILD.bazel index 74b03d588bf..572ba1f87ef 100644 --- a/scripts/src/javatests/org/oppia/android/scripts/build/BUILD.bazel +++ b/scripts/src/javatests/org/oppia/android/scripts/build/BUILD.bazel @@ -4,6 +4,17 @@ Tests corresponding to build pipeline scripts. load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_jvm_test") +kt_jvm_test( + name = "FilterPerLanguageResourcesTest", + srcs = ["FilterPerLanguageResourcesTest.kt"], + deps = [ + "//scripts/src/java/org/oppia/android/scripts/build:filter_per_language_resources_lib", + "//testing:assertion_helpers", + "//third_party:com_google_truth_truth", + "//third_party:junit_junit", + ], +) + kt_jvm_test( name = "TransformAndroidManifestTest", srcs = ["TransformAndroidManifestTest.kt"], diff --git a/scripts/src/javatests/org/oppia/android/scripts/build/FilterPerLanguageResourcesTest.kt b/scripts/src/javatests/org/oppia/android/scripts/build/FilterPerLanguageResourcesTest.kt new file mode 100644 index 00000000000..4b12a0ee953 --- /dev/null +++ b/scripts/src/javatests/org/oppia/android/scripts/build/FilterPerLanguageResourcesTest.kt @@ -0,0 +1,348 @@ +package org.oppia.android.scripts.build + +import com.android.aapt.ConfigurationOuterClass.Configuration +import com.android.aapt.Resources +import com.android.aapt.Resources.ConfigValue +import com.android.aapt.Resources.Entry +import com.android.aapt.Resources.Item +import com.android.aapt.Resources.Package +import com.android.aapt.Resources.ResourceTable +import com.android.aapt.Resources.Type +import com.android.aapt.Resources.Value +import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.oppia.android.app.model.LanguageSupportDefinition +import org.oppia.android.app.model.LanguageSupportDefinition.AndroidLanguageId +import org.oppia.android.app.model.LanguageSupportDefinition.LanguageId +import org.oppia.android.app.model.OppiaLanguage +import org.oppia.android.app.model.SupportedLanguages +import org.oppia.android.scripts.build.FilterPerLanguageResourcesTest.Resource.ColorResource +import org.oppia.android.scripts.build.FilterPerLanguageResourcesTest.Resource.StringResource +import org.oppia.android.testing.assertThrows +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.PrintStream +import java.util.zip.ZipEntry +import java.util.zip.ZipFile +import java.util.zip.ZipOutputStream + +/** Tests for the filter_per_language_resources utility. */ +// PrivatePropertyName: it's valid to have private vals in constant case if they're true constants. +// FunctionName: test names are conventionally named with underscores. +// Same parameter value: helpers reduce test context, even if they are used by 1 test. +@Suppress("PrivatePropertyName", "FunctionName", "SameParameterValue") +class FilterPerLanguageResourcesTest { + private val USAGE_STRING = + "Usage: bazel run //scripts:filter_per_language_resources --" + + " " + + " " + + private val STR_RESOURCE_0_EN = StringResource(mapOf("" to "en str0")) + private val STR_RESOURCE_1_EN_PT = StringResource(mapOf("" to "en str1", "pt-BR" to "pt str1")) + private val STR_RESOURCE_2_EN_SW = StringResource(mapOf("" to "en str2", "sw" to "sw str2")) + private val COLOR_RESOURCE_0_EN_PT = ColorResource(mapOf("" to "0xDEF", "pt-BR" to "0xABC")) + private val RESOURCE_TABLE_EN_PT_SW = + createResourceTable( + STR_RESOURCE_0_EN, STR_RESOURCE_1_EN_PT, STR_RESOURCE_2_EN_SW, COLOR_RESOURCE_0_EN_PT + ) + + private val ENGLISH = + createLanguageSupportDefinition(language = OppiaLanguage.ENGLISH, languageCode = "en") + private val BRAZILIAN_PORTUGUESE = + createLanguageSupportDefinition( + language = OppiaLanguage.BRAZILIAN_PORTUGUESE, languageCode = "pt", regionCode = "br" + ) + private val SWAHILI = + createLanguageSupportDefinition(language = OppiaLanguage.SWAHILI, languageCode = "sw") + private val ARABIC = + createLanguageSupportDefinition(language = OppiaLanguage.ARABIC, languageCode = "ar") + private val SUPPORTED_LANGUAGES_EN = createSupportedLanguages(ENGLISH) + private val SUPPORTED_LANGUAGES_EN_PT = createSupportedLanguages(ENGLISH, BRAZILIAN_PORTUGUESE) + private val SUPPORTED_LANGUAGES_EN_PT_SW = + createSupportedLanguages(ENGLISH, BRAZILIAN_PORTUGUESE, SWAHILI) + private val SUPPORTED_LANGUAGES_EN_AR = createSupportedLanguages(ENGLISH, ARABIC) + + @field:[Rule JvmField] var tempFolder = TemporaryFolder() + + private val outContent: ByteArrayOutputStream = ByteArrayOutputStream() + private val originalOut: PrintStream = System.out + + @Before + fun setUp() { + System.setOut(PrintStream(outContent)) + } + + @After + fun restoreStreams() { + System.setOut(originalOut) + } + + @Test + fun testUtility_noArgs_failsWithUsageString() { + val error = assertThrows(IllegalArgumentException::class) { runScript() } + + assertThat(error).hasMessageThat().contains(USAGE_STRING) + } + + @Test + fun testUtility_oneArg_failsWithUsageString() { + val error = assertThrows(IllegalArgumentException::class) { runScript("first_file.zip") } + + assertThat(error).hasMessageThat().contains(USAGE_STRING) + } + + @Test + fun testUtility_threeArgs_failsWithUsageString() { + val error = assertThrows(IllegalArgumentException::class) { + runScript( + tempFolder.getFilePath("input.zip"), tempFolder.getFilePath("output.zip"), "extra_param" + ) + } + + assertThat(error).hasMessageThat().contains(USAGE_STRING) + } + + @Test + fun testUtility_resourceTableProtoMissingInZip_throwsFailure() { + // Create an empty zip file. + ZipOutputStream(File(tempFolder.root, "input.zip").outputStream()).close() + + val error = assertThrows(IllegalStateException::class) { + runScript(tempFolder.getFilePath("input.zip"), tempFolder.getFilePath("output.zip")) + } + + assertThat(error).hasMessageThat().contains("Expected resources.pb in input zip file") + } + + @Test + fun testUtility_supportedLanguagesProtoMissingInZip_throwsFailure() { + // Create a zip file with only resources.pb. + ZipOutputStream(File(tempFolder.root, "input.zip").outputStream()).use { outputStream -> + outputStream.putNextEntry(ZipEntry("resources.pb")) + ResourceTable.getDefaultInstance().writeTo(outputStream) + } + + val error = assertThrows(IllegalStateException::class) { + runScript(tempFolder.getFilePath("input.zip"), tempFolder.getFilePath("output.zip")) + } + + assertThat(error) + .hasMessageThat() + .contains("Expected assets/supported_languages.pb in input zip file") + } + + @Test + fun testUtility_resourceTableIncludesNonOppiaResources_throwsFailure() { + // Create a resource table with another app's resources. + createZipWith( + fileName = "input.zip", + resourceTable = createResourceTable(packageName = "some.other.app"), + supportedLanguages = SUPPORTED_LANGUAGES_EN + ) + + val error = assertThrows(IllegalStateException::class) { + runScript(tempFolder.getFilePath("input.zip"), tempFolder.getFilePath("output.zip")) + } + + assertThat(error).hasMessageThat().contains("Expected Oppia package, not: some.other.app.") + } + + @Test + fun testUtility_resourceTable_noSupportedLanguages_keepsOnlyEnglish() { + // "No supported languages" always implies English (since English must be supported). + createZipWith( + fileName = "input.zip", + resourceTable = RESOURCE_TABLE_EN_PT_SW, + supportedLanguages = SUPPORTED_LANGUAGES_EN + ) + + runScript(tempFolder.getFilePath("input.zip"), tempFolder.getFilePath("output.zip")) + + // Only English should be kept. + val presentLanguages = readSupportedResourceLanguagesFromZip(fileName = "output.zip") + assertThat(presentLanguages).containsExactly("") + } + + @Test + fun testUtility_resourceTable_onlyPortugueseSupported_keepsOnlyEnglishAndPortuguese() { + // "No supported languages" always implies English (since English must be supported). + createZipWith( + fileName = "input.zip", + resourceTable = RESOURCE_TABLE_EN_PT_SW, + supportedLanguages = SUPPORTED_LANGUAGES_EN_PT + ) + + runScript(tempFolder.getFilePath("input.zip"), tempFolder.getFilePath("output.zip")) + + // Only English & Portuguese resources should be kept. + val presentLanguages = readSupportedResourceLanguagesFromZip(fileName = "output.zip") + assertThat(presentLanguages).containsExactly("", "pt-BR") + } + + @Test + fun testUtility_resourceTable_allSupportedLanguages_keepsEverything() { + // "No supported languages" always implies English (since English must be supported). + createZipWith( + fileName = "input.zip", + resourceTable = RESOURCE_TABLE_EN_PT_SW, + supportedLanguages = SUPPORTED_LANGUAGES_EN_PT_SW + ) + + runScript(tempFolder.getFilePath("input.zip"), tempFolder.getFilePath("output.zip")) + + // All resources should be kept. + val presentLanguages = readSupportedResourceLanguagesFromZip(fileName = "output.zip") + assertThat(presentLanguages).containsExactly("", "pt-BR", "sw") + } + + @Test + fun testUtility_resourceTable_supportedLanguageNotInTable_ignoresExtraLanguage() { + // "No supported languages" always implies English (since English must be supported). + createZipWith( + fileName = "input.zip", + resourceTable = RESOURCE_TABLE_EN_PT_SW, + supportedLanguages = SUPPORTED_LANGUAGES_EN_AR + ) + + runScript(tempFolder.getFilePath("input.zip"), tempFolder.getFilePath("output.zip")) + + // Arabic is ignored since there are no strings to be removed in the table. + val presentLanguages = readSupportedResourceLanguagesFromZip(fileName = "output.zip") + assertThat(presentLanguages).containsExactly("") + } + + @Test + fun testUtility_whenResourcesAreRemoved_printsDiagnosticInformation() { + // "No supported languages" always implies English (since English must be supported). + createZipWith( + fileName = "input.zip", + resourceTable = RESOURCE_TABLE_EN_PT_SW, + supportedLanguages = SUPPORTED_LANGUAGES_EN + ) + + runScript(tempFolder.getFilePath("input.zip"), tempFolder.getFilePath("output.zip")) + + val outputLine = readStandardOutputLines().single() + assertThat(outputLine) + .isEqualTo( + "2 resources are being removed that are tied to unsupported languages: [pt-BR, sw] (size" + + " reduction: 73 bytes)." + ) + } + + private fun createLanguageSupportDefinition( + language: OppiaLanguage, + languageCode: String, + regionCode: String = "" + ): LanguageSupportDefinition { + return LanguageSupportDefinition.newBuilder().apply { + this.language = language + appStringId = LanguageId.newBuilder().apply { + androidResourcesLanguageId = AndroidLanguageId.newBuilder().apply { + this.languageCode = languageCode + this.regionCode = regionCode + }.build() + }.build() + }.build() + } + + private fun createSupportedLanguages( + vararg languageDefinitions: LanguageSupportDefinition + ): SupportedLanguages { + return SupportedLanguages.newBuilder().apply { + addAllLanguageDefinitions(languageDefinitions.toList()) + }.build() + } + + private fun createResourceTable( + vararg resources: Resource, + packageName: String = "org.oppia.android" + ): ResourceTable { + return ResourceTable.newBuilder().apply { + addPackage( + Package.newBuilder().apply { + this.packageName = packageName + addAllType(resources.map { it.toType() }) + } + ) + }.build() + } + + private fun Resource.toType(): Type { + return Type.newBuilder().apply { + name = when (this@toType) { + is ColorResource -> "color" + is StringResource -> "string" + } + + addEntry( + Entry.newBuilder().apply { + addAllConfigValue( + configurations.map { (languageCode, strValue) -> + ConfigValue.newBuilder().apply { + config = Configuration.newBuilder().apply { + locale = languageCode + }.build() + value = Value.newBuilder().apply { + item = Item.newBuilder().apply { + str = Resources.String.newBuilder().apply { + value = strValue + }.build() + }.build() + }.build() + }.build() + } + ) + } + ) + }.build() + } + + private fun createZipWith( + fileName: String, + resourceTable: ResourceTable, + supportedLanguages: SupportedLanguages + ) { + val destZipFile = tempFolder.newFile(fileName) + ZipOutputStream(destZipFile.outputStream()).use { outputStream -> + outputStream.putNextEntry(ZipEntry("resources.pb")) + resourceTable.writeTo(outputStream) + + outputStream.putNextEntry(ZipEntry("assets/supported_languages.pb")) + supportedLanguages.writeTo(outputStream) + } + } + + private fun readSupportedResourceLanguagesFromZip(fileName: String): Set { + val resourceTable = ZipFile(File(tempFolder.root, fileName)).use { zipFile -> + zipFile.getInputStream(zipFile.getEntry("resources.pb")).use { inputStream -> + ResourceTable.parseFrom(inputStream) + } + } + return resourceTable.packageList.flatMap { it.typeList } + .flatMap { it.entryList } + .flatMap { it.configValueList } + .map { it.config.locale } + .toSet() + } + + private fun TemporaryFolder.getFilePath(fileName: String): String = + File(root, fileName).absoluteFile.normalize().path + + private fun readStandardOutputLines(): List = + outContent.toByteArray().inputStream().bufferedReader().readLines() + + private fun runScript(vararg args: String) = main(*args) + + private sealed class Resource { + abstract val configurations: Map + + data class ColorResource(override val configurations: Map) : Resource() + + data class StringResource(override val configurations: Map) : Resource() + } +} diff --git a/third_party/maven_install.json b/third_party/maven_install.json index d743a3bb8ee..3522dd4d415 100644 --- a/third_party/maven_install.json +++ b/third_party/maven_install.json @@ -1,8 +1,8 @@ { "dependency_tree": { "__AUTOGENERATED_FILE_DO_NOT_MODIFY_THIS_FILE_MANUALLY": "THERE_IS_NO_DATA_ONLY_ZUUL", - "__INPUT_ARTIFACTS_HASH": -2010577555, - "__RESOLVED_ARTIFACTS_HASH": 6219109, + "__INPUT_ARTIFACTS_HASH": -109720896, + "__RESOLVED_ARTIFACTS_HASH": 860179600, "conflict_resolution": { "androidx.constraintlayout:constraintlayout:1.1.3": "androidx.constraintlayout:constraintlayout:2.0.1", "androidx.core:core:1.0.1": "androidx.core:core:1.3.1", @@ -4277,6 +4277,44 @@ "sha256": "b1293035a1cb88d25e3eb481b66813cb2e04fe2b3a82daca523ef1e0cded1409", "url": "https://maven.google.com/com/android/tools/build/jetifier/jetifier-core/1.0.0-beta04/jetifier-core-1.0.0-beta04-sources.jar" }, + { + "coord": "com.android.tools.build:aapt2-proto:7.3.1-8691043", + "dependencies": [ + "com.google.protobuf:protobuf-java:3.17.3" + ], + "directDependencies": [ + "com.google.protobuf:protobuf-java:3.17.3" + ], + "file": "v1/https/maven.google.com/com/android/tools/build/aapt2-proto/7.3.1-8691043/aapt2-proto-7.3.1-8691043.jar", + "mirror_urls": [ + "https://maven.google.com/com/android/tools/build/aapt2-proto/7.3.1-8691043/aapt2-proto-7.3.1-8691043.jar", + "https://repo1.maven.org/maven2/com/android/tools/build/aapt2-proto/7.3.1-8691043/aapt2-proto-7.3.1-8691043.jar", + "https://maven.fabric.io/public/com/android/tools/build/aapt2-proto/7.3.1-8691043/aapt2-proto-7.3.1-8691043.jar", + "https://maven.google.com/com/android/tools/build/aapt2-proto/7.3.1-8691043/aapt2-proto-7.3.1-8691043.jar", + "https://repo1.maven.org/maven2/com/android/tools/build/aapt2-proto/7.3.1-8691043/aapt2-proto-7.3.1-8691043.jar" + ], + "sha256": "d5e2f3e1e1eb06224b6875f5e513c72a65182342745718160caf191d46a96664", + "url": "https://maven.google.com/com/android/tools/build/aapt2-proto/7.3.1-8691043/aapt2-proto-7.3.1-8691043.jar" + }, + { + "coord": "com.android.tools.build:aapt2-proto:jar:sources:7.3.1-8691043", + "dependencies": [ + "com.google.protobuf:protobuf-java:jar:sources:3.17.3" + ], + "directDependencies": [ + "com.google.protobuf:protobuf-java:jar:sources:3.17.3" + ], + "file": "v1/https/maven.google.com/com/android/tools/build/aapt2-proto/7.3.1-8691043/aapt2-proto-7.3.1-8691043-sources.jar", + "mirror_urls": [ + "https://maven.google.com/com/android/tools/build/aapt2-proto/7.3.1-8691043/aapt2-proto-7.3.1-8691043-sources.jar", + "https://repo1.maven.org/maven2/com/android/tools/build/aapt2-proto/7.3.1-8691043/aapt2-proto-7.3.1-8691043-sources.jar", + "https://maven.fabric.io/public/com/android/tools/build/aapt2-proto/7.3.1-8691043/aapt2-proto-7.3.1-8691043-sources.jar", + "https://maven.google.com/com/android/tools/build/aapt2-proto/7.3.1-8691043/aapt2-proto-7.3.1-8691043-sources.jar", + "https://repo1.maven.org/maven2/com/android/tools/build/aapt2-proto/7.3.1-8691043/aapt2-proto-7.3.1-8691043-sources.jar" + ], + "sha256": "9887a82631d64be27278ea588bfa69ebc6a8f73564e222abab950a406653114c", + "url": "https://maven.google.com/com/android/tools/build/aapt2-proto/7.3.1-8691043/aapt2-proto-7.3.1-8691043-sources.jar" + }, { "coord": "com.android.tools:annotations:26.4.2", "dependencies": [], diff --git a/third_party/versions.bzl b/third_party/versions.bzl index 245d14f20b9..391c54d952a 100644 --- a/third_party/versions.bzl +++ b/third_party/versions.bzl @@ -48,6 +48,7 @@ MAVEN_PRODUCTION_DEPENDENCY_VERSIONS = { "androidx.work:work-runtime": "2.4.0", "androidx.work:work-runtime-ktx": "2.4.0", "com.android.support:support-annotations": "28.0.0", + "com.android.tools.build:aapt2-proto": "7.3.1-8691043", "com.crashlytics.sdk.android:crashlytics": "2.9.8", "com.github.bumptech.glide:compiler": "4.11.0", "com.github.bumptech.glide:glide": "4.11.0",