From 919ab2b6252150d7e12a7159ac1d46396d51763d Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Tue, 31 Aug 2021 23:33:02 -0700 Subject: [PATCH 01/93] Add support for AABs, build flavors, and proguard. There are a lot of details to cover here--see the PR for the complete context. --- .github/workflows/build_tests.yml | 254 ++++++++++++++ BUILD.bazel | 12 +- WORKSPACE | 9 +- app/src/main/res/raw/shrink_exemptions.xml | 4 + build_flavors.bzl | 160 +++++++++ bundle_config.pb.json | 10 + config/proguard/androidx-proguard-rules.pro | 41 +++ .../firebase-components-proguard-rules.pro | 6 + config/proguard/glide-proguard-rules.pro | 28 ++ .../google-play-services-proguard-rules.pro | 5 + config/proguard/guava-proguard-rules.pro | 63 ++++ config/proguard/kotlin-proguard-rules.pro | 7 + .../kotlinpoet-javapoet-proguard-rules.pro | 6 + config/proguard/material-proguard-rules.pro | 7 + config/proguard/moshi-proguard-rules.pro | 8 + config/proguard/okhttp-proguard-rules.pro | 12 + config/proguard/oppia-prod-proguard-rules.pro | 75 ++++ config/proguard/protobuf-proguard-rules.pro | 11 + oppia_android_application.bzl | 278 +++++++++++++++ oppia_android_test.bzl | 2 +- scripts/BUILD.bazel | 14 + .../oppia/android/scripts/build/BUILD.bazel | 14 + .../scripts/build/TransformAndroidManifest.kt | 138 ++++++++ .../oppia/android/scripts/common/BUILD.bazel | 2 - .../oppia/android/scripts/build/BUILD.bazel | 17 + .../build/TransformAndroidManifestTest.kt | 320 ++++++++++++++++++ third_party/BUILD.bazel | 7 + third_party/versions.bzl | 4 + version.bzl | 7 + 29 files changed, 1516 insertions(+), 5 deletions(-) create mode 100644 app/src/main/res/raw/shrink_exemptions.xml create mode 100644 build_flavors.bzl create mode 100644 bundle_config.pb.json create mode 100644 config/proguard/androidx-proguard-rules.pro create mode 100644 config/proguard/firebase-components-proguard-rules.pro create mode 100644 config/proguard/glide-proguard-rules.pro create mode 100644 config/proguard/google-play-services-proguard-rules.pro create mode 100644 config/proguard/guava-proguard-rules.pro create mode 100644 config/proguard/kotlin-proguard-rules.pro create mode 100644 config/proguard/kotlinpoet-javapoet-proguard-rules.pro create mode 100644 config/proguard/material-proguard-rules.pro create mode 100644 config/proguard/moshi-proguard-rules.pro create mode 100644 config/proguard/okhttp-proguard-rules.pro create mode 100644 config/proguard/oppia-prod-proguard-rules.pro create mode 100644 config/proguard/protobuf-proguard-rules.pro create mode 100644 oppia_android_application.bzl create mode 100644 scripts/src/java/org/oppia/android/scripts/build/BUILD.bazel create mode 100644 scripts/src/java/org/oppia/android/scripts/build/TransformAndroidManifest.kt create mode 100644 scripts/src/javatests/org/oppia/android/scripts/build/BUILD.bazel create mode 100644 scripts/src/javatests/org/oppia/android/scripts/build/TransformAndroidManifestTest.kt create mode 100644 version.bzl diff --git a/.github/workflows/build_tests.yml b/.github/workflows/build_tests.yml index 9e653e266bc..e3ad162f2b1 100644 --- a/.github/workflows/build_tests.yml +++ b/.github/workflows/build_tests.yml @@ -137,3 +137,257 @@ jobs: with: name: oppia-bazel.apk path: /home/runner/work/oppia-android/oppia-android/oppia.apk + + build_oppia_dev_aab: + name: Build Oppia AAB (developer flavor) + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-18.04] + env: + ENABLE_CACHING: false + CACHE_DIRECTORY: ~/.bazel_cache + steps: + - uses: actions/checkout@v2 + + - name: Set up JDK 9 + uses: actions/setup-java@v1 + with: + java-version: 9 + + - name: Set up Bazel + uses: abhinavsingh/setup-bazel@v3 + with: + version: 4.0.0 + + - name: Set up build environment + uses: ./.github/actions/set-up-android-bazel-build-environment + + # For reference on this & the later cache actions, see: + # https://github.com/actions/cache/issues/239#issuecomment-606950711 & + # https://github.com/actions/cache/issues/109#issuecomment-558771281. Note that these work + # with Bazel since Bazel can share the most recent cache from an unrelated build and still + # benefit from incremental build performance (assuming that actions/cache aggressively removes + # older caches due to the 5GB cache limit size & Bazel's large cache size). + - uses: actions/cache@v2 + id: cache + with: + path: ${{ env.CACHE_DIRECTORY }} + key: ${{ runner.os }}-${{ env.CACHE_DIRECTORY }}-bazel-binary-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-${{ env.CACHE_DIRECTORY }}-bazel-binary- + ${{ runner.os }}-${{ env.CACHE_DIRECTORY }}-bazel-tests- + ${{ runner.os }}-${{ env.CACHE_DIRECTORY }}-bazel- + + # This check is needed to ensure that Bazel's unbounded cache growth doesn't result in a + # situation where the cache never updates (e.g. due to exceeding GitHub's cache size limit) + # thereby only ever using the last successful cache version. This solution will result in a + # few slower CI actions around the time cache is detected to be too large, but it should + # incrementally improve thereafter. + - name: Ensure cache size + env: + BAZEL_CACHE_DIR: ${{ env.CACHE_DIRECTORY }} + run: | + # See https://stackoverflow.com/a/27485157 for reference. + EXPANDED_BAZEL_CACHE_PATH="${BAZEL_CACHE_DIR/#\~/$HOME}" + CACHE_SIZE_MB=$(du -smc $EXPANDED_BAZEL_CACHE_PATH | grep total | cut -f1) + echo "Total size of Bazel cache (rounded up to MBs): $CACHE_SIZE_MB" + # Use a 4.5GB threshold since actions/cache compresses the results, and Bazel caches seem + # to only increase by a few hundred megabytes across changes for unrelated branches. This + # is also a reasonable upper-bound (local tests as of 2021-03-31 suggest that a full build + # of the codebase (e.g. //...) from scratch only requires a ~2.1GB uncompressed/~900MB + # compressed cache). + if [[ "$CACHE_SIZE_MB" -gt 4500 ]]; then + echo "Cache exceeds cut-off; resetting it (will result in a slow build)" + rm -rf $EXPANDED_BAZEL_CACHE_PATH + fi + + - name: Configure Bazel to use a local cache + env: + BAZEL_CACHE_DIR: ${{ env.CACHE_DIRECTORY }} + run: | + EXPANDED_BAZEL_CACHE_PATH="${BAZEL_CACHE_DIR/#\~/$HOME}" + echo "Using $EXPANDED_BAZEL_CACHE_PATH as Bazel's cache path" + echo "build --disk_cache=$EXPANDED_BAZEL_CACHE_PATH" >> $HOME/.bazelrc + shell: bash + + - name: Check Bazel environment + run: bazel info + + # See https://git-secret.io/installation for details on installing git-secret. Note that the + # apt-get method isn't used since it's much slower to update & upgrade apt before installation + # versus just directly cloning & installing the project. Further, the specific version + # shouldn't matter since git-secret relies on a future-proof storage mechanism for secrets. + # This also uses a different directory to install git-secret to avoid requiring root access + # when running the git secret command. + - name: Install git-secret (non-fork only) + if: ${{ env.ENABLE_CACHING == 'true' && github.event.pull_request.head.repo.full_name == 'oppia/oppia-android' }} + shell: bash + run: | + cd $HOME + mkdir -p $HOME/gitsecret + git clone https://github.com/sobolevn/git-secret.git git-secret + cd git-secret && make build + PREFIX="$HOME/gitsecret" make install + echo "$HOME/gitsecret" >> $GITHUB_PATH + echo "$HOME/gitsecret/bin" >> $GITHUB_PATH + + - name: Decrypt secrets (non-fork only) + if: ${{ env.ENABLE_CACHING == 'true' && github.event.pull_request.head.repo.full_name == 'oppia/oppia-android' }} + env: + GIT_SECRET_GPG_PRIVATE_KEY: ${{ secrets.GIT_SECRET_GPG_PRIVATE_KEY }} + run: | + cd $HOME + # NOTE TO DEVELOPERS: Make sure to never print this key directly to stdout! + echo $GIT_SECRET_GPG_PRIVATE_KEY | base64 --decode > ./git_secret_private_key.gpg + gpg --import ./git_secret_private_key.gpg + cd $GITHUB_WORKSPACE + git secret reveal + + # Note that caching only works on non-forks. + - name: Build Oppia developer AAB (with caching, non-fork only) + if: ${{ env.ENABLE_CACHING == 'true' && github.event.pull_request.head.repo.full_name == 'oppia/oppia-android' }} + env: + BAZEL_REMOTE_CACHE_URL: ${{ secrets.BAZEL_REMOTE_CACHE_URL }} + run: | + bazel build --remote_http_cache=$BAZEL_REMOTE_CACHE_URL --google_credentials=./config/oppia-dev-workflow-remote-cache-credentials.json -- //:oppia_dev + + - name: Build Oppia developer AAB (without caching, or on a fork) + if: ${{ env.ENABLE_CACHING == 'false' || github.event.pull_request.head.repo.full_name != 'oppia/oppia-android' }} + run: | + bazel build -- //:oppia_dev + + - name: Copy Oppia APK for uploading + run: cp $GITHUB_WORKSPACE/bazel-bin/oppia_dev.aab /home/runner/work/oppia-android/oppia-android/ + + - uses: actions/upload-artifact@v2 + with: + name: oppia_dev.aab + path: /home/runner/work/oppia-android/oppia-android/oppia_dev.aab + + build_oppia_alpha_aab: + name: Build Oppia AAB (alpha flavor) + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-18.04] + env: + ENABLE_CACHING: false + CACHE_DIRECTORY: ~/.bazel_cache + steps: + - uses: actions/checkout@v2 + + - name: Set up JDK 9 + uses: actions/setup-java@v1 + with: + java-version: 9 + + - name: Set up Bazel + uses: abhinavsingh/setup-bazel@v3 + with: + version: 4.0.0 + + - name: Set up build environment + uses: ./.github/actions/set-up-android-bazel-build-environment + + # For reference on this & the later cache actions, see: + # https://github.com/actions/cache/issues/239#issuecomment-606950711 & + # https://github.com/actions/cache/issues/109#issuecomment-558771281. Note that these work + # with Bazel since Bazel can share the most recent cache from an unrelated build and still + # benefit from incremental build performance (assuming that actions/cache aggressively removes + # older caches due to the 5GB cache limit size & Bazel's large cache size). + - uses: actions/cache@v2 + id: cache + with: + path: ${{ env.CACHE_DIRECTORY }} + key: ${{ runner.os }}-${{ env.CACHE_DIRECTORY }}-bazel-binary-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-${{ env.CACHE_DIRECTORY }}-bazel-binary- + ${{ runner.os }}-${{ env.CACHE_DIRECTORY }}-bazel-tests- + ${{ runner.os }}-${{ env.CACHE_DIRECTORY }}-bazel- + + # This check is needed to ensure that Bazel's unbounded cache growth doesn't result in a + # situation where the cache never updates (e.g. due to exceeding GitHub's cache size limit) + # thereby only ever using the last successful cache version. This solution will result in a + # few slower CI actions around the time cache is detected to be too large, but it should + # incrementally improve thereafter. + - name: Ensure cache size + env: + BAZEL_CACHE_DIR: ${{ env.CACHE_DIRECTORY }} + run: | + # See https://stackoverflow.com/a/27485157 for reference. + EXPANDED_BAZEL_CACHE_PATH="${BAZEL_CACHE_DIR/#\~/$HOME}" + CACHE_SIZE_MB=$(du -smc $EXPANDED_BAZEL_CACHE_PATH | grep total | cut -f1) + echo "Total size of Bazel cache (rounded up to MBs): $CACHE_SIZE_MB" + # Use a 4.5GB threshold since actions/cache compresses the results, and Bazel caches seem + # to only increase by a few hundred megabytes across changes for unrelated branches. This + # is also a reasonable upper-bound (local tests as of 2021-03-31 suggest that a full build + # of the codebase (e.g. //...) from scratch only requires a ~2.1GB uncompressed/~900MB + # compressed cache). + if [[ "$CACHE_SIZE_MB" -gt 4500 ]]; then + echo "Cache exceeds cut-off; resetting it (will result in a slow build)" + rm -rf $EXPANDED_BAZEL_CACHE_PATH + fi + + - name: Configure Bazel to use a local cache + env: + BAZEL_CACHE_DIR: ${{ env.CACHE_DIRECTORY }} + run: | + EXPANDED_BAZEL_CACHE_PATH="${BAZEL_CACHE_DIR/#\~/$HOME}" + echo "Using $EXPANDED_BAZEL_CACHE_PATH as Bazel's cache path" + echo "build --disk_cache=$EXPANDED_BAZEL_CACHE_PATH" >> $HOME/.bazelrc + shell: bash + + - name: Check Bazel environment + run: bazel info + + # See https://git-secret.io/installation for details on installing git-secret. Note that the + # apt-get method isn't used since it's much slower to update & upgrade apt before installation + # versus just directly cloning & installing the project. Further, the specific version + # shouldn't matter since git-secret relies on a future-proof storage mechanism for secrets. + # This also uses a different directory to install git-secret to avoid requiring root access + # when running the git secret command. + - name: Install git-secret (non-fork only) + if: ${{ env.ENABLE_CACHING == 'true' && github.event.pull_request.head.repo.full_name == 'oppia/oppia-android' }} + shell: bash + run: | + cd $HOME + mkdir -p $HOME/gitsecret + git clone https://github.com/sobolevn/git-secret.git git-secret + cd git-secret && make build + PREFIX="$HOME/gitsecret" make install + echo "$HOME/gitsecret" >> $GITHUB_PATH + echo "$HOME/gitsecret/bin" >> $GITHUB_PATH + + - name: Decrypt secrets (non-fork only) + if: ${{ env.ENABLE_CACHING == 'true' && github.event.pull_request.head.repo.full_name == 'oppia/oppia-android' }} + env: + GIT_SECRET_GPG_PRIVATE_KEY: ${{ secrets.GIT_SECRET_GPG_PRIVATE_KEY }} + run: | + cd $HOME + # NOTE TO DEVELOPERS: Make sure to never print this key directly to stdout! + echo $GIT_SECRET_GPG_PRIVATE_KEY | base64 --decode > ./git_secret_private_key.gpg + gpg --import ./git_secret_private_key.gpg + cd $GITHUB_WORKSPACE + git secret reveal + + # Note that caching only works on non-forks. + - name: Build Oppia alpha AAB (with caching, non-fork only) + if: ${{ env.ENABLE_CACHING == 'true' && github.event.pull_request.head.repo.full_name == 'oppia/oppia-android' }} + env: + BAZEL_REMOTE_CACHE_URL: ${{ secrets.BAZEL_REMOTE_CACHE_URL }} + run: | + bazel build --compilation_mode=opt --remote_http_cache=$BAZEL_REMOTE_CACHE_URL --google_credentials=./config/oppia-dev-workflow-remote-cache-credentials.json -- //:oppia_alpha + + - name: Build Oppia alpha AAB (without caching, or on a fork) + if: ${{ env.ENABLE_CACHING == 'false' || github.event.pull_request.head.repo.full_name != 'oppia/oppia-android' }} + run: | + bazel build --compilation_mode=opt -- //:oppia_alpha + + - name: Copy Oppia APK for uploading + run: cp $GITHUB_WORKSPACE/bazel-bin/oppia_alpha.aab /home/runner/work/oppia-android/oppia-android/ + + - uses: actions/upload-artifact@v2 + with: + name: oppia_alpha.aab + path: /home/runner/work/oppia-android/oppia-android/oppia_alpha.aab diff --git a/BUILD.bazel b/BUILD.bazel index 7c32ed280d1..a19600fe4ca 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -1,5 +1,7 @@ # TODO(#1532): Rename file to 'BUILD' post-Gradle. +load("//:build_flavors.bzl", "AVAILABLE_FLAVORS", "define_oppia_binary_flavor") + # Corresponds to being accessible to all Oppia targets. This should be used for production APIs & # modules that may be used both in production targets and in tests. package_group( @@ -78,8 +80,16 @@ android_binary( "versionCode": "0", "versionName": "0.1-alpha", }, - multidex = "native", # TODO(#1875): Re-enable legacy for optimized release builds. + multidex = "native", deps = [ "//app", ], ) + +# Define all binary flavors that can be built. Note that these are AABs, not APKs, and can be +# be installed on a local device or emulator using a 'bazel run' command like so: +# bazel run //:install_oppia_dev +[ + define_oppia_binary_flavor(flavor) + for flavor in AVAILABLE_FLAVORS +] diff --git a/WORKSPACE b/WORKSPACE index 5cd142774ae..a57ca2f5a63 100644 --- a/WORKSPACE +++ b/WORKSPACE @@ -3,7 +3,7 @@ This file lists and imports all external dependencies needed to build Oppia Andr """ load("@bazel_tools//tools/build_defs/repo:git.bzl", "git_repository") -load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive") +load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive", "http_jar") load("//third_party:versions.bzl", "HTTP_DEPENDENCY_VERSIONS", "get_maven_dependencies") # Android SDK configuration. For more details, see: @@ -156,6 +156,13 @@ load("@android_test_support//:repo.bzl", "android_test_repositories") android_test_repositories() +# Android bundle tool. +http_jar( + name = "android_bundletool", + sha256 = HTTP_DEPENDENCY_VERSIONS["android_bundletool"]["sha"], + url = "https://github.com/google/bundletool/releases/download/{0}/bundletool-all-{0}.jar".format(HTTP_DEPENDENCY_VERSIONS["android_bundletool"]["version"]), +) + # Note to developers: new dependencies should be added to //third_party:versions.bzl, not here. maven_install( artifacts = DAGGER_ARTIFACTS + get_maven_dependencies(), diff --git a/app/src/main/res/raw/shrink_exemptions.xml b/app/src/main/res/raw/shrink_exemptions.xml new file mode 100644 index 00000000000..1a1f0a5c2f7 --- /dev/null +++ b/app/src/main/res/raw/shrink_exemptions.xml @@ -0,0 +1,4 @@ + + + diff --git a/build_flavors.bzl b/build_flavors.bzl new file mode 100644 index 00000000000..b8f73684959 --- /dev/null +++ b/build_flavors.bzl @@ -0,0 +1,160 @@ +""" +Macros & definitions corresponding to Oppia binary build flavors. +""" + +load("//:oppia_android_application.bzl", "declare_deployable_application", "oppia_android_application") +load("//:version.bzl", "MAJOR_VERSION", "MINOR_VERSION", "VERSION_CODE") + +# Defines the list of flavors available to build the Oppia app in. Note to developers: this list +# should be ordered by the development pipeline (i.e. features go through dev first, then other +# flavors as they mature). +AVAILABLE_FLAVORS = [ + "dev", + "alpha", +] + +# Note to developers: keys of this dict should follow the order of AVAILABLE_FLAVORS. +_FLAVOR_METADATA = { + "dev": { + "manifest": "//app:src/main/AndroidManifest.xml", + "min_sdk_version": 19, + "target_sdk_version": 29, + "multidex": "native", # Legacy multidex not needed for dev builds. + "proguard_specs": [], # Developer builds are not optimized. + "deps": [ + "//app", + ], + }, + "alpha": { + "manifest": "//app:src/main/AndroidManifest.xml", + "min_sdk_version": 19, + "target_sdk_version": 29, + "multidex": "legacy", + "proguard_specs": [ + "config/proguard/androidx-proguard-rules.pro", + "config/proguard/firebase-components-proguard-rules.pro", + "config/proguard/glide-proguard-rules.pro", + "config/proguard/google-play-services-proguard-rules.pro", + "config/proguard/guava-proguard-rules.pro", + "config/proguard/kotlin-proguard-rules.pro", + "config/proguard/kotlinpoet-javapoet-proguard-rules.pro", + "config/proguard/material-proguard-rules.pro", + "config/proguard/moshi-proguard-rules.pro", + "config/proguard/okhttp-proguard-rules.pro", + "config/proguard/oppia-prod-proguard-rules.pro", + "config/proguard/protobuf-proguard-rules.pro", + ], + "deps": [ + "//app", + ], + }, +} + +def _transform_android_manifest_impl(ctx): + input_file = ctx.attr.input_file.files.to_list()[0] + output_file = ctx.outputs.output_file + git_meta_dir = ctx.attr.git_meta_dir.files.to_list()[0] + build_flavor = ctx.attr.build_flavor + major_version = ctx.attr.major_version + minor_version = ctx.attr.minor_version + version_code = ctx.attr.version_code + + # See corresponding transformation script for details on the passed arguments. + arguments = [ + ".", # Working directory of the Bazel repository. + input_file.path, # Path to the source manifest. + output_file.path, # Path to the output manifest. + build_flavor, + "%s" % major_version, + "%s" % minor_version, + "%s" % version_code, + "origin/develop", # The base branch for computing the version name. + ] + + # Reference: https://docs.bazel.build/versions/master/skylark/lib/actions.html#run. + ctx.actions.run( + outputs = [output_file], + inputs = ctx.files.input_file + [git_meta_dir], + tools = [ctx.executable._transform_android_manifest_tool], + executable = ctx.executable._transform_android_manifest_tool.path, + arguments = arguments, + mnemonic = "TransformAndroidManifest", + progress_message = "Transforming Android manifest", + ) + return DefaultInfo( + files = depset([output_file]), + runfiles = ctx.runfiles(files = [output_file]), + ) + +_transform_android_manifest = rule( + attrs = { + "input_file": attr.label( + allow_files = True, + mandatory = True, + ), + "output_file": attr.output( + mandatory = True, + ), + "git_meta_dir": attr.label( + allow_files = True, + mandatory = True, + ), + "build_flavor": attr.string(mandatory = True), + "major_version": attr.int(mandatory = True), + "minor_version": attr.int(mandatory = True), + "version_code": attr.int(mandatory = True), + "_transform_android_manifest_tool": attr.label( + executable = True, + cfg = "host", + default = "//scripts:transform_android_manifest", + ), + }, + implementation = _transform_android_manifest_impl, +) + +def define_oppia_binary_flavor(flavor): + """ + Defines a new flavor of the Oppia Android app. + + Flavors are defined through properties defined within _FLAVOR_METADATA. + + This will define two targets: + - //:oppia_ (the AAB) + - //:install_oppia_ (the installable binary target--see declare_deployable_application + for details) + + Args: + flavor: str. The name of the flavor of the app. Must correspond to an entry in + AVAILABLE_FLAVORS. + """ + _transform_android_manifest( + name = "oppia_%s_transformed_manifest" % flavor, + input_file = _FLAVOR_METADATA[flavor]["manifest"], + output_file = "AndroidManifest_transformed_%s.xml" % flavor, + git_meta_dir = "//:.git", + build_flavor = flavor, + major_version = MAJOR_VERSION, + minor_version = MINOR_VERSION, + version_code = VERSION_CODE, + ) + oppia_android_application( + name = "oppia_%s" % flavor, + custom_package = "org.oppia.android", + enable_data_binding = True, + config_file = "//:bundle_config.pb.json", + manifest = ":AndroidManifest_transformed_%s.xml" % flavor, + manifest_values = { + "applicationId": "org.oppia.android", + "minSdkVersion": "%d" % _FLAVOR_METADATA[flavor]["min_sdk_version"], + "targetSdkVersion": "%d" % _FLAVOR_METADATA[flavor]["target_sdk_version"], + }, + multidex = _FLAVOR_METADATA[flavor]["multidex"], + proguard_generate_mapping = True if len(_FLAVOR_METADATA[flavor]["proguard_specs"]) != 0 else False, + proguard_specs = _FLAVOR_METADATA[flavor]["proguard_specs"], + shrink_resources = True if len(_FLAVOR_METADATA[flavor]["proguard_specs"]) != 0 else False, + deps = _FLAVOR_METADATA[flavor]["deps"], + ) + declare_deployable_application( + name = "install_oppia_%s" % flavor, + aab_target = ":oppia_%s" % flavor, + ) diff --git a/bundle_config.pb.json b/bundle_config.pb.json new file mode 100644 index 00000000000..9d35f4d37c2 --- /dev/null +++ b/bundle_config.pb.json @@ -0,0 +1,10 @@ +{ + "optimizations": { + "splits_config": { + "split_dimension": [{ + "value": "LANGUAGE", + "negate": true + }] + } + } +} diff --git a/config/proguard/androidx-proguard-rules.pro b/config/proguard/androidx-proguard-rules.pro new file mode 100644 index 00000000000..2a4f1c64760 --- /dev/null +++ b/config/proguard/androidx-proguard-rules.pro @@ -0,0 +1,41 @@ +# Proguard rules to workaround issues with AndroidX referencing newer APIs. +# TODO(#3749): Simplify this once the target SDK is updated. + +# It's not ideal to silence a group of classes like this, but different versions reference various +# new APIs, and this is preferable over silencing warnings for View, Canvas, ImageView, etc. +-dontwarn androidx.appcompat.widget.AppCompatTextViewAutoSizeHelper* +-dontwarn androidx.appcompat.widget.DrawableUtils +-dontwarn androidx.appcompat.widget.ListPopupWindow +-dontwarn androidx.appcompat.widget.MenuPopupWindow +-dontwarn androidx.core.app.NotificationCompat* +-dontwarn androidx.core.app.RemoteInput +-dontwarn androidx.core.content.pm.ShortcutInfoCompat +-dontwarn androidx.core.content.res.ResourcesCompat* +-dontwarn androidx.core.graphics.BlendModeColorFilterCompat +-dontwarn androidx.core.graphics.BlendModeUtils +-dontwarn androidx.core.graphics.PaintCompat +-dontwarn androidx.core.graphics.TypefaceCompatApi29Impl +-dontwarn androidx.core.os.TraceCompat +-dontwarn androidx.core.view.ViewCompat* +-dontwarn androidx.core.view.WindowInsetsCompat* +-dontwarn androidx.core.view.accessibility.AccessibilityNodeInfoCompat* +-dontwarn androidx.lifecycle.ProcessLifecycleOwner* +-dontwarn androidx.lifecycle.ReportFragment +-dontwarn androidx.recyclerview.widget.RecyclerView +-dontwarn androidx.transition.CanvasUtils, androidx.transition.ViewGroupUtils +-dontwarn androidx.transition.ViewUtilsApi*, androidx.transition.ImageViewUtils +-dontwarn androidx.viewpager2.widget.ViewPager2 +-dontwarn androidx.work.impl.foreground.SystemForegroundService* + +# Unexpected warnings for missing coroutine & other internal references. +-dontwarn androidx.appcompat.widget.SearchView* +-dontwarn androidx.coordinatorlayout.widget.CoordinatorLayout +-dontwarn androidx.lifecycle.FlowLiveDataConversions* + +# AndroidX Room uses reflection. Reference: https://stackoverflow.com/a/58529027. +-keep class * extends androidx.room.RoomDatabase +-keep @androidx.room.Entity class * + +# A strange unknown issue that arises within a Room class (it seems an actual dependency is missing +# within Room). +-dontwarn androidx.room.paging.LimitOffsetDataSource diff --git a/config/proguard/firebase-components-proguard-rules.pro b/config/proguard/firebase-components-proguard-rules.pro new file mode 100644 index 00000000000..2f72d567674 --- /dev/null +++ b/config/proguard/firebase-components-proguard-rules.pro @@ -0,0 +1,6 @@ +# Reference: https://github.com/firebase/firebase-android-sdk/blob/82b02af331/firebase-components/proguard.txt. + +-dontwarn com.google.firebase.components.Component$Instantiation +-dontwarn com.google.firebase.components.Component$ComponentType + +-keep class * implements com.google.firebase.components.ComponentRegistrar diff --git a/config/proguard/glide-proguard-rules.pro b/config/proguard/glide-proguard-rules.pro new file mode 100644 index 00000000000..b96f8bcbb49 --- /dev/null +++ b/config/proguard/glide-proguard-rules.pro @@ -0,0 +1,28 @@ +# Reference: https://github.com/bumptech/glide#proguard. +# TODO(#3749): Simplify this once the target SDK is updated. + +-keep public class * implements com.bumptech.glide.module.GlideModule +-keep class * extends com.bumptech.glide.module.AppGlideModule { + (...); +} +-keep public enum com.bumptech.glide.load.ImageHeaderParser$** { + **[] $VALUES; + public *; +} +-keep class com.bumptech.glide.load.data.ParcelFileDescriptorRewinder$InternalRewinder { + *** rewind(); +} + +# See: http://bumptech.github.io/glide/doc/download-setup.html#proguard. +-dontwarn com.bumptech.glide.load.resource.bitmap.VideoDecoder + +# Unneeded references that Proguard seems to warn about for Glide. Some of these are due to Glide's +# compiler being pulled into the build. Note that we might not actually want that, but more +# investigation work would be needed in the Bazel build graph to investigate alternatives. +-dontwarn javax.lang.model.SourceVersion, javax.lang.model.element.**, javax.lang.model.type.**, javax.lang.model.util.** +-dontwarn javax.tools.Diagnostic* +-dontwarn com.sun.tools.javac.code.** +# Glide references a few API 29 method. Depending on Glide handles compatibility, this could reuslt +# in some runtime issues. +-dontwarn android.os.Environment +-dontwarn android.provider.MediaStore diff --git a/config/proguard/google-play-services-proguard-rules.pro b/config/proguard/google-play-services-proguard-rules.pro new file mode 100644 index 00000000000..490efe6c1e5 --- /dev/null +++ b/config/proguard/google-play-services-proguard-rules.pro @@ -0,0 +1,5 @@ +# No special Proguard rules are needed for Google Play Services APIs, but some unresolved references +# need to be safely silenced. + +-dontwarn org.checkerframework.checker.nullness.qual.PolyNull +-dontwarn org.checkerframework.checker.nullness.qual.NonNull diff --git a/config/proguard/guava-proguard-rules.pro b/config/proguard/guava-proguard-rules.pro new file mode 100644 index 00000000000..3012632b74a --- /dev/null +++ b/config/proguard/guava-proguard-rules.pro @@ -0,0 +1,63 @@ +# This file is recommended for Android apps that use Guava: +# https://github.com/google/guava/wiki/UsingProGuardWithGuava. + +-dontwarn javax.lang.model.element.Modifier +-dontnote sun.misc.SharedSecrets +-keep class sun.misc.SharedSecrets { + *** getJavaLangAccess(...); +} +-dontnote sun.misc.JavaLangAccess +-keep class sun.misc.JavaLangAccess { + *** getStackTraceElement(...); + *** getStackTraceDepth(...); +} +-keepnames class com.google.common.base.internal.Finalizer { + *** startFinalizer(...); +} +-keepclassmembers class com.google.common.base.internal.Finalizer { + *** startFinalizer(...); +} +-keepnames class com.google.common.base.FinalizableReference { + void finalizeReferent(); +} +-keepclassmembers class com.google.common.base.FinalizableReference { + void finalizeReferent(); +} +-dontwarn sun.misc.Unsafe +-keepclassmembers class com.google.common.cache.Striped64 { + *** base; + *** busy; +} +-keepclassmembers class com.google.common.cache.Striped64$Cell { + ; +} +-dontwarn java.lang.SafeVarargs +-keep class java.lang.Throwable { + *** addSuppressed(...); +} +-keepclassmembers class com.google.common.util.concurrent.AbstractFuture** { + *** waiters; + *** value; + *** listeners; + *** thread; + *** next; +} +-keepclassmembers class com.google.common.util.concurrent.AtomicDouble { + *** value; +} +-keepclassmembers class com.google.common.util.concurrent.AggregateFutureState { + *** remaining; + *** seenExceptions; +} +-keep,allowshrinking,allowobfuscation class com.google.common.util.concurrent.AbstractFuture** { + ; +} +-dontwarn java.lang.ClassValue +-dontnote com.google.appengine.api.ThreadManager +-keep class com.google.appengine.api.ThreadManager { + static *** currentRequestThreadFactory(...); +} +-dontnote com.google.apphosting.api.ApiProxy +-keep class com.google.apphosting.api.ApiProxy { + static *** getCurrentEnvironment (...); +} diff --git a/config/proguard/kotlin-proguard-rules.pro b/config/proguard/kotlin-proguard-rules.pro new file mode 100644 index 00000000000..d5315c8fb1d --- /dev/null +++ b/config/proguard/kotlin-proguard-rules.pro @@ -0,0 +1,7 @@ +# Proguard rules to workaround Kotlin-specific issues that come up with Proguard. + +# These dependencies are actually wrong: the AndroidX versions should be available but current +# Kotlin dependencies seem to reference the support library ones, instead. This could potentially +# run into runtime issues if something is unintentionally removed. +-dontwarn android.support.annotation.Keep +-dontwarn android.support.annotation.VisibleForTesting diff --git a/config/proguard/kotlinpoet-javapoet-proguard-rules.pro b/config/proguard/kotlinpoet-javapoet-proguard-rules.pro new file mode 100644 index 00000000000..507bc225ace --- /dev/null +++ b/config/proguard/kotlinpoet-javapoet-proguard-rules.pro @@ -0,0 +1,6 @@ +# Proguard rules for indirect dependencies on Javapoet/Kotlinpoet. These libraries sometimes depend +# on Java tooling resources that are not actually needed for the app runtime and are intentionally +# not included. + +-dontwarn javax.tools.FileObject, javax.tools.JavaFileObject*, javax.tools.JavaFileManager* +-dontwarn javax.tools.SimpleJavaFileObject, javax.tools.StandardLocation diff --git a/config/proguard/material-proguard-rules.pro b/config/proguard/material-proguard-rules.pro new file mode 100644 index 00000000000..cc70c0e9661 --- /dev/null +++ b/config/proguard/material-proguard-rules.pro @@ -0,0 +1,7 @@ +# Proguard rules to workaround issues with the Android material library. +# TODO(#3749): Simplify this once the target SDK is updated. + +-dontwarn android.graphics.Insets # New API 29 class. +# Silence references to API 29+ methods. +-dontwarn android.view.WindowInsets +-dontwarn android.view.accessibility.AccessibilityManager diff --git a/config/proguard/moshi-proguard-rules.pro b/config/proguard/moshi-proguard-rules.pro new file mode 100644 index 00000000000..f7261c01b09 --- /dev/null +++ b/config/proguard/moshi-proguard-rules.pro @@ -0,0 +1,8 @@ +# No special Proguard rules are needed for Moshi in general since the app relies on compile-time +# generation. However, some of the reflection dependencies are still pulled in & produce warnings. + +-dontwarn com.squareup.moshi.kotlin.codegen.api.ProguardConfig +-dontwarn com.squareup.moshi.kotlin.reflect.** + +# This is a really specific, implementation-specific silence whose need isn't entirely clear. +-dontwarn com.squareup.moshi.kotlinpoet.classinspector.elements.shaded.com.google.auto.common.Overrides* diff --git a/config/proguard/okhttp-proguard-rules.pro b/config/proguard/okhttp-proguard-rules.pro new file mode 100644 index 00000000000..b6757c8c592 --- /dev/null +++ b/config/proguard/okhttp-proguard-rules.pro @@ -0,0 +1,12 @@ +# Reference: https://github.com/square/okhttp/blob/e1af67f082/okhttp/src/main/resources/META-INF/proguard/okhttp3.pro. + +-dontwarn javax.annotation.** +-keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase +-dontwarn org.codehaus.mojo.animal_sniffer.* +-dontwarn okhttp3.internal.platform.ConscryptPlatform +-dontwarn org.conscrypt.ConscryptHostnameVerifier + +# See: https://github.com/square/okhttp/issues/5167. +-dontwarn okhttp3.Authenticator*, okhttp3.CookieJar*, okhttp3.Dns* +-dontwarn okhttp3.internal.http2.PushObserver*, okhttp3.internal.io.FileSystem* +-dontwarn org.conscrypt.Conscrypt* diff --git a/config/proguard/oppia-prod-proguard-rules.pro b/config/proguard/oppia-prod-proguard-rules.pro new file mode 100644 index 00000000000..49b5d87afc9 --- /dev/null +++ b/config/proguard/oppia-prod-proguard-rules.pro @@ -0,0 +1,75 @@ +# Proguard optimization rules for release builds. +# +# References: +# - http://developer.android.com/guide/developing/tools/proguard.html +# - https://www.guardsquare.com/manual/configuration/usage + +# Keep source & line information for better exception stack traces (as defined in the Bazel build +# settings). However, rename source files to something small and rely on a map that can be used via +# ReTrace to reconstruct the original stack trace (from +# https://www.guardsquare.com/manual/configuration/examples). +-renamesourcefileattribute SourceFile +-keepattributes SourceFile, LineNumberTable + +# Attempt at least three optimization passes to further reduce APK size. +-optimizationpasses 3 + +# Ensure serializable classes still work per: +# https://www.guardsquare.com/manual/configuration/examples#serializable. +-keepclassmembers class * implements java.io.Serializable { + private static final java.io.ObjectStreamField[] serialPersistentFields; + private void writeObject(java.io.ObjectOutputStream); + private void readObject(java.io.ObjectInputStream); + java.lang.Object writeReplace(); + java.lang.Object readResolve(); +} + +# General Android configuration from https://www.guardsquare.com/manual/configuration/examples. +-dontpreverify # Not needed for Android builds. +-flattenpackagehierarchy + +# Keep annotations: https://www.guardsquare.com/manual/configuration/examples#annotations. +-keepattributes *Annotation* + +# The manifest references activities by name. +-keepnames class * extends android.app.Activity + +# The manifest references Application classes by name. +-keepnames class * extends android.app.Application + +# Android creates Views using reflection. Setters may also be called via reflection in some cases. +-keep class * extends android.view.View { + public (android.content.Context); + public (android.content.Context, android.util.AttributeSet); + public (android.content.Context, android.util.AttributeSet, int); + public void set*(...); +} + +# RecyclerView creates layout managers using reflection. +-keep class * extends androidx.recyclerview.widget.RecyclerView$LayoutManager { + public (android.content.Context); + public (android.content.Context, int, boolean); + public (android.content.Context, android.util.AttributeSet, int, int); +} + +# The generated R file must be kept as-is since fields can be referenced via reflection. +-keepclassmembers class **.R$* { + public static ; +} + +# Android Parcelables require reflection. +-keepclassmembers class * implements android.os.Parcelable { + public static *** CREATOR; +} + +# Disable some optimizations which trigger a bug in Proguard when trying to simplify enums to ints. +# See https://sourceforge.net/p/proguard/bugs/720/ for context. +-optimizations !class/unboxing/enum + +# Disable some optimizations which trigger a bug in Proguard when using annotations on methods. See +# https://sourceforge.net/p/proguard/bugs/688/ for context. +-optimizations !class/merging/* + +# Disable some field optimizations that can incorrectly remove if-not-null checks (see +# https://stackoverflow.com/a/59764770 for a related issue to the one Oppia runs into). +-optimizations !field/propagation/value diff --git a/config/proguard/protobuf-proguard-rules.pro b/config/proguard/protobuf-proguard-rules.pro new file mode 100644 index 00000000000..c2440af0178 --- /dev/null +++ b/config/proguard/protobuf-proguard-rules.pro @@ -0,0 +1,11 @@ +# See https://github.com/protocolbuffers/protobuf/blob/2937b2ca63/java/lite/proguard.pgcfg and +# https://github.com/protocolbuffers/protobuf/issues/6463 for reference. +# TODO(#3748): Simplify this once R8 is supported. + +-keepclassmembers class * extends com.google.protobuf.GeneratedMessageLite { + ; +} + +# It's not entirely clear why there are a few missing references for these classes. +-dontwarn com.google.protobuf.CodedInputStream +-dontwarn com.google.protobuf.GeneratedMessageLite diff --git a/oppia_android_application.bzl b/oppia_android_application.bzl new file mode 100644 index 00000000000..8e2498185b7 --- /dev/null +++ b/oppia_android_application.bzl @@ -0,0 +1,278 @@ +""" +Macros pertaining to building & managing Android app bundles. +""" + +def _convert_apk_to_aab_module_impl(ctx): + output_file = ctx.outputs.output_file + input_file = ctx.attr.input_file.files.to_list()[0] + + # See aapt2 help documentation for details on the arguments passed here. + arguments = [ + "convert", + "--output-format", + "proto", + "-o", + output_file.path, + input_file.path, + ] + + # Reference: https://docs.bazel.build/versions/master/skylark/lib/actions.html#run. + ctx.actions.run( + outputs = [output_file], + inputs = ctx.files.input_file, + tools = [ctx.executable._aapt2_tool], + executable = ctx.executable._aapt2_tool.path, + arguments = arguments, + mnemonic = "GenerateAndroidAppBundleModuleFromApk", + progress_message = "Generating deployable AAB", + ) + return DefaultInfo( + files = depset([output_file]), + runfiles = ctx.runfiles(files = [output_file]), + ) + +def _convert_module_aab_to_structured_zip_impl(ctx): + output_file = ctx.outputs.output_file + input_file = ctx.attr.input_file.files.to_list()[0] + + command = """ + # Extract AAB to working directory. + WORKING_DIR=$(mktemp -d) + unzip -d $WORKING_DIR {0} + + # Create the expected directory structure for an app bundle. + # Reference for copying all other files to root: https://askubuntu.com/a/951768. + mkdir -p $WORKING_DIR/assets $WORKING_DIR/dex $WORKING_DIR/manifest $WORKING_DIR/root + mv $WORKING_DIR/*.dex $WORKING_DIR/dex/ + mv $WORKING_DIR/AndroidManifest.xml $WORKING_DIR/manifest/ + ls -d $WORKING_DIR/* | grep -v -w -E "res|assets|dex|manifest|root|resources.pb" | xargs mv -t $WORKING_DIR/root/ + + # Zip up the result--this will be used by bundletool to build a deployable AAB. Note that these + # strange file path bits are needed because zip will always retain the directory structure + # passed via arguments (necessitating changing into the working directory). + DEST_FILE_PATH="$(pwd)/{1}" + cd $WORKING_DIR + zip -r $DEST_FILE_PATH . + """.format(input_file.path, output_file.path) + + # Reference: https://docs.bazel.build/versions/main/skylark/lib/actions.html#run_shell. + ctx.actions.run_shell( + outputs = [output_file], + inputs = ctx.files.input_file, + tools = [], + command = command, + mnemonic = "ConvertModuleAabToStructuredZip", + progress_message = "Generating deployable AAB", + ) + 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] + config_file = ctx.attr.config_file.files.to_list()[0] + + # Reference: https://developer.android.com/studio/build/building-cmdline#build_your_app_bundle_using_bundletool. + arguments = [ + "build-bundle", + "--modules=%s" % input_file.path, + "--config=%s" % config_file.path, + "--output=%s" % output_file.path, + ] + + # Reference: https://docs.bazel.build/versions/master/skylark/lib/actions.html#run. + ctx.actions.run( + outputs = [output_file], + inputs = ctx.files.input_file + ctx.files.config_file, + tools = [ctx.executable._bundletool_tool], + executable = ctx.executable._bundletool_tool.path, + arguments = arguments, + mnemonic = "GenerateDeployAabFromModuleZip", + progress_message = "Generating deployable AAB", + ) + return DefaultInfo( + files = depset([output_file]), + runfiles = ctx.runfiles(files = [output_file]), + ) + +def _generate_apks_and_install_impl(ctx): + input_file = ctx.attr.input_file.files.to_list()[0] + apks_file = ctx.actions.declare_file("%s_processed.apks" % ctx.label.name) + deploy_shell = ctx.actions.declare_file("%s_run.sh" % ctx.label.name) + + # Reference: https://developer.android.com/studio/command-line/bundletool#generate_apks. + generate_apks_arguments = [ + "build-apks", + "--bundle=%s" % input_file.path, + "--output=%s" % apks_file.path, + ] + + # Reference: https://docs.bazel.build/versions/master/skylark/lib/actions.html#run. + ctx.actions.run( + outputs = [apks_file], + inputs = ctx.files.input_file, + tools = [ctx.executable._bundletool_tool], + executable = ctx.executable._bundletool_tool.path, + arguments = generate_apks_arguments, + mnemonic = "BuildApksFromDeployAab", + progress_message = "Preparing AAB deploy to device", + ) + + # References: https://github.com/bazelbuild/bazel/issues/7390, + # https://developer.android.com/studio/command-line/bundletool#deploy_with_bundletool, and + # https://docs.bazel.build/versions/main/skylark/rules.html#executable-rules-and-test-rules. + # Note that the bundletool can be executed directly since Bazel creates a wrapper script that + # utilizes its own internal Java toolchain. + ctx.actions.write( + output = deploy_shell, + content = """ + #!/bin/sh + {0} install-apks --apks={1} + echo The APK should now be installed + """.format(ctx.executable._bundletool_tool.short_path, apks_file.short_path), + is_executable = True, + ) + + # Reference for including necessary runfiles for Java: + # https://github.com/bazelbuild/bazel/issues/487#issuecomment-178119424. + runfiles = ctx.runfiles( + files = [ + ctx.executable._bundletool_tool, + apks_file, + ], + ).merge(ctx.attr._bundletool_tool.default_runfiles) + return DefaultInfo( + executable = deploy_shell, + runfiles = runfiles, + ) + +_convert_apk_to_module_aab = rule( + attrs = { + "input_file": attr.label( + allow_files = True, + mandatory = True, + ), + "output_file": attr.output( + mandatory = True, + ), + "_aapt2_tool": attr.label( + executable = True, + cfg = "host", + default = "@androidsdk//:aapt2_binary", + ), + }, + implementation = _convert_apk_to_aab_module_impl, +) + +_convert_module_aab_to_structured_zip = rule( + attrs = { + "input_file": attr.label( + allow_files = True, + mandatory = True, + ), + "output_file": attr.output( + mandatory = True, + ), + }, + implementation = _convert_module_aab_to_structured_zip_impl, +) + +_bundle_module_zip_into_deployable_aab = rule( + attrs = { + "input_file": attr.label( + allow_files = True, + mandatory = True, + ), + "config_file": attr.label( + allow_files = True, + mandatory = True, + ), + "output_file": attr.output( + mandatory = True, + ), + "_bundletool_tool": attr.label( + executable = True, + cfg = "host", + default = "//third_party:android_bundletool", + ), + }, + implementation = _bundle_module_zip_into_deployable_aab_impl, +) + +_generate_apks_and_install = rule( + attrs = { + "input_file": attr.label( + allow_files = True, + mandatory = True, + ), + "_bundletool_tool": attr.label( + executable = True, + cfg = "host", + default = "//third_party:android_bundletool", + ), + }, + executable = True, + implementation = _generate_apks_and_install_impl, +) + +def oppia_android_application(name, config_file, **kwargs): + """ + Creates an Android App Bundle (AAB) binary with the specified name and arguments. + + Args: + name: str. The name of the Android App Bundle to build. This will corresponding to the name of + the generated .aab file. + config_file: target. The path to the .pb.json bundle configuration file for this build. + **kwargs: additional arguments. See android_binary for the exact arguments that are available. + """ + binary_name = "%s_binary" % name + module_aab_name = "%s_module_aab" % name + module_zip_name = "%s_module_zip" % name + native.android_binary( + name = binary_name, + **kwargs + ) + _convert_apk_to_module_aab( + name = module_aab_name, + input_file = ":%s.apk" % binary_name, + output_file = "%s.aab" % module_aab_name, + ) + _convert_module_aab_to_structured_zip( + name = module_zip_name, + input_file = ":%s.aab" % module_aab_name, + output_file = "%s.zip" % module_zip_name, + ) + _bundle_module_zip_into_deployable_aab( + name = name, + input_file = ":%s.zip" % module_zip_name, + config_file = config_file, + output_file = "%s.aab" % name, + ) + +def declare_deployable_application(name, aab_target): + """ + Creates a new target that can be run with 'bazel run' to install an AAB file. + + Example: + declare_deployable_application( + name = "install_oppia_prod", + aab_target = "//:oppia_prod", + ) + + $ bazel run //:install_oppia_prod + + This will build (if necessary) and install the correct APK derived from the Android app bundle + on the locally attached emulator or device. Note that this command does not support targeting a + specific device so it will not work if more than one device is available via 'adb devices'. + + Args: + name: str. The name of the runnable target to install an AAB file on a local device. + aab_target: target. The target (declared via oppia_android_application) that should be made + installable. + """ + _generate_apks_and_install( + name = name, + input_file = aab_target, + ) diff --git a/oppia_android_test.bzl b/oppia_android_test.bzl index 77280daeaf9..b5a1318faa6 100644 --- a/oppia_android_test.bzl +++ b/oppia_android_test.bzl @@ -48,7 +48,7 @@ def oppia_android_test( assets_dir = None, **kwargs): """ - Creates a local Oppia test target with Kotlin support. + Creates a local Oppia test target with Kotlin support. Note that this creates an additional, internal library. diff --git a/scripts/BUILD.bazel b/scripts/BUILD.bazel index f83dd74319a..a34f7234b69 100644 --- a/scripts/BUILD.bazel +++ b/scripts/BUILD.bazel @@ -204,3 +204,17 @@ kt_jvm_binary( "//scripts/src/java/org/oppia/android/scripts/todo:todo_issue_comment_check_lib", ], ) + +# Note that this is intentionally not test-only since it's used by the app build pipeline. Also, +# this apparently needs to be a java_binary to set up runfiles correctly when executed within a +# Starlark rule as a tool. +java_binary( + name = "transform_android_manifest", + main_class = "org.oppia.android.scripts.build.TransformAndroidManifestKt", + visibility = [ + "//:oppia_binary_visibility", + ], + runtime_deps = [ + "//scripts/src/java/org/oppia/android/scripts/build:transform_android_manifest_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 new file mode 100644 index 00000000000..92ad121078e --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/build/BUILD.bazel @@ -0,0 +1,14 @@ +""" +Libraries corresponding to build pipeline scripts. +""" + +load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_jvm_library") + +kt_jvm_library( + name = "transform_android_manifest_lib", + srcs = ["TransformAndroidManifest.kt"], + visibility = ["//scripts:oppia_script_binary_visibility"], + deps = [ + "//scripts/src/java/org/oppia/android/scripts/common:git_client", + ], +) diff --git a/scripts/src/java/org/oppia/android/scripts/build/TransformAndroidManifest.kt b/scripts/src/java/org/oppia/android/scripts/build/TransformAndroidManifest.kt new file mode 100644 index 00000000000..73edba5f623 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/build/TransformAndroidManifest.kt @@ -0,0 +1,138 @@ +package org.oppia.android.scripts.build + +import java.io.File +import java.io.StringWriter +import javax.xml.parsers.DocumentBuilderFactory +import javax.xml.transform.TransformerFactory +import javax.xml.transform.dom.DOMSource +import javax.xml.transform.stream.StreamResult +import org.oppia.android.scripts.common.GitClient +import org.w3c.dom.Document + +private const val USAGE_STRING = + "Usage: bazel run //scripts:transform_android_manifest -- " + + " " + + " " + + " " + + "" + +/** + * The main entrypoint for transforming an AndroidManifest to include both a version code and + * generated version name (for production releases of the Oppia Android app). + * + * Note that this script is primarily meant to be run as part of the Bazel pipeline for AAB (Android + * App Bundle) builds of the app, but it can also be run standalone. See build_flavors.bzl for + * specifics on how this is run within the build pipeline. The example below is meant to be for + * standalone uses. Note that the argument documentation below is also geared towards standalone + * usage (the Bazel run of the script occurs within a runfiles sandbox folder & certain paths are + * intentionally relative to that working directory). Finally, the Bazel runtime version of this + * also does not actually run within the local Git repository (since it doesn't have access to it). + * Instead, it copies just the .git folder of the local repository to create a sufficient copy to + * compute a build hash. + * + * Usage: + * bazel run //scripts:transform_android_manifest -- > \\ + * \\ + * \\ + * \\ + * \\ + * \\ + * \\ + * + * + * Arguments: + * - root_path: directory path to the root of the Oppia Android repository. + * - input_manifest_path: directory path to the manifest to be processed. + * - output_manifest_path: directory path to where the output manifest should be written. + * - build_flavor: the flavor of the build corresponding to this manifest (e.g. 'dev' or 'alpha'). + * - major_app_version: the major version of the app. + * - minor_app_version: the minor version of the app. + * - version_code: the next version code to use. + * - base_develop_branch_reference: the reference to the local develop branch that should be use. + * Generally, this is 'origin/develop'. + * + * Example: + * bazel run //scripts:transform_android_manifest -- $(pwd) \\ + * $(pwd)/app/src/main/AndroidManifest.xml $(pwd)/TransformedAndroidManifest.xml alpha 0 6 6 \\ + * origin/develop + */ +fun main(args: Array) { + check(args.size >= 8) { USAGE_STRING } + + val repoRoot = File(args[0]).also { if (!it.exists()) error("File doesn't exist: ${args[0]}") } + TransformAndroidManifest( + repoRoot = repoRoot, + sourceManifestFile = File(args[1]).also { + if (!it.exists()) { + error("File doesn't exist: ${args[1]}") + } + }, + outputManifestFile = File(args[2]), + buildFlavor = args[3], + majorVersion = args[4].toIntOrNull() ?: error(USAGE_STRING), + minorVersion = args[5].toIntOrNull() ?: error(USAGE_STRING), + versionCode = args[6].toIntOrNull() ?: error(USAGE_STRING), + baseDevelopBranchReference = args[7] + ).generateAndOutputNewManifest() +} + +private class TransformAndroidManifest( + private val repoRoot: File, + private val sourceManifestFile: File, + private val outputManifestFile: File, + private val buildFlavor: String, + private val majorVersion: Int, + private val minorVersion: Int, + private val versionCode: Int, + private val baseDevelopBranchReference: String +) { + private val gitClient by lazy { + GitClient(repoRoot, baseDevelopBranchReference) + } + private val documentBuilderFactory by lazy { DocumentBuilderFactory.newInstance() } + private val transformerFactory by lazy { TransformerFactory.newInstance() } + + /** + * Generates a new manifest by inserting the version code & computed version name, and then + * outputs it to the defined [outputManifestFile]. + */ + fun generateAndOutputNewManifest() { + // Parse the manifest & add the version code & name. + val manifestDocument = documentBuilderFactory.parseXmlFile(sourceManifestFile) + val versionCodeAttribute = manifestDocument.createAttribute("android:versionCode").apply { + value = versionCode.toString() + } + val versionNameAttribute = manifestDocument.createAttribute("android:versionName").apply { + value = computeVersionName( + buildFlavor, majorVersion, minorVersion, commitHash = gitClient.branchMergeBase + ) + } + val manifestNode = manifestDocument.childNodes.item(0) + manifestNode.attributes.apply { + setNamedItem(versionCodeAttribute) + setNamedItem(versionNameAttribute) + } + + // Output the new transformed manifest. + outputManifestFile.writeText(manifestDocument.toSource()) + } + + // The format here is defined as part of the app's release process. + private fun computeVersionName( + flavor: String, + majorVersion: Int, + minorVersion: Int, + commitHash: String + ): String = "$majorVersion.$minorVersion-$flavor-${commitHash.take(10)}" + + private fun DocumentBuilderFactory.parseXmlFile(file: File): Document = + newDocumentBuilder().parse(file) + + private fun Document.toSource(): String { + // Reference: https://stackoverflow.com/a/5456836. + val transformer = transformerFactory.newTransformer() + return StringWriter().apply { + transformer.transform(DOMSource(this@toSource), StreamResult(this@apply)) + }.toString() + } +} diff --git a/scripts/src/java/org/oppia/android/scripts/common/BUILD.bazel b/scripts/src/java/org/oppia/android/scripts/common/BUILD.bazel index aedc872a55c..8d4c312f7b1 100644 --- a/scripts/src/java/org/oppia/android/scripts/common/BUILD.bazel +++ b/scripts/src/java/org/oppia/android/scripts/common/BUILD.bazel @@ -19,7 +19,6 @@ kt_jvm_library( kt_jvm_library( name = "git_client", - testonly = True, srcs = [ "GitClient.kt", ], @@ -31,7 +30,6 @@ kt_jvm_library( kt_jvm_library( name = "command_executor", - testonly = True, srcs = [ "CommandExecutor.kt", "CommandExecutorImpl.kt", diff --git a/scripts/src/javatests/org/oppia/android/scripts/build/BUILD.bazel b/scripts/src/javatests/org/oppia/android/scripts/build/BUILD.bazel new file mode 100644 index 00000000000..74b03d588bf --- /dev/null +++ b/scripts/src/javatests/org/oppia/android/scripts/build/BUILD.bazel @@ -0,0 +1,17 @@ +""" +Tests corresponding to build pipeline scripts. +""" + +load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_jvm_test") + +kt_jvm_test( + name = "TransformAndroidManifestTest", + srcs = ["TransformAndroidManifestTest.kt"], + deps = [ + "//scripts/src/java/org/oppia/android/scripts/build:transform_android_manifest_lib", + "//scripts/src/java/org/oppia/android/scripts/testing:test_git_repository", + "//testing:assertion_helpers", + "//third_party:com_google_truth_truth", + "//third_party:org_jetbrains_kotlin_kotlin-test-junit", + ], +) diff --git a/scripts/src/javatests/org/oppia/android/scripts/build/TransformAndroidManifestTest.kt b/scripts/src/javatests/org/oppia/android/scripts/build/TransformAndroidManifestTest.kt new file mode 100644 index 00000000000..78863120f5d --- /dev/null +++ b/scripts/src/javatests/org/oppia/android/scripts/build/TransformAndroidManifestTest.kt @@ -0,0 +1,320 @@ +package org.oppia.android.scripts.build + +import com.google.common.truth.Truth.assertThat +import java.io.File +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.oppia.android.scripts.common.CommandExecutorImpl +import org.oppia.android.scripts.testing.TestGitRepository +import org.oppia.android.testing.assertThrows + +/** + * Tests for the transform_android_manifest utility. + * + * Note that this test suite makes use of real Git utilities on the local system. As a result, these + * tests could be affected by unexpected environment issues (such as inconsistencies across + * dependency versions or changes in behavior across different filesystems). + */ +// PrivatePropertyName: it's valid to have private vals in constant case if they're true constants. +// FunctionName: test names are conventionally named with underscores. +@Suppress("PrivatePropertyName", "FunctionName") +class TransformAndroidManifestTest { + private val USAGE_STRING = + "Usage: bazel run //scripts:transform_android_manifest -- " + + " " + + " " + + " " + + "" + + private val TEST_MANIFEST_FILE_NAME = "AndroidManifest.xml" + private val TRANSFORMED_MANIFEST_FILE_NAME = "TransformedAndroidManifest.xml" + private val TEST_MANIFEST_CONTENT_WITHOUT_VERSIONS = """ + + + """.trimIndent() + + private val BUILD_FLAVOR = "beta" + private val MAJOR_VERSION = "1" + private val MINOR_VERSION = "3" + private val VERSION_CODE = "23" + + @Rule + @JvmField + var tempFolder = TemporaryFolder() + + private lateinit var testGitRepository: TestGitRepository + + @Before + fun setUp() { + testGitRepository = TestGitRepository(tempFolder, CommandExecutorImpl()) + } + + @Test + fun testUtility_noArgs_failsWithUsageString() { + initializeEmptyGitRepository() + + val exception = assertThrows(IllegalStateException::class) { runScript() } + + assertThat(exception).hasMessageThat().contains(USAGE_STRING) + } + + @Test + fun testUtility_oneArg_failsWithUsageString() { + initializeEmptyGitRepository() + + val exception = assertThrows(IllegalStateException::class) { + runScript(tempFolder.root.absolutePath) + } + + assertThat(exception).hasMessageThat().contains(USAGE_STRING) + } + + @Test + fun testUtility_twoArgs_failsWithUsageString() { + initializeEmptyGitRepository() + val manifestFile = tempFolder.newFile(TEST_MANIFEST_FILE_NAME) + + val exception = assertThrows(IllegalStateException::class) { + runScript(tempFolder.root.absolutePath, manifestFile.absolutePath) + } + + assertThat(exception).hasMessageThat().contains(USAGE_STRING) + } + + @Test + fun testUtility_threeAgs_failsWithUsageString() { + initializeEmptyGitRepository() + val manifestFile = tempFolder.newFile(TEST_MANIFEST_FILE_NAME) + + val exception = assertThrows(IllegalStateException::class) { + runScript( + tempFolder.root.absolutePath, + manifestFile.absolutePath, + File(tempFolder.root, TRANSFORMED_MANIFEST_FILE_NAME).absolutePath + ) + } + + assertThat(exception).hasMessageThat().contains(USAGE_STRING) + } + + @Test + fun testUtility_fourAgs_failsWithUsageString() { + initializeEmptyGitRepository() + val manifestFile = tempFolder.newFile(TEST_MANIFEST_FILE_NAME) + + val exception = assertThrows(IllegalStateException::class) { + runScript( + tempFolder.root.absolutePath, + manifestFile.absolutePath, + File(tempFolder.root, TRANSFORMED_MANIFEST_FILE_NAME).absolutePath, + BUILD_FLAVOR + ) + } + + assertThat(exception).hasMessageThat().contains(USAGE_STRING) + } + + @Test + fun testUtility_fiveAgs_failsWithUsageString() { + initializeEmptyGitRepository() + val manifestFile = tempFolder.newFile(TEST_MANIFEST_FILE_NAME) + + val exception = assertThrows(IllegalStateException::class) { + runScript( + tempFolder.root.absolutePath, + manifestFile.absolutePath, + File(tempFolder.root, TRANSFORMED_MANIFEST_FILE_NAME).absolutePath, + BUILD_FLAVOR, + MAJOR_VERSION + ) + } + + assertThat(exception).hasMessageThat().contains(USAGE_STRING) + } + + @Test + fun testUtility_sixAgs_failsWithUsageString() { + initializeEmptyGitRepository() + val manifestFile = tempFolder.newFile(TEST_MANIFEST_FILE_NAME) + + val exception = assertThrows(IllegalStateException::class) { + runScript( + tempFolder.root.absolutePath, + manifestFile.absolutePath, + File(tempFolder.root, TRANSFORMED_MANIFEST_FILE_NAME).absolutePath, + BUILD_FLAVOR, + MAJOR_VERSION, + MINOR_VERSION + ) + } + + assertThat(exception).hasMessageThat().contains(USAGE_STRING) + } + + @Test + fun testUtility_sevenAgs_failsWithUsageString() { + initializeEmptyGitRepository() + val manifestFile = tempFolder.newFile(TEST_MANIFEST_FILE_NAME) + + val exception = assertThrows(IllegalStateException::class) { + runScript( + tempFolder.root.absolutePath, + manifestFile.absolutePath, + File(tempFolder.root, TRANSFORMED_MANIFEST_FILE_NAME).absolutePath, + BUILD_FLAVOR, + MAJOR_VERSION, + MINOR_VERSION, + VERSION_CODE + ) + } + + assertThat(exception).hasMessageThat().contains(USAGE_STRING) + } + + @Test + fun testUtility_allArgs_nonIntMajorVersion_failsWithUsageString() { + initializeEmptyGitRepository() + val manifestFile = tempFolder.newFile(TEST_MANIFEST_FILE_NAME) + + val exception = assertThrows(IllegalStateException::class) { + runScript( + tempFolder.root.absolutePath, + manifestFile.absolutePath, + File(tempFolder.root, TRANSFORMED_MANIFEST_FILE_NAME).absolutePath, + BUILD_FLAVOR, + "major_version", + MINOR_VERSION, + VERSION_CODE, + "develop" + ) + } + + assertThat(exception).hasMessageThat().contains(USAGE_STRING) + } + + @Test + fun testUtility_allArgs_nonIntMinorVersion_failsWithUsageString() { + initializeEmptyGitRepository() + val manifestFile = tempFolder.newFile(TEST_MANIFEST_FILE_NAME) + + val exception = assertThrows(IllegalStateException::class) { + runScript( + tempFolder.root.absolutePath, + manifestFile.absolutePath, + File(tempFolder.root, TRANSFORMED_MANIFEST_FILE_NAME).absolutePath, + BUILD_FLAVOR, + MAJOR_VERSION, + "minor_version", + VERSION_CODE, + "develop" + ) + } + + assertThat(exception).hasMessageThat().contains(USAGE_STRING) + } + + @Test + fun testUtility_allArgs_nonIntVersionCode_failsWithUsageString() { + initializeEmptyGitRepository() + val manifestFile = tempFolder.newFile(TEST_MANIFEST_FILE_NAME) + + val exception = assertThrows(IllegalStateException::class) { + runScript( + tempFolder.root.absolutePath, + manifestFile.absolutePath, + File(tempFolder.root, TRANSFORMED_MANIFEST_FILE_NAME).absolutePath, + BUILD_FLAVOR, + MAJOR_VERSION, + MINOR_VERSION, + "version_code", + "develop" + ) + } + + assertThat(exception).hasMessageThat().contains(USAGE_STRING) + } + + @Test + fun testUtility_allArgs_rootDoesNotExist_failsWithError() { + initializeEmptyGitRepository() + val manifestFile = tempFolder.newFile(TEST_MANIFEST_FILE_NAME) + + val exception = assertThrows(IllegalStateException::class) { + runScript( + "nowhere", + manifestFile.absolutePath, + File(tempFolder.root, TRANSFORMED_MANIFEST_FILE_NAME).absolutePath, + BUILD_FLAVOR, + MAJOR_VERSION, + MINOR_VERSION, + VERSION_CODE, + "develop" + ) + } + + assertThat(exception).hasMessageThat().contains("File doesn't exist: nowhere") + } + + @Test + fun testUtility_allArgs_manifestDoesNotExist_failsWithError() { + initializeEmptyGitRepository() + + val exception = assertThrows(IllegalStateException::class) { + runScript( + tempFolder.root.absolutePath, + "fake_manifest_file", + File(tempFolder.root, TRANSFORMED_MANIFEST_FILE_NAME).absolutePath, + BUILD_FLAVOR, + MAJOR_VERSION, + MINOR_VERSION, + VERSION_CODE, + "develop" + ) + } + + assertThat(exception).hasMessageThat().contains("File doesn't exist: fake_manifest_file") + } + + @Test + fun testUtility_allArgsCorrect_outputsNewManifestWithVersionNameAndCode() { + initializeEmptyGitRepository() + val manifestFile = tempFolder.newFile(TEST_MANIFEST_FILE_NAME).apply { + writeText(TEST_MANIFEST_CONTENT_WITHOUT_VERSIONS) + } + + runScript( + tempFolder.root.absolutePath, + manifestFile.absolutePath, + File(tempFolder.root, TRANSFORMED_MANIFEST_FILE_NAME).absolutePath, + BUILD_FLAVOR, + MAJOR_VERSION, + MINOR_VERSION, + VERSION_CODE, + "develop" + ) + + val transformedManifest = File(tempFolder.root, TRANSFORMED_MANIFEST_FILE_NAME).readText() + assertThat(transformedManifest).containsMatch("android:versionCode=\"$VERSION_CODE\"") + assertThat(transformedManifest) + .containsMatch("android:versionName=\"$MAJOR_VERSION\\.$MINOR_VERSION" + + "-$BUILD_FLAVOR-[a-f0-9]{10}\"") + } + + /** Runs the transform_android_manifest utility. */ + private fun runScript(vararg args: String) { + main(args.toList().toTypedArray()) + } + + private fun initializeEmptyGitRepository() { + // Initialize the git repository with a base 'develop' branch & an initial empty commit (so that + // there's a HEAD commit). + testGitRepository.init() + testGitRepository.setUser(email = "test@oppia.org", name = "Test User") + testGitRepository.checkoutNewBranch("develop") + testGitRepository.commit(message = "Initial commit.", allowEmpty = true) + } +} diff --git a/third_party/BUILD.bazel b/third_party/BUILD.bazel index 9f7a3ef4722..9129292e32e 100644 --- a/third_party/BUILD.bazel +++ b/third_party/BUILD.bazel @@ -84,3 +84,10 @@ java_library( "//third_party:com_github_bumptech_glide_compiler", ], ) + +java_binary( + name = "android_bundletool", + main_class = "com.android.tools.build.bundletool.BundleToolMain", + visibility = ["//visibility:public"], + runtime_deps = ["@android_bundletool//jar"], +) diff --git a/third_party/versions.bzl b/third_party/versions.bzl index 2d94cf461a0..8a438be85a0 100644 --- a/third_party/versions.bzl +++ b/third_party/versions.bzl @@ -108,6 +108,10 @@ MAVEN_TEST_DEPENDENCY_VERSIONS = { # Note to developers: Please keep this dict sorted by key to make it easier to find dependencies. HTTP_DEPENDENCY_VERSIONS = { + "android_bundletool": { + "sha": "1e8430002c76f36ce2ddbac8aadfaf2a252a5ffbd534dab64bb255cda63db7ba", + "version": "1.8.0", + }, "dagger": { "sha": "9e69ab2f9a47e0f74e71fe49098bea908c528aa02fa0c5995334447b310d0cdd", "version": "2.28.1", diff --git a/version.bzl b/version.bzl new file mode 100644 index 00000000000..0f070c793a0 --- /dev/null +++ b/version.bzl @@ -0,0 +1,7 @@ +""" +Defines the latest version of the Oppia Android app. +""" + +MAJOR_VERSION = 0 +MINOR_VERSION = 6 +VERSION_CODE = 6 From ceb117924b4d4badc4d3333ebd0a5f60b17267ac Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Tue, 31 Aug 2021 23:56:17 -0700 Subject: [PATCH 02/93] Lint & codeowner fixes. --- .github/CODEOWNERS | 11 ++++++++++- BUILD.bazel | 2 +- scripts/BUILD.bazel | 1 + .../android/scripts/build/TransformAndroidManifest.kt | 4 ++-- .../scripts/build/TransformAndroidManifestTest.kt | 11 +++++++---- third_party/BUILD.bazel | 2 +- 6 files changed, 22 insertions(+), 9 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 36d3b2309f8..287fae287fa 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -57,7 +57,7 @@ gradlew.bat @BenHenning # App UI strings. /app/src/main/res/values*/strings.xml @BenHenning -# Proguard configuration. +# Proguard configurations. *.pro @BenHenning # Lesson assets. @@ -85,6 +85,9 @@ buf.yaml @anandwana001 # Binary files. *.png @BenHenning +# Configurations for Bazel-built Android App Bundles. +bundle_config.pb.json + # Important codebase files. LICENSE @BenHenning NOTICE @BenHenning @@ -220,3 +223,9 @@ WORKSPACE @BenHenning # License texts. /app/src/main/res/values/third_party_dependencies.xml @BenHenning + +# Exemptions to resource shrinking. +app/src/main/res/raw/shrink_exemptions.xml @BenHenning + +# Version tracking. +version.bzl @BenHenning diff --git a/BUILD.bazel b/BUILD.bazel index a19600fe4ca..845129a3402 100644 --- a/BUILD.bazel +++ b/BUILD.bazel @@ -90,6 +90,6 @@ android_binary( # be installed on a local device or emulator using a 'bazel run' command like so: # bazel run //:install_oppia_dev [ - define_oppia_binary_flavor(flavor) + define_oppia_binary_flavor(flavor = flavor) for flavor in AVAILABLE_FLAVORS ] diff --git a/scripts/BUILD.bazel b/scripts/BUILD.bazel index a34f7234b69..8550eb00dc4 100644 --- a/scripts/BUILD.bazel +++ b/scripts/BUILD.bazel @@ -3,6 +3,7 @@ Kotlin-based scripts to help developers or perform continuous integration tasks. """ load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_jvm_binary", "kt_jvm_library") +load("@rules_java//java:defs.bzl", "java_binary") load( "//scripts:script_assets.bzl", "generate_accessibility_label_assets_list_from_text_protos", diff --git a/scripts/src/java/org/oppia/android/scripts/build/TransformAndroidManifest.kt b/scripts/src/java/org/oppia/android/scripts/build/TransformAndroidManifest.kt index 73edba5f623..fc5ec90d7e0 100644 --- a/scripts/src/java/org/oppia/android/scripts/build/TransformAndroidManifest.kt +++ b/scripts/src/java/org/oppia/android/scripts/build/TransformAndroidManifest.kt @@ -1,13 +1,13 @@ package org.oppia.android.scripts.build +import org.oppia.android.scripts.common.GitClient +import org.w3c.dom.Document import java.io.File import java.io.StringWriter import javax.xml.parsers.DocumentBuilderFactory import javax.xml.transform.TransformerFactory import javax.xml.transform.dom.DOMSource import javax.xml.transform.stream.StreamResult -import org.oppia.android.scripts.common.GitClient -import org.w3c.dom.Document private const val USAGE_STRING = "Usage: bazel run //scripts:transform_android_manifest -- " + diff --git a/scripts/src/javatests/org/oppia/android/scripts/build/TransformAndroidManifestTest.kt b/scripts/src/javatests/org/oppia/android/scripts/build/TransformAndroidManifestTest.kt index 78863120f5d..02889cc08bd 100644 --- a/scripts/src/javatests/org/oppia/android/scripts/build/TransformAndroidManifestTest.kt +++ b/scripts/src/javatests/org/oppia/android/scripts/build/TransformAndroidManifestTest.kt @@ -1,7 +1,6 @@ package org.oppia.android.scripts.build import com.google.common.truth.Truth.assertThat -import java.io.File import org.junit.Before import org.junit.Rule import org.junit.Test @@ -9,6 +8,7 @@ import org.junit.rules.TemporaryFolder import org.oppia.android.scripts.common.CommandExecutorImpl import org.oppia.android.scripts.testing.TestGitRepository import org.oppia.android.testing.assertThrows +import java.io.File /** * Tests for the transform_android_manifest utility. @@ -30,7 +30,8 @@ class TransformAndroidManifestTest { private val TEST_MANIFEST_FILE_NAME = "AndroidManifest.xml" private val TRANSFORMED_MANIFEST_FILE_NAME = "TransformedAndroidManifest.xml" - private val TEST_MANIFEST_CONTENT_WITHOUT_VERSIONS = """ + private val TEST_MANIFEST_CONTENT_WITHOUT_VERSIONS = + """ Date: Wed, 1 Sep 2021 02:00:30 -0700 Subject: [PATCH 03/93] 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 --- .github/CODEOWNERS | 2 +- .github/workflows/build_tests.yml | 8 ++++---- build_flavors.bzl | 5 ++++- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 287fae287fa..e0377080805 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -86,7 +86,7 @@ buf.yaml @anandwana001 *.png @BenHenning # Configurations for Bazel-built Android App Bundles. -bundle_config.pb.json +bundle_config.pb.json @BenHenning # Important codebase files. LICENSE @BenHenning diff --git a/.github/workflows/build_tests.yml b/.github/workflows/build_tests.yml index e3ad162f2b1..f94c19012cb 100644 --- a/.github/workflows/build_tests.yml +++ b/.github/workflows/build_tests.yml @@ -250,12 +250,12 @@ jobs: env: BAZEL_REMOTE_CACHE_URL: ${{ secrets.BAZEL_REMOTE_CACHE_URL }} run: | - bazel build --remote_http_cache=$BAZEL_REMOTE_CACHE_URL --google_credentials=./config/oppia-dev-workflow-remote-cache-credentials.json -- //:oppia_dev + bazel build --//:oppia_dev_transformed_manifest=develop --remote_http_cache=$BAZEL_REMOTE_CACHE_URL --google_credentials=./config/oppia-dev-workflow-remote-cache-credentials.json -- //:oppia_dev - name: Build Oppia developer AAB (without caching, or on a fork) if: ${{ env.ENABLE_CACHING == 'false' || github.event.pull_request.head.repo.full_name != 'oppia/oppia-android' }} run: | - bazel build -- //:oppia_dev + bazel build --//:oppia_dev_transformed_manifest=develop -- //:oppia_dev - name: Copy Oppia APK for uploading run: cp $GITHUB_WORKSPACE/bazel-bin/oppia_dev.aab /home/runner/work/oppia-android/oppia-android/ @@ -377,12 +377,12 @@ jobs: env: BAZEL_REMOTE_CACHE_URL: ${{ secrets.BAZEL_REMOTE_CACHE_URL }} run: | - bazel build --compilation_mode=opt --remote_http_cache=$BAZEL_REMOTE_CACHE_URL --google_credentials=./config/oppia-dev-workflow-remote-cache-credentials.json -- //:oppia_alpha + bazel build --compilation_mode=opt --//:oppia_alpha_transformed_manifest=develop --remote_http_cache=$BAZEL_REMOTE_CACHE_URL --google_credentials=./config/oppia-dev-workflow-remote-cache-credentials.json -- //:oppia_alpha - name: Build Oppia alpha AAB (without caching, or on a fork) if: ${{ env.ENABLE_CACHING == 'false' || github.event.pull_request.head.repo.full_name != 'oppia/oppia-android' }} run: | - bazel build --compilation_mode=opt -- //:oppia_alpha + bazel build --compilation_mode=opt --//:oppia_alpha_transformed_manifest=develop -- //:oppia_alpha - name: Copy Oppia APK for uploading run: cp $GITHUB_WORKSPACE/bazel-bin/oppia_alpha.aab /home/runner/work/oppia-android/oppia-android/ diff --git a/build_flavors.bzl b/build_flavors.bzl index b8f73684959..9ce248c26a2 100644 --- a/build_flavors.bzl +++ b/build_flavors.bzl @@ -58,6 +58,7 @@ def _transform_android_manifest_impl(ctx): major_version = ctx.attr.major_version minor_version = ctx.attr.minor_version version_code = ctx.attr.version_code + base_develop_branch_reference = ctx.build_setting_value # See corresponding transformation script for details on the passed arguments. arguments = [ @@ -68,7 +69,7 @@ def _transform_android_manifest_impl(ctx): "%s" % major_version, "%s" % minor_version, "%s" % version_code, - "origin/develop", # The base branch for computing the version name. + base_develop_branch_reference, ] # Reference: https://docs.bazel.build/versions/master/skylark/lib/actions.html#run. @@ -110,6 +111,7 @@ _transform_android_manifest = rule( ), }, implementation = _transform_android_manifest_impl, + build_setting = config.string(flag = True), ) def define_oppia_binary_flavor(flavor): @@ -136,6 +138,7 @@ def define_oppia_binary_flavor(flavor): major_version = MAJOR_VERSION, minor_version = MINOR_VERSION, version_code = VERSION_CODE, + build_setting_default = "origin/develop", ) oppia_android_application( name = "oppia_%s" % flavor, From d7945d56de9fd7a1ad7ff131953de296ffcf35cd Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 1 Sep 2021 02:09:23 -0700 Subject: [PATCH 04/93] Different attempt to fix bad develop reference in CI. --- .github/workflows/build_tests.yml | 12 ++++++++---- build_flavors.bzl | 5 +---- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/.github/workflows/build_tests.yml b/.github/workflows/build_tests.yml index f94c19012cb..b827f77733b 100644 --- a/.github/workflows/build_tests.yml +++ b/.github/workflows/build_tests.yml @@ -149,6 +149,8 @@ jobs: CACHE_DIRECTORY: ~/.bazel_cache steps: - uses: actions/checkout@v2 + with: + fetch-depth: 0 - name: Set up JDK 9 uses: actions/setup-java@v1 @@ -250,12 +252,12 @@ jobs: env: BAZEL_REMOTE_CACHE_URL: ${{ secrets.BAZEL_REMOTE_CACHE_URL }} run: | - bazel build --//:oppia_dev_transformed_manifest=develop --remote_http_cache=$BAZEL_REMOTE_CACHE_URL --google_credentials=./config/oppia-dev-workflow-remote-cache-credentials.json -- //:oppia_dev + bazel build --remote_http_cache=$BAZEL_REMOTE_CACHE_URL --google_credentials=./config/oppia-dev-workflow-remote-cache-credentials.json -- //:oppia_dev - name: Build Oppia developer AAB (without caching, or on a fork) if: ${{ env.ENABLE_CACHING == 'false' || github.event.pull_request.head.repo.full_name != 'oppia/oppia-android' }} run: | - bazel build --//:oppia_dev_transformed_manifest=develop -- //:oppia_dev + bazel build -- //:oppia_dev - name: Copy Oppia APK for uploading run: cp $GITHUB_WORKSPACE/bazel-bin/oppia_dev.aab /home/runner/work/oppia-android/oppia-android/ @@ -276,6 +278,8 @@ jobs: CACHE_DIRECTORY: ~/.bazel_cache steps: - uses: actions/checkout@v2 + with: + fetch-depth: 0 - name: Set up JDK 9 uses: actions/setup-java@v1 @@ -377,12 +381,12 @@ jobs: env: BAZEL_REMOTE_CACHE_URL: ${{ secrets.BAZEL_REMOTE_CACHE_URL }} run: | - bazel build --compilation_mode=opt --//:oppia_alpha_transformed_manifest=develop --remote_http_cache=$BAZEL_REMOTE_CACHE_URL --google_credentials=./config/oppia-dev-workflow-remote-cache-credentials.json -- //:oppia_alpha + bazel build --compilation_mode=opt --remote_http_cache=$BAZEL_REMOTE_CACHE_URL --google_credentials=./config/oppia-dev-workflow-remote-cache-credentials.json -- //:oppia_alpha - name: Build Oppia alpha AAB (without caching, or on a fork) if: ${{ env.ENABLE_CACHING == 'false' || github.event.pull_request.head.repo.full_name != 'oppia/oppia-android' }} run: | - bazel build --compilation_mode=opt --//:oppia_alpha_transformed_manifest=develop -- //:oppia_alpha + bazel build --compilation_mode=opt -- //:oppia_alpha - name: Copy Oppia APK for uploading run: cp $GITHUB_WORKSPACE/bazel-bin/oppia_alpha.aab /home/runner/work/oppia-android/oppia-android/ diff --git a/build_flavors.bzl b/build_flavors.bzl index 9ce248c26a2..b8f73684959 100644 --- a/build_flavors.bzl +++ b/build_flavors.bzl @@ -58,7 +58,6 @@ def _transform_android_manifest_impl(ctx): major_version = ctx.attr.major_version minor_version = ctx.attr.minor_version version_code = ctx.attr.version_code - base_develop_branch_reference = ctx.build_setting_value # See corresponding transformation script for details on the passed arguments. arguments = [ @@ -69,7 +68,7 @@ def _transform_android_manifest_impl(ctx): "%s" % major_version, "%s" % minor_version, "%s" % version_code, - base_develop_branch_reference, + "origin/develop", # The base branch for computing the version name. ] # Reference: https://docs.bazel.build/versions/master/skylark/lib/actions.html#run. @@ -111,7 +110,6 @@ _transform_android_manifest = rule( ), }, implementation = _transform_android_manifest_impl, - build_setting = config.string(flag = True), ) def define_oppia_binary_flavor(flavor): @@ -138,7 +136,6 @@ def define_oppia_binary_flavor(flavor): major_version = MAJOR_VERSION, minor_version = MINOR_VERSION, version_code = VERSION_CODE, - build_setting_default = "origin/develop", ) oppia_android_application( name = "oppia_%s" % flavor, From 366ab4d0c766dbb4fa50bd920098369d59be372c Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 1 Sep 2021 11:59:01 -0700 Subject: [PATCH 05/93] 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. --- .github/workflows/build_tests.yml | 1 + .github/workflows/main.yml | 2 ++ .github/workflows/static_checks.yml | 3 +++ 3 files changed, 6 insertions(+) diff --git a/.github/workflows/build_tests.yml b/.github/workflows/build_tests.yml index 9e653e266bc..46de53c94a2 100644 --- a/.github/workflows/build_tests.yml +++ b/.github/workflows/build_tests.yml @@ -13,6 +13,7 @@ on: jobs: bazel_build_app: name: Build Binary with Bazel + if: ${{ false }} runs-on: ${{ matrix.os }} strategy: matrix: diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 82bfc3713a1..3e9ebe88c59 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -18,6 +18,7 @@ on: jobs: robolectric_tests: name: Non-app Module Robolectric Tests + if: ${{ false }} runs-on: ${{ matrix.os }} strategy: matrix: @@ -95,6 +96,7 @@ jobs: app_tests: name: App Module Robolectric Tests + if: ${{ false }} runs-on: ${{ matrix.os }} strategy: matrix: diff --git a/.github/workflows/static_checks.yml b/.github/workflows/static_checks.yml index 6f3542ec995..97dd62fd5f2 100644 --- a/.github/workflows/static_checks.yml +++ b/.github/workflows/static_checks.yml @@ -44,6 +44,7 @@ jobs: linters: name: Lint Tests + if: ${{ false }} runs-on: ubuntu-18.04 steps: - uses: actions/checkout@v2 @@ -95,6 +96,7 @@ jobs: script_checks: name: Script Checks + if: ${{ false }} runs-on: ubuntu-18.04 steps: - uses: actions/checkout@v2 @@ -140,6 +142,7 @@ jobs: third_party_dependencies_check: name: Maven Dependencies Checks runs-on: ubuntu-18.04 + if: ${{ false }} steps: - uses: actions/checkout@v2 From ac73dd6df276763d8a3b267cde41018db3b24a3c Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 1 Sep 2021 19:13:48 -0700 Subject: [PATCH 06/93] 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. --- .github/workflows/static_checks.yml | 43 ++++ .github/workflows/unit_tests.yml | 105 ++++++-- scripts/BUILD.bazel | 7 + .../org/oppia/android/scripts/ci/BUILD.bazel | 15 ++ .../scripts/ci/ComputeAffectedTests.kt | 232 +++++++++++------- .../scripts/ci/RetrieveAffectedTests.kt | 19 ++ .../oppia/android/scripts/common/BUILD.bazel | 10 + .../scripts/common/ProtoStringEncoder.kt | 52 ++++ .../oppia/android/scripts/proto/BUILD.bazel | 11 + .../scripts/proto/affected_tests.proto | 11 + .../oppia/android/scripts/common/BUILD.bazel | 12 + .../scripts/common/ProtoStringEncoderTest.kt | 91 +++++++ .../license/MavenDependenciesListCheckTest.kt | 2 +- .../license/MavenDependenciesRetrieverTest.kt | 3 +- third_party/maven_install.json | 43 ++-- third_party/versions.bzl | 8 +- .../oppia/android/util/caching/BUILD.bazel | 2 +- .../oppia/android/util/extensions/BUILD.bazel | 4 +- 18 files changed, 524 insertions(+), 146 deletions(-) create mode 100644 scripts/src/java/org/oppia/android/scripts/ci/RetrieveAffectedTests.kt create mode 100644 scripts/src/java/org/oppia/android/scripts/common/ProtoStringEncoder.kt create mode 100644 scripts/src/java/org/oppia/android/scripts/proto/affected_tests.proto create mode 100644 scripts/src/javatests/org/oppia/android/scripts/common/ProtoStringEncoderTest.kt diff --git a/.github/workflows/static_checks.yml b/.github/workflows/static_checks.yml index 97dd62fd5f2..04511cf022f 100644 --- a/.github/workflows/static_checks.yml +++ b/.github/workflows/static_checks.yml @@ -98,6 +98,8 @@ jobs: name: Script Checks if: ${{ false }} runs-on: ubuntu-18.04 + env: + CACHE_DIRECTORY: ~/.bazel_cache steps: - uses: actions/checkout@v2 @@ -106,6 +108,47 @@ jobs: with: version: 4.0.0 + - uses: actions/cache@v2 + id: scripts_cache + with: + path: ${{ env.CACHE_DIRECTORY }} + key: ${{ runner.os }}-${{ env.CACHE_DIRECTORY }}-bazel-scripts-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-${{ env.CACHE_DIRECTORY }}-bazel-scripts- + ${{ runner.os }}-${{ env.CACHE_DIRECTORY }}-bazel- + + # This check is needed to ensure that Bazel's unbounded cache growth doesn't result in a + # situation where the cache never updates (e.g. due to exceeding GitHub's cache size limit) + # thereby only ever using the last successful cache version. This solution will result in a + # few slower CI actions around the time cache is detected to be too large, but it should + # incrementally improve thereafter. + - name: Ensure cache size + env: + BAZEL_CACHE_DIR: ${{ env.CACHE_DIRECTORY }} + run: | + # See https://stackoverflow.com/a/27485157 for reference. + EXPANDED_BAZEL_CACHE_PATH="${BAZEL_CACHE_DIR/#\~/$HOME}" + CACHE_SIZE_MB=$(du -smc $EXPANDED_BAZEL_CACHE_PATH | grep total | cut -f1) + echo "Total size of Bazel cache (rounded up to MBs): $CACHE_SIZE_MB" + # Use a 4.5GB threshold since actions/cache compresses the results, and Bazel caches seem + # to only increase by a few hundred megabytes across changes for unrelated branches. This + # is also a reasonable upper-bound (local tests as of 2021-03-31 suggest that a full build + # of the codebase (e.g. //...) from scratch only requires a ~2.1GB uncompressed/~900MB + # compressed cache). + if [[ "$CACHE_SIZE_MB" -gt 4500 ]]; then + echo "Cache exceeds cut-off; resetting it (will result in a slow build)" + rm -rf $EXPANDED_BAZEL_CACHE_PATH + fi + + - name: Configure Bazel to use a local cache + env: + BAZEL_CACHE_DIR: ${{ env.CACHE_DIRECTORY }} + run: | + EXPANDED_BAZEL_CACHE_PATH="${BAZEL_CACHE_DIR/#\~/$HOME}" + echo "Using $EXPANDED_BAZEL_CACHE_PATH as Bazel's cache path" + echo "build --disk_cache=$EXPANDED_BAZEL_CACHE_PATH" >> $HOME/.bazelrc + shell: bash + - name: Regex Patterns Validation Check if: always() run: | diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 3813bf8d6f1..cc2884fe08f 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -17,40 +17,78 @@ jobs: name: Compute affected tests runs-on: ubuntu-18.04 outputs: - matrix: ${{ steps.compute-test-matrix-from-affected.outputs.matrix || steps.compute-test-matrix-from-all.outputs.matrix }} - have_tests_to_run: ${{ steps.compute-test-matrix-from-affected.outputs.have_tests_to_run || steps.compute-test-matrix-from-all.outputs.have_tests_to_run }} + matrix: ${{ steps.compute-test-matrix.outputs.matrix }} + have_tests_to_run: ${{ steps.compute-test-matrix.outputs.have_tests_to_run }} + env: + CACHE_DIRECTORY: ~/.bazel_cache steps: - uses: actions/checkout@v2 with: fetch-depth: 0 + - name: Set up Bazel uses: abhinavsingh/setup-bazel@v3 with: version: 4.0.0 - - name: Compute test matrix based on affected targets - id: compute-test-matrix-from-affected - if: "!contains(github.event.pull_request.title, '[RunAllTests]')" + + - uses: actions/cache@v2 + id: scripts_cache + with: + path: ${{ env.CACHE_DIRECTORY }} + key: ${{ runner.os }}-${{ env.CACHE_DIRECTORY }}-bazel-scripts-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-${{ env.CACHE_DIRECTORY }}-bazel-scripts- + ${{ runner.os }}-${{ env.CACHE_DIRECTORY }}-bazel- + + # This check is needed to ensure that Bazel's unbounded cache growth doesn't result in a + # situation where the cache never updates (e.g. due to exceeding GitHub's cache size limit) + # thereby only ever using the last successful cache version. This solution will result in a + # few slower CI actions around the time cache is detected to be too large, but it should + # incrementally improve thereafter. + - name: Ensure cache size + env: + BAZEL_CACHE_DIR: ${{ env.CACHE_DIRECTORY }} + run: | + # See https://stackoverflow.com/a/27485157 for reference. + EXPANDED_BAZEL_CACHE_PATH="${BAZEL_CACHE_DIR/#\~/$HOME}" + CACHE_SIZE_MB=$(du -smc $EXPANDED_BAZEL_CACHE_PATH | grep total | cut -f1) + echo "Total size of Bazel cache (rounded up to MBs): $CACHE_SIZE_MB" + # Use a 4.5GB threshold since actions/cache compresses the results, and Bazel caches seem + # to only increase by a few hundred megabytes across changes for unrelated branches. This + # is also a reasonable upper-bound (local tests as of 2021-03-31 suggest that a full build + # of the codebase (e.g. //...) from scratch only requires a ~2.1GB uncompressed/~900MB + # compressed cache). + if [[ "$CACHE_SIZE_MB" -gt 4500 ]]; then + echo "Cache exceeds cut-off; resetting it (will result in a slow build)" + rm -rf $EXPANDED_BAZEL_CACHE_PATH + fi + + - name: Configure Bazel to use a local cache + env: + BAZEL_CACHE_DIR: ${{ env.CACHE_DIRECTORY }} + run: | + EXPANDED_BAZEL_CACHE_PATH="${BAZEL_CACHE_DIR/#\~/$HOME}" + echo "Using $EXPANDED_BAZEL_CACHE_PATH as Bazel's cache path" + echo "build --disk_cache=$EXPANDED_BAZEL_CACHE_PATH" >> $HOME/.bazelrc + shell: bash + + - name: Compute test matrix + id: compute-test-matrix + env: + compute_all_targets: ${{ contains(github.event.pull_request.title, '[RunAllTests]') }} # https://unix.stackexchange.com/a/338124 for reference on creating a JSON-friendly # comma-separated list of test targets for the matrix. run: | - bazel run //scripts:compute_affected_tests -- $(pwd) $(pwd)/affected_targets.log origin/develop - TEST_TARGET_LIST=$(cat ./affected_targets.log | sed 's/^\|$/"/g' | paste -sd, -) - echo "Affected tests (note that this might be all tests if on the develop branch): $TEST_TARGET_LIST" - echo "::set-output name=matrix::{\"test-target\":[$TEST_TARGET_LIST]}" - if [[ ! -z "$TEST_TARGET_LIST" ]]; then + bazel run //scripts:compute_affected_tests -- $(pwd) $(pwd)/affected_targets.log origin/develop compute_all_tests=$compute_all_targets + TEST_BUCKET_LIST=$(cat ./affected_targets.log | sed 's/^\|$/"/g' | paste -sd, -) + echo "Affected tests (note that this might be all tests if configured to run all or on the develop branch): $TEST_BUCKET_LIST" + echo "::set-output name=matrix::{\"affected-tests-bucket-base64\":[$TEST_BUCKET_LIST]}" + if [[ ! -z "$TEST_BUCKET_LIST" ]]; then echo "::set-output name=have_tests_to_run::true" else echo "::set-output name=have_tests_to_run::false" echo "No tests are detected as affected by this change. If this is wrong, you can add '[RunAllTests]' to the PR title to force a run." fi - - name: Compute test matrix based on all tests - id: compute-test-matrix-from-all - if: "contains(github.event.pull_request.title, '[RunAllTests]')" - run: | - TEST_TARGET_LIST=$(bazel query "kind(test, //...)" | sed 's/^\|$/"/g' | paste -sd, -) - echo "Affected tests (note that this might be all tests if on the develop branch): $TEST_TARGET_LIST" - echo "::set-output name=matrix::{\"test-target\":[$TEST_TARGET_LIST]}" - echo "::set-output name=have_tests_to_run::true" bazel_run_test: name: Run Bazel Test @@ -60,7 +98,7 @@ jobs: strategy: fail-fast: false max-parallel: 5 - matrix: ${{fromJson(needs.bazel_compute_affected_targets.outputs.matrix)}} + matrix: ${{ fromJson(needs.bazel_compute_affected_targets.outputs.matrix) }} env: ENABLE_CACHING: false CACHE_DIRECTORY: ~/.bazel_cache @@ -77,17 +115,29 @@ jobs: with: version: 4.0.0 + - uses: actions/cache@v2 + id: scripts_cache + with: + path: ${{ env.CACHE_DIRECTORY }} + key: ${{ runner.os }}-${{ env.CACHE_DIRECTORY }}-bazel-scripts-${{ github.sha }} + restore-keys: | + ${{ runner.os }}-${{ env.CACHE_DIRECTORY }}-bazel-scripts- + ${{ runner.os }}-${{ env.CACHE_DIRECTORY }}-bazel- + - name: Set up build environment uses: ./.github/actions/set-up-android-bazel-build-environment - - name: Compute test caching bucket + - name: Extract test caching bucket & targets env: - TEST_TARGET: ${{ matrix.test-target }} + AFFECTED_TESTS_BUCKET_BASE64: ${{ matrix.affected-tests-bucket-base64 }} run: | - echo "Test target: $TEST_TARGET" - TEST_CATEGORY=$(echo "$TEST_TARGET" | grep -oP 'org/oppia/android/(.+?)/' | cut -f 4 -d "/") + bazel run //scripts:retrieve_affected_tests -- $AFFECTED_TESTS_BUCKET_BASE64 $(pwd)/test_bucket_name $(pwd)/bazel_test_targets + TEST_CATRGORY=$(cat ./test_bucket_name) + BAZEL_TEST_TARGETS=$(cat ./bazel_test_targets) echo "Test category: $TEST_CATEGORY" + echo "Bazel test targets: BAZEL_TEST_TARGETS" echo "TEST_CACHING_BUCKET=$TEST_CATEGORY" >> $GITHUB_ENV + echo "BAZEL_TEST_TARGETS=$BAZEL_TEST_TARGETS" >> $GITHUB_ENV # For reference on this & the later cache actions, see: # https://github.com/actions/cache/issues/239#issuecomment-606950711 & @@ -96,7 +146,7 @@ jobs: # benefit from incremental build performance (assuming that actions/cache aggressively removes # older caches due to the 5GB cache limit size & Bazel's large cache size). - uses: actions/cache@v2 - id: cache + id: test_cache with: path: ${{ env.CACHE_DIRECTORY }} key: ${{ runner.os }}-${{ env.CACHE_DIRECTORY }}-bazel-tests-${{ env.TEST_CACHING_BUCKET }}-${{ github.sha }} @@ -167,13 +217,14 @@ jobs: if: ${{ env.ENABLE_CACHING == 'true' && ((github.ref == 'refs/heads/develop' && github.event_name == 'push') || (github.event.pull_request.head.repo.full_name == 'oppia/oppia-android')) }} env: BAZEL_REMOTE_CACHE_URL: ${{ secrets.BAZEL_REMOTE_CACHE_URL }} - run: bazel test --remote_http_cache=$BAZEL_REMOTE_CACHE_URL --google_credentials=./config/oppia-dev-workflow-remote-cache-credentials.json -- ${{ matrix.test-target }} + BAZEL_TEST_TARGETS: ${{ env.BAZEL_TEST_TARGETS }} + run: bazel test --remote_http_cache=$BAZEL_REMOTE_CACHE_URL --google_credentials=./config/oppia-dev-workflow-remote-cache-credentials.json -- $BAZEL_TEST_TARGETS - name: Run Oppia Test (without caching, or on a fork) if: ${{ env.ENABLE_CACHING == 'false' || ((github.ref != 'refs/heads/develop' || github.event_name != 'push') && (github.event.pull_request.head.repo.full_name != 'oppia/oppia-android')) }} env: - BAZEL_REMOTE_CACHE_URL: ${{ secrets.BAZEL_REMOTE_CACHE_URL }} - run: bazel test -- ${{ matrix.test-target }} + BAZEL_TEST_TARGETS: ${{ env.BAZEL_TEST_TARGETS }} + run: bazel test -- $BAZEL_TEST_TARGETS # Reference: https://github.community/t/127354/7. check_test_results: diff --git a/scripts/BUILD.bazel b/scripts/BUILD.bazel index f83dd74319a..65c29064892 100644 --- a/scripts/BUILD.bazel +++ b/scripts/BUILD.bazel @@ -48,6 +48,13 @@ kt_jvm_binary( runtime_deps = ["//scripts/src/java/org/oppia/android/scripts/ci:compute_affected_tests_lib"], ) +kt_jvm_binary( + name = "retrieve_affected_tests", + testonly = True, + main_class = "org.oppia.android.scripts.ci.RetrieveAffectedTestsKt", + runtime_deps = ["//scripts/src/java/org/oppia/android/scripts/ci:retrieve_affected_tests_lib"], +) + # TODO(#3428): Refactor textproto assets to subpackage level. REGEX_PATTERN_CHECK_ASSETS = generate_regex_assets_list_from_text_protos( name = "regex_asset_files", diff --git a/scripts/src/java/org/oppia/android/scripts/ci/BUILD.bazel b/scripts/src/java/org/oppia/android/scripts/ci/BUILD.bazel index 5791cac87b4..2e91f4a052e 100644 --- a/scripts/src/java/org/oppia/android/scripts/ci/BUILD.bazel +++ b/scripts/src/java/org/oppia/android/scripts/ci/BUILD.bazel @@ -14,5 +14,20 @@ kt_jvm_library( deps = [ "//scripts/src/java/org/oppia/android/scripts/common:bazel_client", "//scripts/src/java/org/oppia/android/scripts/common:git_client", + "//scripts/src/java/org/oppia/android/scripts/common:proto_string_encoder", + "//scripts/src/java/org/oppia/android/scripts/proto:affected_tests_java_proto_lite", + ], +) + +kt_jvm_library( + name = "retrieve_affected_tests_lib", + testonly = True, + srcs = [ + "RetrieveAffectedTests.kt", + ], + visibility = ["//scripts:oppia_script_binary_visibility"], + deps = [ + "//scripts/src/java/org/oppia/android/scripts/common:proto_string_encoder", + "//scripts/src/java/org/oppia/android/scripts/proto:affected_tests_java_proto_lite", ], ) diff --git a/scripts/src/java/org/oppia/android/scripts/ci/ComputeAffectedTests.kt b/scripts/src/java/org/oppia/android/scripts/ci/ComputeAffectedTests.kt index be1edfb5b3e..c8d683bd964 100644 --- a/scripts/src/java/org/oppia/android/scripts/ci/ComputeAffectedTests.kt +++ b/scripts/src/java/org/oppia/android/scripts/ci/ComputeAffectedTests.kt @@ -2,6 +2,8 @@ package org.oppia.android.scripts.ci import org.oppia.android.scripts.common.BazelClient import org.oppia.android.scripts.common.GitClient +import org.oppia.android.scripts.common.ProtoStringEncoder.Companion.toCompressedBase64 +import org.oppia.android.scripts.proto.AffectedTestsBucket import java.io.File import java.util.Locale import kotlin.system.exitProcess @@ -21,116 +23,160 @@ import kotlin.system.exitProcess * Generally, this is 'origin/develop'. * * Example: - * bazel run //scripts:compute_affected_tests -- $(pwd) /tmp/affected_tests.log origin/develop + * bazel run //scripts:compute_affected_tests -- $(pwd) /tmp/affected_test_buckets.proto64 \\ + * origin/develop compute_all_tests=false */ fun main(args: Array) { - if (args.size < 3) { - println( - "Usage: bazel run //scripts:compute_affected_tests --" + - " " - ) - exitProcess(1) - } - - val pathToRoot = args[0] - val pathToOutputFile = args[1] - val baseDevelopBranchReference = args[2] - val rootDirectory = File(pathToRoot).absoluteFile - val outputFile = File(pathToOutputFile).absoluteFile - - check(rootDirectory.isDirectory) { "Expected '$pathToRoot' to be a directory" } - check(rootDirectory.list().contains("WORKSPACE")) { - "Expected script to be run from the workspace's root directory" - } - - println("Running from directory root: $rootDirectory") - println("Saving results to file: $outputFile") - - val gitClient = GitClient(rootDirectory, baseDevelopBranchReference) - val bazelClient = BazelClient(rootDirectory) - println("Current branch: ${gitClient.currentBranch}") - println("Most recent common commit: ${gitClient.branchMergeBase}") - when (gitClient.currentBranch.toLowerCase(Locale.getDefault())) { - "develop" -> computeAffectedTargetsForDevelopBranch(bazelClient, outputFile) - else -> - computeAffectedTargetsForNonDevelopBranch(gitClient, bazelClient, rootDirectory, outputFile) - } + ComputeAffectedTests().main(args) } -private fun computeAffectedTargetsForDevelopBranch(bazelClient: BazelClient, outputFile: File) { - // Compute & print all test targets since this is the develop branch. - println("Computing all test targets for the develop branch") +private class ComputeAffectedTests { + companion object { + private const val COMPUTE_ALL_TESTS_PREFIX = "compute_all_tests=" + + private val VALID_TEST_BUCKET_NAMES = listOf( + "app", + "data", + "domain", + "instrumentation", + "scripts", + "testing", + "utility" + ) - val allTestTargets = bazelClient.retrieveAllTestTargets() - println() + private val EXTRACT_BUCKET_REGEX = "^//([^(/|:)]+?)[/:].+?\$".toRegex() + } - // Filtering out the targets to be ignored. - val nonInstrumentationAffectedTestTargets = allTestTargets.filter { targetPath -> - !targetPath - .startsWith( - "//instrumentation/src/javatests/org/oppia/android/instrumentation/player", - ignoreCase = true + fun main(args: Array) { + if (args.size < 4) { + println( + "Usage: bazel run //scripts:compute_affected_tests --" + + " " + + " " ) + exitProcess(1) + } + + val pathToRoot = args[0] + val pathToOutputFile = args[1] + val baseDevelopBranchReference = args[2] + val computeAllTestsSetting = args[3].let { + check(it.startsWith(COMPUTE_ALL_TESTS_PREFIX)) { + "Expected last argument to start with '$COMPUTE_ALL_TESTS_PREFIX'" + } + val computeAllTestsValue = it.removePrefix(COMPUTE_ALL_TESTS_PREFIX) + return@let computeAllTestsValue.toBooleanStrictOrNull() + ?: error( + "Expected last argument to have 'true' or 'false' passed to it, not:" + + " '$computeAllTestsValue'" + ) + } + val rootDirectory = File(pathToRoot).absoluteFile + + check(rootDirectory.isDirectory) { "Expected '$pathToRoot' to be a directory" } + check(rootDirectory.list().contains("WORKSPACE")) { + "Expected script to be run from the workspace's root directory" + } + + println("Running from directory root: $rootDirectory") + + val gitClient = GitClient(rootDirectory, baseDevelopBranchReference) + val bazelClient = BazelClient(rootDirectory) + println("Current branch: ${gitClient.currentBranch}") + println("Most recent common commit: ${gitClient.branchMergeBase}") + + val currentBranch = gitClient.currentBranch.toLowerCase(Locale.getDefault()) + val affectedTestTargets = if (computeAllTestsSetting || currentBranch == "develop") { + computeAllTestTargets(bazelClient) + } else computeAffectedTargetsForNonDevelopBranch(gitClient, bazelClient, rootDirectory) + + val filteredTestTargets = filterTargets(affectedTestTargets) + println() + println("Affected test targets:") + println(filteredTestTargets.joinToString(separator = "\n") { "- $it" }) + + val affectedTestBuckets = bucketTargets(filteredTestTargets) + val encodedTestBuckets = affectedTestBuckets.map { it.toCompressedBase64() } + File(pathToOutputFile).printWriter().use { writer -> + encodedTestBuckets.forEach(writer::println) + } } - println( - "Affected test targets:" + - "\n${nonInstrumentationAffectedTestTargets.joinToString(separator = "\n") { "- $it" }}" - ) - outputFile.printWriter().use { writer -> - nonInstrumentationAffectedTestTargets.forEach { writer.println(it) } + private fun computeAllTestTargets(bazelClient: BazelClient): List { + println("Computing all test targets") + return bazelClient.retrieveAllTestTargets() } -} -private fun computeAffectedTargetsForNonDevelopBranch( - gitClient: GitClient, - bazelClient: BazelClient, - rootDirectory: File, - outputFile: File -) { - // Compute the list of changed files, but exclude files which no longer exist (since bazel query - // can't handle these well). - val changedFiles = gitClient.changedFiles.filter { filepath -> - File(rootDirectory, filepath).exists() + private fun computeAffectedTargetsForNonDevelopBranch( + gitClient: GitClient, + bazelClient: BazelClient, + rootDirectory: File + ): List { + // Compute the list of changed files, but exclude files which no longer exist (since bazel query + // can't handle these well). + val changedFiles = gitClient.changedFiles.filter { filepath -> + File(rootDirectory, filepath).exists() + } + println("Changed files (per Git): $changedFiles") + + val changedFileTargets = bazelClient.retrieveBazelTargets(changedFiles).toSet() + println("Changed Bazel file targets: $changedFileTargets") + + val affectedTestTargets = bazelClient.retrieveRelatedTestTargets(changedFileTargets).toSet() + println("Affected Bazel test targets: $affectedTestTargets") + + // Compute the list of Bazel files that were changed. + val changedBazelFiles = changedFiles.filter { file -> + file.endsWith(".bzl", ignoreCase = true) || + file.endsWith(".bazel", ignoreCase = true) || + file == "WORKSPACE" + } + println("Changed Bazel-specific support files: $changedBazelFiles") + + // Compute the list of affected tests based on BUILD/Bazel/WORKSPACE files. These are generally + // framed as: if a BUILD file changes, run all tests transitively connected to it. + val transitiveTestTargets = bazelClient.retrieveTransitiveTestTargets(changedBazelFiles) + println("Affected test targets due to transitive build deps: $transitiveTestTargets") + + return (affectedTestTargets + transitiveTestTargets).toSet().toList() } - println("Changed files (per Git): $changedFiles") - - val changedFileTargets = bazelClient.retrieveBazelTargets(changedFiles).toSet() - println("Changed Bazel file targets: $changedFileTargets") - - val affectedTestTargets = bazelClient.retrieveRelatedTestTargets(changedFileTargets).toSet() - println("Affected Bazel test targets: $affectedTestTargets") - // Compute the list of Bazel files that were changed. - val changedBazelFiles = changedFiles.filter { file -> - file.endsWith(".bzl", ignoreCase = true) || - file.endsWith(".bazel", ignoreCase = true) || - file == "WORKSPACE" + private fun filterTargets(testTargets: List): List { + // Filtering out the targets to be ignored. + return testTargets.filter { targetPath -> + !targetPath + .startsWith( + "//instrumentation/src/javatests/org/oppia/android/instrumentation/player", + ignoreCase = true + ) + } } - println("Changed Bazel-specific support files: $changedBazelFiles") - // Compute the list of affected tests based on BUILD/Bazel/WORKSPACE files. These are generally - // framed as: if a BUILD file changes, run all tests transitively connected to it. - val transitiveTestTargets = bazelClient.retrieveTransitiveTestTargets(changedBazelFiles) - println("Affected test targets due to transitive build deps: $transitiveTestTargets") - - val allAffectedTestTargets = (affectedTestTargets + transitiveTestTargets).toSet() + private fun bucketTargets(testTargets: List): List { + return testTargets.map { target -> + AffectedTestsBucket.newBuilder().apply { + cacheBucketName = retrieveBucket(target) + addAffectedTestTargets(target) + }.build() + } + } - // Filtering out the targets to be ignored. - val nonInstrumentationAffectedTestTargets = allAffectedTestTargets.filter { targetPath -> - !targetPath - .startsWith( - "//instrumentation/src/javatests/org/oppia/android/instrumentation/player", - ignoreCase = true - ) + private fun retrieveBucket(target: String): String { + return EXTRACT_BUCKET_REGEX.matchEntire(target)?.groupValues?.maybeSecond()?.also { + check(it in VALID_TEST_BUCKET_NAMES) { + "Invalid bucket name: $it (expected one of: $VALID_TEST_BUCKET_NAMES)" + } + } ?: error("Invalid target: $target (could not extract bucket name)") } - println() - println( - "Affected test targets:" + - "\n${nonInstrumentationAffectedTestTargets.joinToString(separator = "\n") { "- $it" }}" - ) - outputFile.printWriter().use { writer -> - nonInstrumentationAffectedTestTargets.forEach { writer.println(it) } + private fun List.maybeSecond(): E? = if (size >= 2) this[1] else null + + // Needed since the codebase isn't yet using Kotlin 1.5, so this function isn't available. + private fun String.toBooleanStrictOrNull(): Boolean? { + return when (toLowerCase(Locale.getDefault())) { + "false" -> false + "true" -> true + else -> null + } } } diff --git a/scripts/src/java/org/oppia/android/scripts/ci/RetrieveAffectedTests.kt b/scripts/src/java/org/oppia/android/scripts/ci/RetrieveAffectedTests.kt new file mode 100644 index 00000000000..ad07948df49 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/ci/RetrieveAffectedTests.kt @@ -0,0 +1,19 @@ +package org.oppia.android.scripts.ci + +import org.oppia.android.scripts.common.ProtoStringEncoder.Companion.mergeFromCompressedBase64 +import org.oppia.android.scripts.proto.AffectedTestsBucket +import java.io.File + +fun main(args: Array) { + val protoBase64 = args[0] + val bucketNameOutputFile = File(args[1]) + val testTargetsOutputFile = File(args[2]) + val affectedTestsBucket = + AffectedTestsBucket.getDefaultInstance().mergeFromCompressedBase64(protoBase64) + bucketNameOutputFile.printWriter().use { writer -> + writer.println(affectedTestsBucket.cacheBucketName) + } + testTargetsOutputFile.printWriter().use { writer -> + writer.println(affectedTestsBucket.affectedTestTargetsList.joinToString(separator = " ")) + } +} diff --git a/scripts/src/java/org/oppia/android/scripts/common/BUILD.bazel b/scripts/src/java/org/oppia/android/scripts/common/BUILD.bazel index aedc872a55c..84a5d2e949c 100644 --- a/scripts/src/java/org/oppia/android/scripts/common/BUILD.bazel +++ b/scripts/src/java/org/oppia/android/scripts/common/BUILD.bazel @@ -40,6 +40,16 @@ kt_jvm_library( visibility = ["//scripts:oppia_script_library_visibility"], ) +kt_jvm_library( + name = "proto_string_encoder", + testonly = True, + srcs = ["ProtoStringEncoder.kt"], + visibility = ["//scripts:oppia_script_library_visibility"], + deps = [ + "//third_party:com_google_protobuf_protobuf-javalite", + ], +) + kt_jvm_library( name = "repository_file", testonly = True, diff --git a/scripts/src/java/org/oppia/android/scripts/common/ProtoStringEncoder.kt b/scripts/src/java/org/oppia/android/scripts/common/ProtoStringEncoder.kt new file mode 100644 index 00000000000..e1a68d12c1d --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/common/ProtoStringEncoder.kt @@ -0,0 +1,52 @@ +package org.oppia.android.scripts.common + +import com.google.protobuf.MessageLite +import java.io.ByteArrayOutputStream +import java.util.Base64 +import java.util.zip.GZIPInputStream +import java.util.zip.GZIPOutputStream + +/** + * Encoder/decoder for stringifying lite protos in a compacted way. See companion object functions + * for the available API. + */ +class ProtoStringEncoder private constructor() { + private val base64Encoder by lazy { Base64.getEncoder() } + private val base64Decoder by lazy { Base64.getDecoder() } + + private fun encodeToCompressedBase64(message: M): String { + val compressedMessage = ByteArrayOutputStream().also { byteOutputStream -> + GZIPOutputStream(byteOutputStream).use { message.writeTo(it) } + }.toByteArray() + return base64Encoder.encodeToString(compressedMessage) + } + + private fun decodeFromCompressedBase64(base64: String, exampleMessage: M): M { + val compressedMessage = base64Decoder.decode(base64) + return GZIPInputStream(compressedMessage.inputStream()).use { + @Suppress("UNCHECKED_CAST") // Proto guarantees type safety here. + exampleMessage.newBuilderForType().mergeFrom(it).build() as M + } + } + + companion object { + private val protoStringEncoder by lazy { ProtoStringEncoder() } + + /** + * Returns a compressed Base64 representation of this proto that can later be decoded using + * [mergeFromCompressedBase64]. + */ + fun M.toCompressedBase64(): String = + protoStringEncoder.encodeToCompressedBase64(this) + + /** + * Merges this proto into a new proto decoded from the specified Base64 string. It's expected + * that string was constructed using [toCompressedBase64]. + * + * Note that this method ignores any properties in the current proto; it will treat it like a + * default instance when populating fields from the new proto. + */ + fun M.mergeFromCompressedBase64(base64: String): M = + protoStringEncoder.decodeFromCompressedBase64(base64, exampleMessage = this) + } +} diff --git a/scripts/src/java/org/oppia/android/scripts/proto/BUILD.bazel b/scripts/src/java/org/oppia/android/scripts/proto/BUILD.bazel index a7296d63615..b8ea5902201 100644 --- a/scripts/src/java/org/oppia/android/scripts/proto/BUILD.bazel +++ b/scripts/src/java/org/oppia/android/scripts/proto/BUILD.bazel @@ -9,6 +9,17 @@ For more context on adding a new proto library, please refer to model/BUILD.baze load("@rules_java//java:defs.bzl", "java_lite_proto_library", "java_proto_library") load("@rules_proto//proto:defs.bzl", "proto_library") +proto_library( + name = "affected_tests_proto", + srcs = ["affected_tests.proto"], +) + +java_lite_proto_library( + name = "affected_tests_java_proto_lite", + visibility = ["//scripts:oppia_script_library_visibility"], + deps = [":affected_tests_proto"], +) + proto_library( name = "filename_pattern_validation_checks_proto", srcs = ["filename_pattern_validation_checks.proto"], diff --git a/scripts/src/java/org/oppia/android/scripts/proto/affected_tests.proto b/scripts/src/java/org/oppia/android/scripts/proto/affected_tests.proto new file mode 100644 index 00000000000..e9ce67dda84 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/proto/affected_tests.proto @@ -0,0 +1,11 @@ +syntax = "proto3"; + +package proto; + +option java_package = "org.oppia.android.scripts.proto"; +option java_multiple_files = true; + +message AffectedTestsBucket { + string cache_bucket_name = 1; + repeated string affected_test_targets = 2; +} diff --git a/scripts/src/javatests/org/oppia/android/scripts/common/BUILD.bazel b/scripts/src/javatests/org/oppia/android/scripts/common/BUILD.bazel index 2c97ab2f1b1..b4559459055 100644 --- a/scripts/src/javatests/org/oppia/android/scripts/common/BUILD.bazel +++ b/scripts/src/javatests/org/oppia/android/scripts/common/BUILD.bazel @@ -39,6 +39,18 @@ kt_jvm_test( ], ) +kt_jvm_test( + name = "ProtoStringEncoderTest", + srcs = ["ProtoStringEncoderTest.kt"], + deps = [ + "//model:test_models", + "//scripts/src/java/org/oppia/android/scripts/common:proto_string_encoder", + "//testing:assertion_helpers", + "//third_party:com_google_truth_extensions_truth-liteproto-extension", + "//third_party:com_google_truth_truth", + ], +) + kt_jvm_test( name = "RepositoryFileTest", srcs = ["RepositoryFileTest.kt"], diff --git a/scripts/src/javatests/org/oppia/android/scripts/common/ProtoStringEncoderTest.kt b/scripts/src/javatests/org/oppia/android/scripts/common/ProtoStringEncoderTest.kt new file mode 100644 index 00000000000..b03c106500a --- /dev/null +++ b/scripts/src/javatests/org/oppia/android/scripts/common/ProtoStringEncoderTest.kt @@ -0,0 +1,91 @@ +package org.oppia.android.scripts.common + +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.extensions.proto.LiteProtoTruth.assertThat +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.oppia.android.app.model.TestMessage +import org.oppia.android.scripts.common.ProtoStringEncoder.Companion.mergeFromCompressedBase64 +import org.oppia.android.scripts.common.ProtoStringEncoder.Companion.toCompressedBase64 +import org.oppia.android.testing.assertThrows +import java.io.EOFException +import java.util.zip.ZipException + +/** Tests for [ProtoStringEncoder]. */ +// Function name: test names are conventionally named with underscores. +@Suppress("FunctionName") +class ProtoStringEncoderTest { + @Rule + @JvmField + var tempFolder = TemporaryFolder() + + @Test + fun testEncode_defaultProto_producesString() { + val testMessage = TestMessage.getDefaultInstance() + + val base64 = testMessage.toCompressedBase64() + + // Verify that a valid base64 string is produced. + assertThat(base64).isNotEmpty() + assertThat(base64.length % 4).isEqualTo(0) + assertThat(base64).matches("^([A-Z]|[a-z]|[0-9]|\\+|/)+=?=?$") + } + + @Test + fun testDecode_emptyString_throwsException() { + assertThrows(EOFException::class) { + TestMessage.getDefaultInstance().mergeFromCompressedBase64(base64 = "") + } + } + + @Test + fun testDecode_badString_throwsException() { + assertThrows(ZipException::class) { + TestMessage.getDefaultInstance().mergeFromCompressedBase64(base64 = "asdf") + } + } + + @Test + fun testDecode_encodedDefaultProto_mergedFromDefaultProto_producesDefaultProto() { + val testMessage = TestMessage.getDefaultInstance() + val encodedTestMessage = testMessage.toCompressedBase64() + + val decodedMessage = + TestMessage.getDefaultInstance().mergeFromCompressedBase64(encodedTestMessage) + + assertThat(decodedMessage).isEqualToDefaultInstance() + } + + @Test + fun testDecode_encodedNonDefaultProto_mergedFromDefaultProto_producesValidProto() { + val testMessage = TestMessage.newBuilder().apply { + strValue = "test string" + }.build() + val encodedTestMessage = testMessage.toCompressedBase64() + + val decodedMessage = + TestMessage.getDefaultInstance().mergeFromCompressedBase64(encodedTestMessage) + + assertThat(decodedMessage).isNotEqualToDefaultInstance() + assertThat(decodedMessage.strValue).isEqualTo("test string") + } + + @Test + fun testDecode_encodedNonDefaultProto_mergedFromNonDefaultProto_producesProtoIgnoringBase() { + val testMessage = TestMessage.newBuilder().apply { + strValue = "test string" + }.build() + val encodedTestMessage = testMessage.toCompressedBase64() + + val decodedMessage = + TestMessage.newBuilder().apply { + intValue = 123 + }.build().mergeFromCompressedBase64(encodedTestMessage) + + // The intValue is not kept when reading the test message. + assertThat(decodedMessage).isNotEqualToDefaultInstance() + assertThat(decodedMessage.strValue).isEqualTo("test string") + assertThat(decodedMessage.intValue).isEqualTo(0) + } +} diff --git a/scripts/src/javatests/org/oppia/android/scripts/license/MavenDependenciesListCheckTest.kt b/scripts/src/javatests/org/oppia/android/scripts/license/MavenDependenciesListCheckTest.kt index 4172db0f756..f30b261f53a 100644 --- a/scripts/src/javatests/org/oppia/android/scripts/license/MavenDependenciesListCheckTest.kt +++ b/scripts/src/javatests/org/oppia/android/scripts/license/MavenDependenciesListCheckTest.kt @@ -38,7 +38,7 @@ class MavenDependenciesListCheckTest { private val DATA_BINDING_DEP_WITH_THIRD_PARTY_PREFIX = "//third_party:androidx_databinding_databinding-adapters" private val PROTO_DEP_WITH_THIRD_PARTY_PREFIX = - "//third_party:com_google_protobuf_protobuf-lite" + "//third_party:com_google_protobuf_protobuf-javalite" private val GLIDE_DEP_WITH_THIRD_PARTY_PREFIX = "//third_party:com_github_bumptech_glide_annotations" private val FIREBASE_DEP_WITH_THIRD_PARTY_PREFIX = diff --git a/scripts/src/javatests/org/oppia/android/scripts/license/MavenDependenciesRetrieverTest.kt b/scripts/src/javatests/org/oppia/android/scripts/license/MavenDependenciesRetrieverTest.kt index 3bf3c43e81a..5dd8baec8b3 100644 --- a/scripts/src/javatests/org/oppia/android/scripts/license/MavenDependenciesRetrieverTest.kt +++ b/scripts/src/javatests/org/oppia/android/scripts/license/MavenDependenciesRetrieverTest.kt @@ -36,7 +36,8 @@ class MavenDependenciesRetrieverTest { private val DATA_BINDING_DEP_WITH_THIRD_PARTY_PREFIX = "//third_party:androidx_databinding_databinding-adapters" - private val PROTO_DEP_WITH_THIRD_PARTY_PREFIX = "//third_party:com_google_protobuf_protobuf-lite" + private val PROTO_DEP_WITH_THIRD_PARTY_PREFIX = + "//third_party:com_google_protobuf_protobuf-javalite" private val GLIDE_DEP_WITH_THIRD_PARTY_PREFIX = "//third_party:com_github_bumptech_glide_annotations" private val FIREBASE_DEP_WITH_THIRD_PARTY_PREFIX = diff --git a/third_party/maven_install.json b/third_party/maven_install.json index 3886ce482d9..e771414887d 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": -1770608701, - "__RESOLVED_ARTIFACTS_HASH": -348454682, + "__INPUT_ARTIFACTS_HASH": 979421834, + "__RESOLVED_ARTIFACTS_HASH": 164642601, "conflict_resolution": { "androidx.appcompat:appcompat:1.0.2": "androidx.appcompat:appcompat:1.2.0", "androidx.core:core:1.0.1": "androidx.core:core:1.3.0", @@ -6276,19 +6276,34 @@ "url": "https://repo1.maven.org/maven2/com/google/protobuf/protobuf-java/3.17.3/protobuf-java-3.17.3-sources.jar" }, { - "coord": "com.google.protobuf:protobuf-lite:3.0.0", + "coord": "com.google.protobuf:protobuf-javalite:3.17.3", "dependencies": [], "directDependencies": [], - "file": "v1/https/repo1.maven.org/maven2/com/google/protobuf/protobuf-lite/3.0.0/protobuf-lite-3.0.0.jar", + "file": "v1/https/repo1.maven.org/maven2/com/google/protobuf/protobuf-javalite/3.17.3/protobuf-javalite-3.17.3.jar", "mirror_urls": [ - "https://maven.google.com/com/google/protobuf/protobuf-lite/3.0.0/protobuf-lite-3.0.0.jar", - "https://repo1.maven.org/maven2/com/google/protobuf/protobuf-lite/3.0.0/protobuf-lite-3.0.0.jar", - "https://maven.fabric.io/public/com/google/protobuf/protobuf-lite/3.0.0/protobuf-lite-3.0.0.jar", - "https://maven.google.com/com/google/protobuf/protobuf-lite/3.0.0/protobuf-lite-3.0.0.jar", - "https://repo1.maven.org/maven2/com/google/protobuf/protobuf-lite/3.0.0/protobuf-lite-3.0.0.jar" + "https://maven.google.com/com/google/protobuf/protobuf-javalite/3.17.3/protobuf-javalite-3.17.3.jar", + "https://repo1.maven.org/maven2/com/google/protobuf/protobuf-javalite/3.17.3/protobuf-javalite-3.17.3.jar", + "https://maven.fabric.io/public/com/google/protobuf/protobuf-javalite/3.17.3/protobuf-javalite-3.17.3.jar", + "https://maven.google.com/com/google/protobuf/protobuf-javalite/3.17.3/protobuf-javalite-3.17.3.jar", + "https://repo1.maven.org/maven2/com/google/protobuf/protobuf-javalite/3.17.3/protobuf-javalite-3.17.3.jar" ], - "sha256": "f44739e95d21ca352aff947086d3176e8c61cf91ccbc100cf335d0964de44fe0", - "url": "https://repo1.maven.org/maven2/com/google/protobuf/protobuf-lite/3.0.0/protobuf-lite-3.0.0.jar" + "sha256": "dc643901cc9d95998a1e45ab11e75d4237a7e1947bcbca0b7eca569aaf5e714d", + "url": "https://repo1.maven.org/maven2/com/google/protobuf/protobuf-javalite/3.17.3/protobuf-javalite-3.17.3.jar" + }, + { + "coord": "com.google.protobuf:protobuf-javalite:jar:sources:3.17.3", + "dependencies": [], + "directDependencies": [], + "file": "v1/https/repo1.maven.org/maven2/com/google/protobuf/protobuf-javalite/3.17.3/protobuf-javalite-3.17.3-sources.jar", + "mirror_urls": [ + "https://maven.google.com/com/google/protobuf/protobuf-javalite/3.17.3/protobuf-javalite-3.17.3-sources.jar", + "https://repo1.maven.org/maven2/com/google/protobuf/protobuf-javalite/3.17.3/protobuf-javalite-3.17.3-sources.jar", + "https://maven.fabric.io/public/com/google/protobuf/protobuf-javalite/3.17.3/protobuf-javalite-3.17.3-sources.jar", + "https://maven.google.com/com/google/protobuf/protobuf-javalite/3.17.3/protobuf-javalite-3.17.3-sources.jar", + "https://repo1.maven.org/maven2/com/google/protobuf/protobuf-javalite/3.17.3/protobuf-javalite-3.17.3-sources.jar" + ], + "sha256": "b7bc7b41c266f59c921bf094b4325fb9bcdd0a8d95d742db8cca3a1c76503f9b", + "url": "https://repo1.maven.org/maven2/com/google/protobuf/protobuf-javalite/3.17.3/protobuf-javalite-3.17.3-sources.jar" }, { "coord": "com.google.truth.extensions:truth-liteproto-extension:0.43", @@ -10005,12 +10020,6 @@ "directDependencies": [], "file": null }, - { - "coord": "com.google.protobuf:protobuf-lite:jar:sources:3.0.0", - "dependencies": [], - "directDependencies": [], - "file": null - }, { "coord": "io.fabric.sdk.android:fabric:aar:sources:1.4.7", "dependencies": [], diff --git a/third_party/versions.bzl b/third_party/versions.bzl index 2d94cf461a0..3b2585e5349 100644 --- a/third_party/versions.bzl +++ b/third_party/versions.bzl @@ -57,7 +57,7 @@ MAVEN_PRODUCTION_DEPENDENCY_VERSIONS = { "com.google.firebase:firebase-crashlytics": "17.1.1", "com.google.gms:google-services": "4.3.3", "com.google.guava:guava": "28.1-android", - "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.moshi:moshi-kotlin-codegen": "1.11.0", "com.squareup.okhttp3:okhttp": "4.1.0", @@ -116,8 +116,8 @@ HTTP_DEPENDENCY_VERSIONS = { "version": "3.11.0", }, "rules_java": { - "sha": "220b87d8cfabd22d1c6d8e3cdb4249abd4c93dcc152e0667db061fb1b957ee68", - "version": "0.1.1", + "sha": "34b41ec683e67253043ab1a3d1e8b7c61e4e8edefbcad485381328c934d072fe", + "version": "4.0.0", }, "rules_jvm": { "sha": "f36441aa876c4f6427bfb2d1f2d723b48e9d930b62662bf723ddfb8fc80f0140", @@ -129,7 +129,7 @@ HTTP_DEPENDENCY_VERSIONS = { }, "rules_proto": { "sha": "602e7161d9195e50246177e7c55b2f39950a9cf7366f74ed5f22fd45750cd208", - "version": "97d8af4dc474595af3900dd85cb3a29ad28cc313", + "version": "c0b62f2f46c85c16cb3b5e9e921f0d00e3101934", }, } 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 c4e3f041c39..f665291fa39 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 @@ -15,7 +15,7 @@ kt_android_library( ], visibility = ["//:oppia_api_visibility"], deps = [ - "//third_party:com_google_protobuf_protobuf-lite", + "//third_party:com_google_protobuf_protobuf-javalite", "//third_party:javax_inject_javax_inject", "//utility/src/main/java/org/oppia/android/util/logging:console_logger", ], diff --git a/utility/src/main/java/org/oppia/android/util/extensions/BUILD.bazel b/utility/src/main/java/org/oppia/android/util/extensions/BUILD.bazel index 37e40584de7..525f5160210 100644 --- a/utility/src/main/java/org/oppia/android/util/extensions/BUILD.bazel +++ b/utility/src/main/java/org/oppia/android/util/extensions/BUILD.bazel @@ -11,7 +11,7 @@ kt_android_library( ], visibility = ["//:oppia_api_visibility"], deps = [ - "//third_party:com_google_protobuf_protobuf-lite", + "//third_party:com_google_protobuf_protobuf-javalite", ], ) @@ -22,6 +22,6 @@ kt_android_library( ], visibility = ["//:oppia_api_visibility"], deps = [ - "//third_party:com_google_protobuf_protobuf-lite", + "//third_party:com_google_protobuf_protobuf-javalite", ], ) From 3aec233de0a2e8436a55012cd9fbce3418743ad8 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 1 Sep 2021 19:32:40 -0700 Subject: [PATCH 07/93] 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. --- .../scripts/ci/ComputeAffectedTests.kt | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/scripts/src/java/org/oppia/android/scripts/ci/ComputeAffectedTests.kt b/scripts/src/java/org/oppia/android/scripts/ci/ComputeAffectedTests.kt index c8d683bd964..c6cd25e4c3f 100644 --- a/scripts/src/java/org/oppia/android/scripts/ci/ComputeAffectedTests.kt +++ b/scripts/src/java/org/oppia/android/scripts/ci/ComputeAffectedTests.kt @@ -45,6 +45,9 @@ private class ComputeAffectedTests { ) private val EXTRACT_BUCKET_REGEX = "^//([^(/|:)]+?)[/:].+?\$".toRegex() + + /** Corresponds to the maximum number of tests that can be part of a single shard. */ + private const val MAX_TEST_COUNT_PER_SHARD = 10 } fun main(args: Array) { @@ -95,7 +98,9 @@ private class ComputeAffectedTests { println("Affected test targets:") println(filteredTestTargets.joinToString(separator = "\n") { "- $it" }) - val affectedTestBuckets = bucketTargets(filteredTestTargets) + // TODO: take more than 3 buckets once CI is stable. + // TODO: add randomization. + val affectedTestBuckets = bucketTargets(filteredTestTargets).take(3) val encodedTestBuckets = affectedTestBuckets.map { it.toCompressedBase64() } File(pathToOutputFile).printWriter().use { writer -> encodedTestBuckets.forEach(writer::println) @@ -153,15 +158,21 @@ private class ComputeAffectedTests { } private fun bucketTargets(testTargets: List): List { - return testTargets.map { target -> - AffectedTestsBucket.newBuilder().apply { - cacheBucketName = retrieveBucket(target) - addAffectedTestTargets(target) - }.build() + val targetBuckets = testTargets.groupBy { retrieveBucketName(it) } + val shardedBuckets = targetBuckets.mapValues { (_, targets) -> + targets.chunked(MAX_TEST_COUNT_PER_SHARD) + } + return shardedBuckets.entries.flatMap { (bucketName, shardedTargets) -> + shardedTargets.map { targets -> + AffectedTestsBucket.newBuilder().apply { + cacheBucketName = bucketName + addAllAffectedTestTargets(targets) + }.build() + } } } - private fun retrieveBucket(target: String): String { + private fun retrieveBucketName(target: String): String { return EXTRACT_BUCKET_REGEX.matchEntire(target)?.groupValues?.maybeSecond()?.also { check(it in VALID_TEST_BUCKET_NAMES) { "Invalid bucket name: $it (expected one of: $VALID_TEST_BUCKET_NAMES)" From 650570b981cebf3511950908cd4f9c7c8d1e8262 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 1 Sep 2021 19:49:13 -0700 Subject: [PATCH 08/93] 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). --- .github/workflows/unit_tests.yml | 50 +++++++++++++++++++++++++++----- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index cc2884fe08f..444f35dce8b 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -127,15 +127,24 @@ jobs: - name: Set up build environment uses: ./.github/actions/set-up-android-bazel-build-environment + - name: Configure Bazel to use a local cache (for scripts) + env: + BAZEL_CACHE_DIR: ${{ env.CACHE_DIRECTORY }} + run: | + EXPANDED_BAZEL_CACHE_PATH="${BAZEL_CACHE_DIR/#\~/$HOME}" + echo "Using $EXPANDED_BAZEL_CACHE_PATH as Bazel's cache path" + echo "build --disk_cache=$EXPANDED_BAZEL_CACHE_PATH" >> $HOME/.bazelrc + shell: bash + - name: Extract test caching bucket & targets env: AFFECTED_TESTS_BUCKET_BASE64: ${{ matrix.affected-tests-bucket-base64 }} run: | bazel run //scripts:retrieve_affected_tests -- $AFFECTED_TESTS_BUCKET_BASE64 $(pwd)/test_bucket_name $(pwd)/bazel_test_targets - TEST_CATRGORY=$(cat ./test_bucket_name) + TEST_CATEGORY=$(cat ./test_bucket_name) BAZEL_TEST_TARGETS=$(cat ./bazel_test_targets) echo "Test category: $TEST_CATEGORY" - echo "Bazel test targets: BAZEL_TEST_TARGETS" + echo "Bazel test targets: $BAZEL_TEST_TARGETS" echo "TEST_CACHING_BUCKET=$TEST_CATEGORY" >> $GITHUB_ENV echo "BAZEL_TEST_TARGETS=$BAZEL_TEST_TARGETS" >> $GITHUB_ENV @@ -179,7 +188,7 @@ jobs: rm -rf $EXPANDED_BAZEL_CACHE_PATH fi - - name: Configure Bazel to use a local cache + - name: Configure Bazel to use a local cache (for tests) env: BAZEL_CACHE_DIR: ${{ env.CACHE_DIRECTORY }} run: | @@ -213,18 +222,45 @@ jobs: cd $GITHUB_WORKSPACE git secret reveal - - name: Run Oppia Test (with caching, non-fork only) + # See https://www.cyberciti.biz/faq/unix-for-loop-1-to-10/ for for-loop reference. + - name: Build Oppia Tests (with caching, non-fork only) + if: ${{ env.ENABLE_CACHING == 'true' && ((github.ref == 'refs/heads/develop' && github.event_name == 'push') || (github.event.pull_request.head.repo.full_name == 'oppia/oppia-android')) }} + env: + BAZEL_REMOTE_CACHE_URL: ${{ secrets.BAZEL_REMOTE_CACHE_URL }} + BAZEL_TEST_TARGETS: ${{ env.BAZEL_TEST_TARGETS }} + run: | + # Attempt to build 5 times in case there are flaky builds. + # TODO(#3759): Remove this once there are no longer app test build failures. + i=0 + while [ $i -ne 5 ]; do + i=$(( $i+1 )) + bazel build --keep_going --remote_http_cache=$BAZEL_REMOTE_CACHE_URL --google_credentials=./config/oppia-dev-workflow-remote-cache-credentials.json -- $BAZEL_TEST_TARGETS + done + + - name: Build Oppia Tests (without caching, or on a fork) + if: ${{ env.ENABLE_CACHING == 'false' || ((github.ref != 'refs/heads/develop' || github.event_name != 'push') && (github.event.pull_request.head.repo.full_name != 'oppia/oppia-android')) }} + env: + BAZEL_TEST_TARGETS: ${{ env.BAZEL_TEST_TARGETS }} + run: | + # Attempt to build 5 times in case there are flaky builds. + # TODO(#3759): Remove this once there are no longer app test build failures. + i=0 + while [ $i -ne 5 ]; do + i=$(( $i+1 )) + bazel build --keep_going -- $BAZEL_TEST_TARGETS + done + - name: Run Oppia Tests (with caching, non-fork only) if: ${{ env.ENABLE_CACHING == 'true' && ((github.ref == 'refs/heads/develop' && github.event_name == 'push') || (github.event.pull_request.head.repo.full_name == 'oppia/oppia-android')) }} env: BAZEL_REMOTE_CACHE_URL: ${{ secrets.BAZEL_REMOTE_CACHE_URL }} BAZEL_TEST_TARGETS: ${{ env.BAZEL_TEST_TARGETS }} - run: bazel test --remote_http_cache=$BAZEL_REMOTE_CACHE_URL --google_credentials=./config/oppia-dev-workflow-remote-cache-credentials.json -- $BAZEL_TEST_TARGETS + run: bazel test --keep_going --remote_http_cache=$BAZEL_REMOTE_CACHE_URL --google_credentials=./config/oppia-dev-workflow-remote-cache-credentials.json -- $BAZEL_TEST_TARGETS - - name: Run Oppia Test (without caching, or on a fork) + - name: Run Oppia Tests (without caching, or on a fork) if: ${{ env.ENABLE_CACHING == 'false' || ((github.ref != 'refs/heads/develop' || github.event_name != 'push') && (github.event.pull_request.head.repo.full_name != 'oppia/oppia-android')) }} env: BAZEL_TEST_TARGETS: ${{ env.BAZEL_TEST_TARGETS }} - run: bazel test -- $BAZEL_TEST_TARGETS + run: bazel test --keep_going -- $BAZEL_TEST_TARGETS # Reference: https://github.community/t/127354/7. check_test_results: From d66bb2c99c68e91ace0806b213559cac19e8b7e4 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 1 Sep 2021 20:37:39 -0700 Subject: [PATCH 09/93] 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. --- .github/workflows/build_tests.yml | 1 - .github/workflows/main.yml | 2 -- .github/workflows/static_checks.yml | 3 --- .../org/oppia/android/scripts/ci/ComputeAffectedTests.kt | 9 ++++----- 4 files changed, 4 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build_tests.yml b/.github/workflows/build_tests.yml index 46de53c94a2..9e653e266bc 100644 --- a/.github/workflows/build_tests.yml +++ b/.github/workflows/build_tests.yml @@ -13,7 +13,6 @@ on: jobs: bazel_build_app: name: Build Binary with Bazel - if: ${{ false }} runs-on: ${{ matrix.os }} strategy: matrix: diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3e9ebe88c59..82bfc3713a1 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -18,7 +18,6 @@ on: jobs: robolectric_tests: name: Non-app Module Robolectric Tests - if: ${{ false }} runs-on: ${{ matrix.os }} strategy: matrix: @@ -96,7 +95,6 @@ jobs: app_tests: name: App Module Robolectric Tests - if: ${{ false }} runs-on: ${{ matrix.os }} strategy: matrix: diff --git a/.github/workflows/static_checks.yml b/.github/workflows/static_checks.yml index 04511cf022f..bc8250f7c0f 100644 --- a/.github/workflows/static_checks.yml +++ b/.github/workflows/static_checks.yml @@ -44,7 +44,6 @@ jobs: linters: name: Lint Tests - if: ${{ false }} runs-on: ubuntu-18.04 steps: - uses: actions/checkout@v2 @@ -96,7 +95,6 @@ jobs: script_checks: name: Script Checks - if: ${{ false }} runs-on: ubuntu-18.04 env: CACHE_DIRECTORY: ~/.bazel_cache @@ -185,7 +183,6 @@ jobs: third_party_dependencies_check: name: Maven Dependencies Checks runs-on: ubuntu-18.04 - if: ${{ false }} steps: - uses: actions/checkout@v2 diff --git a/scripts/src/java/org/oppia/android/scripts/ci/ComputeAffectedTests.kt b/scripts/src/java/org/oppia/android/scripts/ci/ComputeAffectedTests.kt index c6cd25e4c3f..ed95b8641d8 100644 --- a/scripts/src/java/org/oppia/android/scripts/ci/ComputeAffectedTests.kt +++ b/scripts/src/java/org/oppia/android/scripts/ci/ComputeAffectedTests.kt @@ -47,7 +47,7 @@ private class ComputeAffectedTests { private val EXTRACT_BUCKET_REGEX = "^//([^(/|:)]+?)[/:].+?\$".toRegex() /** Corresponds to the maximum number of tests that can be part of a single shard. */ - private const val MAX_TEST_COUNT_PER_SHARD = 10 + private const val MAX_TEST_COUNT_PER_SHARD = 20 } fun main(args: Array) { @@ -98,9 +98,7 @@ private class ComputeAffectedTests { println("Affected test targets:") println(filteredTestTargets.joinToString(separator = "\n") { "- $it" }) - // TODO: take more than 3 buckets once CI is stable. - // TODO: add randomization. - val affectedTestBuckets = bucketTargets(filteredTestTargets).take(3) + val affectedTestBuckets = bucketTargets(filteredTestTargets) val encodedTestBuckets = affectedTestBuckets.map { it.toCompressedBase64() } File(pathToOutputFile).printWriter().use { writer -> encodedTestBuckets.forEach(writer::println) @@ -160,7 +158,8 @@ private class ComputeAffectedTests { private fun bucketTargets(testTargets: List): List { val targetBuckets = testTargets.groupBy { retrieveBucketName(it) } val shardedBuckets = targetBuckets.mapValues { (_, targets) -> - targets.chunked(MAX_TEST_COUNT_PER_SHARD) + // Use randomization to encourage cache breadth & potentially improve workflow performance. + targets.shuffled().chunked(MAX_TEST_COUNT_PER_SHARD) } return shardedBuckets.entries.flatMap { (bucketName, shardedTargets) -> shardedTargets.map { targets -> From 3969a6d8c6778dc92f001c23303ea72b27bf5d4f Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 1 Sep 2021 23:23:09 -0700 Subject: [PATCH 10/93] 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. --- .github/workflows/unit_tests.yml | 9 +- .../scripts/ci/ComputeAffectedTests.kt | 182 +++++++++++++++--- 2 files changed, 156 insertions(+), 35 deletions(-) diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 444f35dce8b..bbd04d6f9f6 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -82,7 +82,7 @@ jobs: bazel run //scripts:compute_affected_tests -- $(pwd) $(pwd)/affected_targets.log origin/develop compute_all_tests=$compute_all_targets TEST_BUCKET_LIST=$(cat ./affected_targets.log | sed 's/^\|$/"/g' | paste -sd, -) echo "Affected tests (note that this might be all tests if configured to run all or on the develop branch): $TEST_BUCKET_LIST" - echo "::set-output name=matrix::{\"affected-tests-bucket-base64\":[$TEST_BUCKET_LIST]}" + echo "::set-output name=matrix::{\"affected-tests-bucket-base64-encoded-shard\":[$TEST_BUCKET_LIST]}" if [[ ! -z "$TEST_BUCKET_LIST" ]]; then echo "::set-output name=have_tests_to_run::true" else @@ -138,9 +138,12 @@ jobs: - name: Extract test caching bucket & targets env: - AFFECTED_TESTS_BUCKET_BASE64: ${{ matrix.affected-tests-bucket-base64 }} + AFFECTED_TESTS_BUCKET_BASE64_ENCODED_SHARD: ${{ matrix.affected-tests-bucket-base64-encoded-shard }} run: | - bazel run //scripts:retrieve_affected_tests -- $AFFECTED_TESTS_BUCKET_BASE64 $(pwd)/test_bucket_name $(pwd)/bazel_test_targets + # See https://stackoverflow.com/a/29903172 for cut logic. This is needed to remove the + # user-friendly shard prefix from the matrix value. + AFFECTED_TESTS_BUCKET_BASE64=$(echo "$AFFECTED_TESTS_BUCKET_BASE64_ENCODED_SHARD" | cut -d ";" -f 2) + bazel run //scripts:retrieve_affected_tests -- $AFFECTED_TESTS_BUCKET_BASE64_ENCODED_SHARD $(pwd)/test_bucket_name $(pwd)/bazel_test_targets TEST_CATEGORY=$(cat ./test_bucket_name) BAZEL_TEST_TARGETS=$(cat ./bazel_test_targets) echo "Test category: $TEST_CATEGORY" diff --git a/scripts/src/java/org/oppia/android/scripts/ci/ComputeAffectedTests.kt b/scripts/src/java/org/oppia/android/scripts/ci/ComputeAffectedTests.kt index ed95b8641d8..6c007770900 100644 --- a/scripts/src/java/org/oppia/android/scripts/ci/ComputeAffectedTests.kt +++ b/scripts/src/java/org/oppia/android/scripts/ci/ComputeAffectedTests.kt @@ -33,21 +33,7 @@ fun main(args: Array) { private class ComputeAffectedTests { companion object { private const val COMPUTE_ALL_TESTS_PREFIX = "compute_all_tests=" - - private val VALID_TEST_BUCKET_NAMES = listOf( - "app", - "data", - "domain", - "instrumentation", - "scripts", - "testing", - "utility" - ) - - private val EXTRACT_BUCKET_REGEX = "^//([^(/|:)]+?)[/:].+?\$".toRegex() - - /** Corresponds to the maximum number of tests that can be part of a single shard. */ - private const val MAX_TEST_COUNT_PER_SHARD = 20 + private const val GENERIC_TEST_BUCKET_NAME = "generic" } fun main(args: Array) { @@ -98,10 +84,15 @@ private class ComputeAffectedTests { println("Affected test targets:") println(filteredTestTargets.joinToString(separator = "\n") { "- $it" }) + // Bucket the targets & then shuffle them so that shards are run in different orders each time + // (to avoid situations where the longest/most expensive tests are run last). val affectedTestBuckets = bucketTargets(filteredTestTargets) - val encodedTestBuckets = affectedTestBuckets.map { it.toCompressedBase64() } + val encodedTestBucketEntries = + affectedTestBuckets.associateBy { it.toCompressedBase64() }.entries.shuffled() File(pathToOutputFile).printWriter().use { writer -> - encodedTestBuckets.forEach(writer::println) + encodedTestBucketEntries.forEachIndexed { index, (encoded, bucket) -> + writer.println("${bucket.cacheBucketName}-shard$index;$encoded") + } } } @@ -156,11 +147,56 @@ private class ComputeAffectedTests { } private fun bucketTargets(testTargets: List): List { - val targetBuckets = testTargets.groupBy { retrieveBucketName(it) } - val shardedBuckets = targetBuckets.mapValues { (_, targets) -> - // Use randomization to encourage cache breadth & potentially improve workflow performance. - targets.shuffled().chunked(MAX_TEST_COUNT_PER_SHARD) - } + // Group first by the bucket, then by the grouping strategy. Here's what's happening here: + // 1. Create: Map> + // 2. Convert to: Iterable>> + // 3. Convert to: Map>>> + // 4. Convert to: Map>> + val groupedBuckets: Map>> = + testTargets.groupBy { TestBucket.retrieveCorrespondingTestBucket(it) } + .entries.groupBy( + keySelector = { checkNotNull(it.key).groupingStrategy }, + valueTransform = { checkNotNull(it.key) to it.value } + ).mapValues { (_, bucketLists) -> bucketLists.toMap() } + + // Next, properly segment buckets by splitting out individual ones and collecting like one: + // 5. Convert to: Map>> + val partitionedBuckets: Map>> = + groupedBuckets.entries.flatMap { (strategy, buckets) -> + return@flatMap when (strategy) { + GroupingStrategy.BUCKET_SEPARATELY -> { + // Each entry in the combined map should be a separate entry in the segmented map: + // 1. Start with: Map> + // 2. Convert to: Map>> + // 3. Convert to: Map>> + // 4. Convert to: Iterable>>> + buckets.mapValues { (testBucket, targets) -> mapOf(testBucket to targets) } + .mapKeys { (testBucket, _) -> testBucket.cacheBucketName } + .entries.map { (cacheName, bucket) -> cacheName to bucket } + } + GroupingStrategy.BUCKET_GENERICALLY -> listOf(GENERIC_TEST_BUCKET_NAME to buckets) + } + }.toMap() + + // Next, collapse the test bucket lists & partition them based on the common sharding strategy + // for each group: + // 6. Convert to: Map>> + val shardedBuckets: Map>> = + partitionedBuckets.mapValues { (_, bucketMap) -> + val shardingStrategies = bucketMap.keys.map { it.shardingStrategy }.toSet() + check(shardingStrategies.size == 1) { + "Error: expected all buckets in the same partition to share a sharding strategy:" + + " ${bucketMap.keys} (strategies: $shardingStrategies)" + } + val maxTestCountPerShard = shardingStrategies.first().maxTestCountPerShard + val allPartitionTargets = bucketMap.values.flatten() + + // Use randomization to encourage cache breadth & potentially improve workflow performance. + allPartitionTargets.shuffled().chunked(maxTestCountPerShard) + } + + // Finally, compile into a list of protos: + // 7. Convert to List return shardedBuckets.entries.flatMap { (bucketName, shardedTargets) -> shardedTargets.map { targets -> AffectedTestsBucket.newBuilder().apply { @@ -171,16 +207,6 @@ private class ComputeAffectedTests { } } - private fun retrieveBucketName(target: String): String { - return EXTRACT_BUCKET_REGEX.matchEntire(target)?.groupValues?.maybeSecond()?.also { - check(it in VALID_TEST_BUCKET_NAMES) { - "Invalid bucket name: $it (expected one of: $VALID_TEST_BUCKET_NAMES)" - } - } ?: error("Invalid target: $target (could not extract bucket name)") - } - - private fun List.maybeSecond(): E? = if (size >= 2) this[1] else null - // Needed since the codebase isn't yet using Kotlin 1.5, so this function isn't available. private fun String.toBooleanStrictOrNull(): Boolean? { return when (toLowerCase(Locale.getDefault())) { @@ -189,4 +215,96 @@ private class ComputeAffectedTests { else -> null } } + + private enum class TestBucket( + val cacheBucketName: String, + val groupingStrategy: GroupingStrategy, + val shardingStrategy: ShardingStrategy + ) { + APP( + cacheBucketName = "app", + groupingStrategy = GroupingStrategy.BUCKET_SEPARATELY, + shardingStrategy = ShardingStrategy.SMALL_PARTITIONS + ), + DATA( + cacheBucketName = "data", + groupingStrategy = GroupingStrategy.BUCKET_GENERICALLY, + shardingStrategy = ShardingStrategy.LARGE_PARTITIONS + ), + DOMAIN( + cacheBucketName = "domain", + groupingStrategy = GroupingStrategy.BUCKET_SEPARATELY, + shardingStrategy = ShardingStrategy.LARGE_PARTITIONS + ), + INSTRUMENTATION( + cacheBucketName = "instrumentation", + groupingStrategy = GroupingStrategy.BUCKET_GENERICALLY, + shardingStrategy = ShardingStrategy.LARGE_PARTITIONS + ), + SCRIPTS( + cacheBucketName = "scripts", + groupingStrategy = GroupingStrategy.BUCKET_SEPARATELY, + shardingStrategy = ShardingStrategy.MEDIUM_PARTITIONS + ), + TESTING( + cacheBucketName = "testing", + groupingStrategy = GroupingStrategy.BUCKET_GENERICALLY, + shardingStrategy = ShardingStrategy.LARGE_PARTITIONS + ), + UTILITY( + cacheBucketName = "utility", + groupingStrategy = GroupingStrategy.BUCKET_GENERICALLY, + shardingStrategy = ShardingStrategy.LARGE_PARTITIONS + ); + + companion object { + private val EXTRACT_BUCKET_REGEX = "^//([^(/|:)]+?)[/:].+?\$".toRegex() + + fun retrieveCorrespondingTestBucket(targetName: String): TestBucket? { + return EXTRACT_BUCKET_REGEX.matchEntire(targetName) + ?.groupValues + ?.maybeSecond() + ?.let { bucket -> + values().find { it.cacheBucketName == bucket } + ?: error( + "Invalid bucket name: $bucket (expected one of:" + + " ${values().map { it.cacheBucketName }})" + ) + } ?: error("Invalid target: $targetName (could not extract bucket name)") + } + + private fun List.maybeSecond(): E? = if (size >= 2) this[1] else null + } + } + + private enum class GroupingStrategy { + /** Indicates that a particular test bucket should be sharded by itself. */ + BUCKET_SEPARATELY, + + /** + * Indicates that a particular test bucket should be combined with all other generically grouped + * buckets. + */ + BUCKET_GENERICALLY + } + + private enum class ShardingStrategy(val maxTestCountPerShard: Int) { + /** + * Indicates that the tests for a test bucket run very quickly and don't need as much + * parallelization. + */ + LARGE_PARTITIONS(maxTestCountPerShard = 50), + + /** + * Indicates that the tests for a test bucket are somewhere between [LARGE_PARTITIONS] and + * [SMALL_PARTITIONS]. + */ + MEDIUM_PARTITIONS(maxTestCountPerShard = 25), + + /** + * Indicates that the tests for a test bucket run slowly and require more parallelization for + * faster CI runs. + */ + SMALL_PARTITIONS(maxTestCountPerShard = 15) + } } From e7b2a0dbe84d6ad0e31519df92d8275e6bc02465 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Thu, 2 Sep 2021 01:27:51 -0700 Subject: [PATCH 11/93] Add new tests & fix static analysis errors. --- .github/workflows/static_checks.yml | 3 + scripts/assets/maven_dependencies.textproto | 4 +- .../scripts/ci/ComputeAffectedTests.kt | 139 +++-- .../scripts/ci/RetrieveAffectedTests.kt | 31 ++ .../scripts/testing/TestBazelWorkspace.kt | 13 +- .../org/oppia/android/scripts/ci/BUILD.bazel | 13 + .../scripts/ci/ComputeAffectedTestsTest.kt | 501 ++++++++++++++++-- .../scripts/ci/RetrieveAffectedTestsTest.kt | 147 +++++ 8 files changed, 746 insertions(+), 105 deletions(-) create mode 100644 scripts/src/javatests/org/oppia/android/scripts/ci/RetrieveAffectedTestsTest.kt diff --git a/.github/workflows/static_checks.yml b/.github/workflows/static_checks.yml index bc8250f7c0f..ff531ffbebb 100644 --- a/.github/workflows/static_checks.yml +++ b/.github/workflows/static_checks.yml @@ -180,6 +180,9 @@ jobs: gh issue list --limit 2000 --repo oppia/oppia-android --json number > $(pwd)/open_issues.json bazel run //scripts:todo_open_check -- $(pwd) scripts/assets/todo_open_exemptions.pb open_issues.json + # Note that caching is intentionally not enabled for this check since licenses should always be + # verified without any potential influence from earlier builds (i.e. always from a clean build to + # ensure the results exactly match the current state of the repository). third_party_dependencies_check: name: Maven Dependencies Checks runs-on: ubuntu-18.04 diff --git a/scripts/assets/maven_dependencies.textproto b/scripts/assets/maven_dependencies.textproto index 7ebc41af1c6..f18308b616d 100644 --- a/scripts/assets/maven_dependencies.textproto +++ b/scripts/assets/maven_dependencies.textproto @@ -565,8 +565,8 @@ maven_dependency { } } maven_dependency { - artifact_name: "com.google.protobuf:protobuf-lite:3.0.0" - artifact_version: "3.0.0" + artifact_name: "com.google.protobuf:protobuf-javalite:3.17.3" + artifact_version: "3.17.3" license { license_name: "Simplified BSD License" extracted_copy_link { diff --git a/scripts/src/java/org/oppia/android/scripts/ci/ComputeAffectedTests.kt b/scripts/src/java/org/oppia/android/scripts/ci/ComputeAffectedTests.kt index 6c007770900..5aea7c69969 100644 --- a/scripts/src/java/org/oppia/android/scripts/ci/ComputeAffectedTests.kt +++ b/scripts/src/java/org/oppia/android/scripts/ci/ComputeAffectedTests.kt @@ -8,60 +8,96 @@ import java.io.File import java.util.Locale import kotlin.system.exitProcess +private const val COMPUTE_ALL_TESTS_PREFIX = "compute_all_tests=" +private const val MAX_TEST_COUNT_PER_LARGE_SHARD = 50 +private const val MAX_TEST_COUNT_PER_MEDIUM_SHARD = 25 +private const val MAX_TEST_COUNT_PER_SMALL_SHARD = 15 + /** * The main entrypoint for computing the list of affected test targets based on changes in the local * Oppia Android Git repository. * * Usage: * bazel run //scripts:compute_affected_tests -- \\ - * + * \\ + * * * Arguments: * - path_to_directory_root: directory path to the root of the Oppia Android repository. * - path_to_output_file: path to the file in which the affected test targets will be printed. * - base_develop_branch_reference: the reference to the local develop branch that should be use. * Generally, this is 'origin/develop'. + * - compute_all_tests: whether to compute a list of all tests to run. * * Example: * bazel run //scripts:compute_affected_tests -- $(pwd) /tmp/affected_test_buckets.proto64 \\ * origin/develop compute_all_tests=false */ fun main(args: Array) { - ComputeAffectedTests().main(args) + if (args.size < 4) { + println( + "Usage: bazel run //scripts:compute_affected_tests --" + + " " + + " " + ) + exitProcess(1) + } + + val pathToRoot = args[0] + val pathToOutputFile = args[1] + val baseDevelopBranchReference = args[2] + val computeAllTestsSetting = args[3].let { + check(it.startsWith(COMPUTE_ALL_TESTS_PREFIX)) { + "Expected last argument to start with '$COMPUTE_ALL_TESTS_PREFIX'" + } + val computeAllTestsValue = it.removePrefix(COMPUTE_ALL_TESTS_PREFIX) + return@let computeAllTestsValue.toBooleanStrictOrNull() + ?: error( + "Expected last argument to have 'true' or 'false' passed to it, not:" + + " '$computeAllTestsValue'" + ) + } + ComputeAffectedTests().compute( + pathToRoot, pathToOutputFile, baseDevelopBranchReference, computeAllTestsSetting + ) } -private class ComputeAffectedTests { - companion object { - private const val COMPUTE_ALL_TESTS_PREFIX = "compute_all_tests=" - private const val GENERIC_TEST_BUCKET_NAME = "generic" +// Needed since the codebase isn't yet using Kotlin 1.5, so this function isn't available. +private fun String.toBooleanStrictOrNull(): Boolean? { + return when (toLowerCase(Locale.getDefault())) { + "false" -> false + "true" -> true + else -> null } +} - fun main(args: Array) { - if (args.size < 4) { - println( - "Usage: bazel run //scripts:compute_affected_tests --" + - " " + - " " - ) - exitProcess(1) - } +/** Utility used to compute affected test targets. */ +class ComputeAffectedTests( + val maxTestCountPerLargeShard: Int = MAX_TEST_COUNT_PER_LARGE_SHARD, + val maxTestCountPerMediumShard: Int = MAX_TEST_COUNT_PER_MEDIUM_SHARD, + val maxTestCountPerSmallShard: Int = MAX_TEST_COUNT_PER_SMALL_SHARD +) { + private companion object { + private const val GENERIC_TEST_BUCKET_NAME = "generic" + } - val pathToRoot = args[0] - val pathToOutputFile = args[1] - val baseDevelopBranchReference = args[2] - val computeAllTestsSetting = args[3].let { - check(it.startsWith(COMPUTE_ALL_TESTS_PREFIX)) { - "Expected last argument to start with '$COMPUTE_ALL_TESTS_PREFIX'" - } - val computeAllTestsValue = it.removePrefix(COMPUTE_ALL_TESTS_PREFIX) - return@let computeAllTestsValue.toBooleanStrictOrNull() - ?: error( - "Expected last argument to have 'true' or 'false' passed to it, not:" + - " '$computeAllTestsValue'" - ) - } + /** + * Computes a list of tests to run. + * + * @param pathToRoot the absolute path to the working root directory + * @param pathToOutputFile the absolute path to the file in which the encoded Base64 test bucket + * protos should be printed + * @param baseDevelopBranchReference see [GitClient] + * @param computeAllTestsSetting whether all tests should be outputted versus only the ones which + * are affected by local changes in the repository + */ + fun compute( + pathToRoot: String, + pathToOutputFile: String, + baseDevelopBranchReference: String, + computeAllTestsSetting: Boolean + ) { val rootDirectory = File(pathToRoot).absoluteFile - check(rootDirectory.isDirectory) { "Expected '$pathToRoot' to be a directory" } check(rootDirectory.list().contains("WORKSPACE")) { "Expected script to be run from the workspace's root directory" @@ -188,7 +224,11 @@ private class ComputeAffectedTests { "Error: expected all buckets in the same partition to share a sharding strategy:" + " ${bucketMap.keys} (strategies: $shardingStrategies)" } - val maxTestCountPerShard = shardingStrategies.first().maxTestCountPerShard + val maxTestCountPerShard = when (shardingStrategies.first()) { + ShardingStrategy.LARGE_PARTITIONS -> maxTestCountPerLargeShard + ShardingStrategy.MEDIUM_PARTITIONS -> maxTestCountPerMediumShard + ShardingStrategy.SMALL_PARTITIONS -> maxTestCountPerSmallShard + } val allPartitionTargets = bucketMap.values.flatten() // Use randomization to encourage cache breadth & potentially improve workflow performance. @@ -207,50 +247,54 @@ private class ComputeAffectedTests { } } - // Needed since the codebase isn't yet using Kotlin 1.5, so this function isn't available. - private fun String.toBooleanStrictOrNull(): Boolean? { - return when (toLowerCase(Locale.getDefault())) { - "false" -> false - "true" -> true - else -> null - } - } - private enum class TestBucket( val cacheBucketName: String, val groupingStrategy: GroupingStrategy, val shardingStrategy: ShardingStrategy ) { + /** Corresponds to app layer tests. */ APP( cacheBucketName = "app", groupingStrategy = GroupingStrategy.BUCKET_SEPARATELY, shardingStrategy = ShardingStrategy.SMALL_PARTITIONS ), + + /** Corresponds to data layer tests. */ DATA( cacheBucketName = "data", groupingStrategy = GroupingStrategy.BUCKET_GENERICALLY, shardingStrategy = ShardingStrategy.LARGE_PARTITIONS ), + + /** Corresponds to domain layer tests. */ DOMAIN( cacheBucketName = "domain", groupingStrategy = GroupingStrategy.BUCKET_SEPARATELY, shardingStrategy = ShardingStrategy.LARGE_PARTITIONS ), + + /** Corresponds to instrumentation tests. */ INSTRUMENTATION( cacheBucketName = "instrumentation", groupingStrategy = GroupingStrategy.BUCKET_GENERICALLY, shardingStrategy = ShardingStrategy.LARGE_PARTITIONS ), + + /** Corresponds to scripts tests. */ SCRIPTS( cacheBucketName = "scripts", groupingStrategy = GroupingStrategy.BUCKET_SEPARATELY, shardingStrategy = ShardingStrategy.MEDIUM_PARTITIONS ), + + /** Corresponds to testing utility tests. */ TESTING( cacheBucketName = "testing", groupingStrategy = GroupingStrategy.BUCKET_GENERICALLY, shardingStrategy = ShardingStrategy.LARGE_PARTITIONS ), + + /** Corresponds to production utility tests. */ UTILITY( cacheBucketName = "utility", groupingStrategy = GroupingStrategy.BUCKET_GENERICALLY, @@ -260,8 +304,9 @@ private class ComputeAffectedTests { companion object { private val EXTRACT_BUCKET_REGEX = "^//([^(/|:)]+?)[/:].+?\$".toRegex() - fun retrieveCorrespondingTestBucket(targetName: String): TestBucket? { - return EXTRACT_BUCKET_REGEX.matchEntire(targetName) + /** Returns the [TestBucket] that corresponds to the specific [testTarget]. */ + fun retrieveCorrespondingTestBucket(testTarget: String): TestBucket? { + return EXTRACT_BUCKET_REGEX.matchEntire(testTarget) ?.groupValues ?.maybeSecond() ?.let { bucket -> @@ -270,7 +315,7 @@ private class ComputeAffectedTests { "Invalid bucket name: $bucket (expected one of:" + " ${values().map { it.cacheBucketName }})" ) - } ?: error("Invalid target: $targetName (could not extract bucket name)") + } ?: error("Invalid target: $testTarget (could not extract bucket name)") } private fun List.maybeSecond(): E? = if (size >= 2) this[1] else null @@ -288,23 +333,23 @@ private class ComputeAffectedTests { BUCKET_GENERICALLY } - private enum class ShardingStrategy(val maxTestCountPerShard: Int) { + private enum class ShardingStrategy { /** * Indicates that the tests for a test bucket run very quickly and don't need as much * parallelization. */ - LARGE_PARTITIONS(maxTestCountPerShard = 50), + LARGE_PARTITIONS, /** * Indicates that the tests for a test bucket are somewhere between [LARGE_PARTITIONS] and * [SMALL_PARTITIONS]. */ - MEDIUM_PARTITIONS(maxTestCountPerShard = 25), + MEDIUM_PARTITIONS, /** * Indicates that the tests for a test bucket run slowly and require more parallelization for * faster CI runs. */ - SMALL_PARTITIONS(maxTestCountPerShard = 15) + SMALL_PARTITIONS } } diff --git a/scripts/src/java/org/oppia/android/scripts/ci/RetrieveAffectedTests.kt b/scripts/src/java/org/oppia/android/scripts/ci/RetrieveAffectedTests.kt index ad07948df49..e37e4691ac0 100644 --- a/scripts/src/java/org/oppia/android/scripts/ci/RetrieveAffectedTests.kt +++ b/scripts/src/java/org/oppia/android/scripts/ci/RetrieveAffectedTests.kt @@ -3,8 +3,39 @@ package org.oppia.android.scripts.ci import org.oppia.android.scripts.common.ProtoStringEncoder.Companion.mergeFromCompressedBase64 import org.oppia.android.scripts.proto.AffectedTestsBucket import java.io.File +import kotlin.system.exitProcess +/** + * The main entrypoint for retrieving the list of affected tests from a particular encoded Base64 + * bucket. This is used to parse the output from compute_affected_tests. + * + * Usage: + * bazel run //scripts:retrieve_affected_tests -- \\ + * \\ + * + * + * Arguments: + * - encoded_proto_in_base64: the compressed & Base64-encoded [AffectedTestsBucket] proto computed + * by compute_affected_tests. + * - path_to_bucket_name_output_file: path to the file where the test bucket name corresponding to + * this bucket should be printed. + * - path_to_test_target_list_output_file: path to the file where the list of affected test targets + * corresponding to this bucket should be printed. + * + * Example: + * bazel run //scripts:retrieve_affected_tests -- $AFFECTED_BUCKETS_BASE64_ENCODED_PROTO \\ + * $(pwd)/test_bucket_name $(pwd)/bazel_test_targets + */ fun main(args: Array) { + if (args.size < 3) { + println( + "Usage: bazel run //scripts:retrieve_affected_tests --" + + " " + + " " + ) + exitProcess(1) + } + val protoBase64 = args[0] val bucketNameOutputFile = File(args[1]) val testTargetsOutputFile = File(args[2]) diff --git a/scripts/src/java/org/oppia/android/scripts/testing/TestBazelWorkspace.kt b/scripts/src/java/org/oppia/android/scripts/testing/TestBazelWorkspace.kt index c86567477a6..20c9e18119d 100644 --- a/scripts/src/java/org/oppia/android/scripts/testing/TestBazelWorkspace.kt +++ b/scripts/src/java/org/oppia/android/scripts/testing/TestBazelWorkspace.kt @@ -70,15 +70,18 @@ class TestBazelWorkspace(private val temporaryRootFolder: TemporaryFolder) { if (!File(temporaryRootFolder.root, subpackage.replace(".", "/")).exists()) { temporaryRootFolder.newFolder(*(subpackage.split(".")).toTypedArray()) } - val newBuildFile = temporaryRootFolder.newFile("${subpackage.replace(".", "/")}/BUILD.bazel") - newBuildFile + val newBuildFileRelativePath = "${subpackage.replace(".", "/")}/BUILD.bazel" + val newBuildFile = File(temporaryRootFolder.root, newBuildFileRelativePath) + if (newBuildFile.exists()) { + newBuildFile + } else temporaryRootFolder.newFile(newBuildFileRelativePath) } else rootBuildFile prepareBuildFileForTests(buildFile) testFileMap[testName] = testFile val generatedDependencyExpression = if (withGeneratedDependency) { testDependencyNameMap[testName] = dependencyTargetName ?: error("Something went wrong.") - "\":$dependencyTargetName\"," + "\"$dependencyTargetName\"," } else "" val extraDependencyExpression = withExtraDependency?.let { "\"$it\"," } ?: "" buildFile.appendText( @@ -140,7 +143,7 @@ class TestBazelWorkspace(private val temporaryRootFolder: TemporaryFolder) { prepareBuildFileForLibraries(rootBuildFile) val depFile = temporaryRootFolder.newFile("$dependencyName.kt") - libraryFileMap[libTargetName] = depFile + libraryFileMap["//:$libTargetName"] = depFile rootBuildFile.appendText( """ kt_jvm_library( @@ -150,7 +153,7 @@ class TestBazelWorkspace(private val temporaryRootFolder: TemporaryFolder) { """.trimIndent() + "\n" ) - return libTargetName to (setOf(depFile, rootBuildFile) + prereqFiles) + return "//:$libTargetName" to (setOf(depFile, rootBuildFile) + prereqFiles) } /** diff --git a/scripts/src/javatests/org/oppia/android/scripts/ci/BUILD.bazel b/scripts/src/javatests/org/oppia/android/scripts/ci/BUILD.bazel index 5db3b49d21c..6eb235b5fef 100644 --- a/scripts/src/javatests/org/oppia/android/scripts/ci/BUILD.bazel +++ b/scripts/src/javatests/org/oppia/android/scripts/ci/BUILD.bazel @@ -9,6 +9,7 @@ kt_jvm_test( srcs = ["ComputeAffectedTestsTest.kt"], deps = [ "//scripts/src/java/org/oppia/android/scripts/ci:compute_affected_tests_lib", + "//scripts/src/java/org/oppia/android/scripts/common:proto_string_encoder", "//scripts/src/java/org/oppia/android/scripts/testing:test_bazel_workspace", "//scripts/src/java/org/oppia/android/scripts/testing:test_git_repository", "//testing:assertion_helpers", @@ -16,3 +17,15 @@ kt_jvm_test( "//third_party:org_jetbrains_kotlin_kotlin-test-junit", ], ) + +kt_jvm_test( + name = "RetrieveAffectedTestsTest", + srcs = ["RetrieveAffectedTestsTest.kt"], + deps = [ + "//scripts/src/java/org/oppia/android/scripts/ci:retrieve_affected_tests_lib", + "//scripts/src/java/org/oppia/android/scripts/common:proto_string_encoder", + "//testing:assertion_helpers", + "//third_party:com_google_truth_truth", + "//third_party:org_jetbrains_kotlin_kotlin-test-junit", + ], +) diff --git a/scripts/src/javatests/org/oppia/android/scripts/ci/ComputeAffectedTestsTest.kt b/scripts/src/javatests/org/oppia/android/scripts/ci/ComputeAffectedTestsTest.kt index c70b453f3d0..654e42425c8 100644 --- a/scripts/src/javatests/org/oppia/android/scripts/ci/ComputeAffectedTestsTest.kt +++ b/scripts/src/javatests/org/oppia/android/scripts/ci/ComputeAffectedTestsTest.kt @@ -7,6 +7,8 @@ import org.junit.Rule import org.junit.Test import org.junit.rules.TemporaryFolder import org.oppia.android.scripts.common.CommandExecutorImpl +import org.oppia.android.scripts.common.ProtoStringEncoder.Companion.mergeFromCompressedBase64 +import org.oppia.android.scripts.proto.AffectedTestsBucket import org.oppia.android.scripts.testing.TestBazelWorkspace import org.oppia.android.scripts.testing.TestGitRepository import org.oppia.android.testing.assertThrows @@ -88,15 +90,47 @@ class ComputeAffectedTestsTest { assertThat(pendingOutputStream.toString()).contains("Usage:") } + @Test + fun testUtility_threeArguments_printsUsageStringAndExits() { + val exception = assertThrows(SecurityException::class) { + main(arrayOf("first", "second", "third")) + } + + // Bazel catches the System.exit() call and throws a SecurityException. This is a bit hacky way + // to verify that System.exit() is called, but it's helpful. + assertThat(exception).hasMessageThat().contains("System.exit()") + assertThat(pendingOutputStream.toString()).contains("Usage:") + } + @Test fun testUtility_directoryRootDoesNotExist_throwsException() { val exception = assertThrows(IllegalStateException::class) { - main(arrayOf("fake", "alsofake", "andstillfake")) + main(arrayOf("fake", "alsofake", "andstillfake", "compute_all_tests=false")) } assertThat(exception).hasMessageThat().contains("Expected 'fake' to be a directory") } + @Test + fun testUtility_invalid_lastArgument_throwsException() { + val exception = assertThrows(IllegalStateException::class) { + main(arrayOf("fake", "alsofake", "andstillfake", "compute_all_testss=false")) + } + + assertThat(exception).hasMessageThat() + .contains("Expected last argument to start with 'compute_all_tests='") + } + + @Test + fun testUtility_invalid_lastArgumentValue_throwsException() { + val exception = assertThrows(IllegalStateException::class) { + main(arrayOf("fake", "alsofake", "andstillfake", "compute_all_tests=blah")) + } + + assertThat(exception).hasMessageThat() + .contains("Expected last argument to have 'true' or 'false' passed to it, not: 'blah'") + } + @Test fun testUtility_emptyDirectory_throwsException() { val exception = assertThrows(IllegalStateException::class) { runScript() } @@ -120,18 +154,21 @@ class ComputeAffectedTestsTest { @Test fun testUtility_bazelWorkspace_developBranch_returnsAllTests() { initializeEmptyGitRepository() - createAndCommitBasicTests("FirstTest", "SecondTest", "ThirdTest") + createAndCommitBasicAppTests("FirstTest", "SecondTest", "ThirdTest") val reportedTargets = runScript() // Since the develop branch is checked out, all test targets should be returned. - assertThat(reportedTargets).containsExactly("//:FirstTest", "//:SecondTest", "//:ThirdTest") + assertThat(reportedTargets).hasSize(1) + assertThat(reportedTargets.first().affectedTestTargetsList).containsExactly( + "//app:FirstTest", "//app:SecondTest", "//app:ThirdTest" + ) } @Test fun testUtility_bazelWorkspace_featureBranch_noChanges_returnsNoTargets() { initializeEmptyGitRepository() - createAndCommitBasicTests("FirstTest", "SecondTest", "ThirdTest") + createAndCommitBasicAppTests("FirstTest", "SecondTest", "ThirdTest") switchToFeatureBranch() val reportedTargets = runScript() @@ -140,106 +177,133 @@ class ComputeAffectedTestsTest { assertThat(reportedTargets).isEmpty() } + @Test + fun testUtility_bazelWorkspace_featureBranch_noChanges_computeAllTargets_returnsAllTests() { + initializeEmptyGitRepository() + createAndCommitBasicAppTests("FirstTest", "SecondTest", "ThirdTest") + switchToFeatureBranch() + + val reportedTargets = runScript(computeAllTargets = true) + + // Even though there are no changes, all targets should be returned since that was requested via + // a command argument. + assertThat(reportedTargets).hasSize(1) + assertThat(reportedTargets.first().affectedTestTargetsList).containsExactly( + "//app:FirstTest", "//app:SecondTest", "//app:ThirdTest" + ) + } + @Test fun testUtility_bazelWorkspace_featureBranch_testChange_committed_returnsTestTarget() { initializeEmptyGitRepository() - createAndCommitBasicTests("FirstTest", "SecondTest", "ThirdTest") + createAndCommitBasicAppTests("FirstTest", "SecondTest", "ThirdTest") switchToFeatureBranch() changeAndCommitTestFile("FirstTest") val reportedTargets = runScript() // Only the first test should be reported since the test file itself was changed & committed. - assertThat(reportedTargets).containsExactly("//:FirstTest") + assertThat(reportedTargets).hasSize(1) + assertThat(reportedTargets.first().affectedTestTargetsList).containsExactly("//app:FirstTest") } @Test fun testUtility_bazelWorkspace_featureBranch_testChange_staged_returnsTestTarget() { initializeEmptyGitRepository() - createAndCommitBasicTests("FirstTest", "SecondTest", "ThirdTest") + createAndCommitBasicAppTests("FirstTest", "SecondTest", "ThirdTest") switchToFeatureBranch() changeAndStageTestFile("FirstTest") val reportedTargets = runScript() // Only the first test should be reported since the test file itself was changed & staged. - assertThat(reportedTargets).containsExactly("//:FirstTest") + assertThat(reportedTargets).hasSize(1) + assertThat(reportedTargets.first().affectedTestTargetsList).containsExactly("//app:FirstTest") } @Test fun testUtility_bazelWorkspace_featureBranch_testChange_unstaged_returnsTestTarget() { initializeEmptyGitRepository() - createAndCommitBasicTests("FirstTest", "SecondTest", "ThirdTest") + createAndCommitBasicAppTests("FirstTest", "SecondTest", "ThirdTest") switchToFeatureBranch() changeTestFile("FirstTest") val reportedTargets = runScript() // The first test should still be reported since it was changed (even though it wasn't staged). - assertThat(reportedTargets).containsExactly("//:FirstTest") + assertThat(reportedTargets).hasSize(1) + assertThat(reportedTargets.first().affectedTestTargetsList).containsExactly("//app:FirstTest") } @Test fun testUtility_bazelWorkspace_featureBranch_newTest_untracked_returnsNewTestTarget() { initializeEmptyGitRepository() - createAndCommitBasicTests("FirstTest", "SecondTest", "ThirdTest") + createAndCommitBasicAppTests("FirstTest", "SecondTest", "ThirdTest") switchToFeatureBranch() // A separate subpackage is needed to avoid unintentionally changing the BUILD file used by the // other already-committed tests. - createBasicTests("NewUntrackedTest", subpackage = "newtest") + createBasicTests("NewUntrackedTest", subpackage = "data") val reportedTargets = runScript() // The new test should still be reported since it was changed (even though it wasn't staged). - assertThat(reportedTargets).containsExactly("//newtest:NewUntrackedTest") + assertThat(reportedTargets).hasSize(1) + assertThat(reportedTargets.first().affectedTestTargetsList).containsExactly( + "//data:NewUntrackedTest" + ) } @Test fun testUtility_bazelWorkspace_featureBranch_dependencyChange_committed_returnsTestTarget() { initializeEmptyGitRepository() - createAndCommitBasicTests("FirstTest", "SecondTest", withGeneratedDependencies = true) + createAndCommitBasicAppTests("FirstTest", "SecondTest", withGeneratedDependencies = true) switchToFeatureBranch() changeAndCommitDependencyFileForTest("FirstTest") val reportedTargets = runScript() // The first test should be reported since its dependency was changed. - assertThat(reportedTargets).containsExactly("//:FirstTest") + assertThat(reportedTargets).hasSize(1) + assertThat(reportedTargets.first().affectedTestTargetsList).containsExactly("//app:FirstTest") } @Test fun testUtility_bazelWorkspace_featureBranch_commonDepChange_committed_returnsTestTargets() { initializeEmptyGitRepository() val targetName = createAndCommitLibrary("CommonDependency") - createAndCommitBasicTests("FirstTest", withGeneratedDependencies = true) - createAndCommitBasicTests("SecondTest", "ThirdTest", withExtraDependency = targetName) + createAndCommitBasicAppTests("FirstTest", withGeneratedDependencies = true) + createAndCommitBasicAppTests("SecondTest", "ThirdTest", withExtraDependency = targetName) switchToFeatureBranch() changeAndCommitLibrary("CommonDependency") val reportedTargets = runScript() // The two tests with a common dependency should be reported since that dependency was changed. - assertThat(reportedTargets).containsExactly("//:SecondTest", "//:ThirdTest") + assertThat(reportedTargets).hasSize(1) + assertThat(reportedTargets.first().affectedTestTargetsList) + .containsExactly("//app:SecondTest", "//app:ThirdTest") } @Test fun testUtility_bazelWorkspace_featureBranch_buildFileChange_committed_returnsRelatedTargets() { initializeEmptyGitRepository() - createAndCommitBasicTests("FirstTest", "SecondTest") + createAndCommitBasicAppTests("FirstTest", "SecondTest") switchToFeatureBranch() - createAndCommitBasicTests("ThirdTest") + createAndCommitBasicAppTests("ThirdTest") val reportedTargets = runScript() // Introducing a fourth test requires changing the common BUILD file which leads to the other // tests becoming affected. - assertThat(reportedTargets).containsExactly("//:FirstTest", "//:SecondTest", "//:ThirdTest") + assertThat(reportedTargets).hasSize(1) + assertThat(reportedTargets.first().affectedTestTargetsList) + .containsExactly("//app:FirstTest", "//app:SecondTest", "//app:ThirdTest") } @Test fun testUtility_bazelWorkspace_featureBranch_deletedTest_committed_returnsNoTargets() { initializeEmptyGitRepository() - createAndCommitBasicTests("FirstTest") + createAndCommitBasicAppTests("FirstTest") switchToFeatureBranch() removeAndCommitTestFileAndResetBuildFile("FirstTest") @@ -254,20 +318,22 @@ class ComputeAffectedTestsTest { @Test fun testUtility_bazelWorkspace_featureBranch_movedTest_staged_returnsNewTestTarget() { initializeEmptyGitRepository() - createAndCommitBasicTests("FirstTest") + createAndCommitBasicAppTests("FirstTest") switchToFeatureBranch() - moveTest(oldTestName = "FirstTest", newTestName = "RenamedTest", newSubpackage = "newpkg") + moveTest(oldTestName = "FirstTest", newTestName = "RenamedTest", newSubpackage = "domain") val reportedTargets = runScript() // The test should show up under its new name since moving it is the same as changing it. - assertThat(reportedTargets).containsExactly("//newpkg:RenamedTest") + assertThat(reportedTargets).hasSize(1) + assertThat(reportedTargets.first().affectedTestTargetsList) + .containsExactly("//domain:RenamedTest") } @Test fun testUtility_featureBranch_multipleTargetsChanged_committed_returnsAffectedTests() { initializeEmptyGitRepository() - createAndCommitBasicTests("FirstTest", "SecondTest", "ThirdTest") + createAndCommitBasicAppTests("FirstTest", "SecondTest", "ThirdTest") switchToFeatureBranch() changeAndCommitTestFile("FirstTest") changeAndCommitTestFile("ThirdTest") @@ -275,13 +341,15 @@ class ComputeAffectedTestsTest { val reportedTargets = runScript() // Changing multiple tests independently should be reflected in the script's results. - assertThat(reportedTargets).containsExactly("//:FirstTest", "//:ThirdTest") + assertThat(reportedTargets).hasSize(1) + assertThat(reportedTargets.first().affectedTestTargetsList) + .containsExactly("//app:FirstTest", "//app:ThirdTest") } @Test fun testUtility_featureBranch_instrumentationModuleChanged_instrumentationTargetsAreIgnored() { initializeEmptyGitRepository() - createAndCommitBasicTests("FirstTest", "SecondTest") + createAndCommitBasicAppTests("FirstTest", "SecondTest") switchToFeatureBranch() createBasicTests( "InstrumentationTest", @@ -291,17 +359,15 @@ class ComputeAffectedTestsTest { "RobolectricTest", subpackage = "instrumentation.src.javatests.org.oppia.android.instrumentation.app" ) - createBasicTests("ThirdTest") + createBasicTests("ThirdTest", subpackage = "instrumentation") + val reportedTargets = runScript() - assertThat( - reportedTargets - ).doesNotContain( + assertThat(reportedTargets).hasSize(1) + assertThat(reportedTargets.first().affectedTestTargetsList).doesNotContain( "//instrumentation/src/javatests/org/oppia/android/instrumentation/player:InstrumentationTest" ) - assertThat( - reportedTargets - ).contains( + assertThat(reportedTargets.first().affectedTestTargetsList).contains( "//instrumentation/src/javatests/org/oppia/android/instrumentation/app:RobolectricTest" ) } @@ -309,7 +375,6 @@ class ComputeAffectedTestsTest { @Test fun testUtility_developBranch_instrumentationModuleChanged_instrumentationTargetsAreIgnored() { initializeEmptyGitRepository() - createAndCommitBasicTests("FirstTest", "SecondTest", "ThirdTest") createBasicTests( "InstrumentationTest", subpackage = "instrumentation.src.javatests.org.oppia.android.instrumentation.player" @@ -318,29 +383,362 @@ class ComputeAffectedTestsTest { "RobolectricTest", subpackage = "instrumentation.src.javatests.org.oppia.android.instrumentation.app" ) + createBasicTests("ThirdTest", subpackage = "instrumentation") + val reportedTargets = runScript() - assertThat( - reportedTargets - ).doesNotContain( + assertThat(reportedTargets).hasSize(1) + assertThat(reportedTargets.first().affectedTestTargetsList).doesNotContain( "//instrumentation/src/javatests/org/oppia/android/instrumentation/player:InstrumentationTest" ) - assertThat( - reportedTargets - ).contains( + assertThat(reportedTargets.first().affectedTestTargetsList).contains( "//instrumentation/src/javatests/org/oppia/android/instrumentation/app:RobolectricTest" ) } + @Test + fun testUtility_appTest_usesAppCacheName() { + initializeEmptyGitRepository() + switchToFeatureBranch() + createBasicTests("ExampleTest", subpackage = "app") + + val reportedTargets = runScript() + + assertThat(reportedTargets).hasSize(1) + assertThat(reportedTargets.first().cacheBucketName).isEqualTo("app") + } + + @Test + fun testUtility_dataTest_usesGenericCacheName() { + initializeEmptyGitRepository() + switchToFeatureBranch() + createBasicTests("ExampleTest", subpackage = "data") + + val reportedTargets = runScript() + + assertThat(reportedTargets).hasSize(1) + assertThat(reportedTargets.first().cacheBucketName).isEqualTo("generic") + } + + @Test + fun testUtility_domainTest_usesDomainCacheName() { + initializeEmptyGitRepository() + switchToFeatureBranch() + createBasicTests("ExampleTest", subpackage = "domain") + + val reportedTargets = runScript() + + assertThat(reportedTargets).hasSize(1) + assertThat(reportedTargets.first().cacheBucketName).isEqualTo("domain") + } + + @Test + fun testUtility_instrumentationTest_usesGenericCacheName() { + initializeEmptyGitRepository() + switchToFeatureBranch() + createBasicTests("ExampleTest", subpackage = "instrumentation") + + val reportedTargets = runScript() + + assertThat(reportedTargets).hasSize(1) + assertThat(reportedTargets.first().cacheBucketName).isEqualTo("generic") + } + + @Test + fun testUtility_scriptsTest_usesScriptsCacheName() { + initializeEmptyGitRepository() + switchToFeatureBranch() + createBasicTests("ExampleTest", subpackage = "scripts") + + val reportedTargets = runScript() + + assertThat(reportedTargets).hasSize(1) + assertThat(reportedTargets.first().cacheBucketName).isEqualTo("scripts") + } + + @Test + fun testUtility_testingTest_usesGenericCacheName() { + initializeEmptyGitRepository() + switchToFeatureBranch() + createBasicTests("ExampleTest", subpackage = "testing") + + val reportedTargets = runScript() + + assertThat(reportedTargets).hasSize(1) + assertThat(reportedTargets.first().cacheBucketName).isEqualTo("generic") + } + + @Test + fun testUtility_utilityTest_usesGenericCacheName() { + initializeEmptyGitRepository() + switchToFeatureBranch() + createBasicTests("ExampleTest", subpackage = "utility") + + val reportedTargets = runScript() + + assertThat(reportedTargets).hasSize(1) + assertThat(reportedTargets.first().cacheBucketName).isEqualTo("generic") + } + + @Test + fun testUtility_testsForMultipleBuckets_correctlyGroupTogether() { + initializeEmptyGitRepository() + switchToFeatureBranch() + createBasicTests("AppTest", subpackage = "app") + createBasicTests("DataTest", subpackage = "data") + createBasicTests("DomainTest", subpackage = "domain") + createBasicTests("InstrumentationTest", subpackage = "instrumentation") + createBasicTests("ScriptsTest", subpackage = "scripts") + createBasicTests("TestingTest", subpackage = "testing") + createBasicTests("UtilityTest", subpackage = "utility") + + val reportedTargets = runScript() + + // Turn the targets into a map by cache name in order to guard against potential randomness from + // the script. + val targetMap = reportedTargets.associateBy { it.cacheBucketName } + assertThat(reportedTargets).hasSize(4) + assertThat(targetMap).hasSize(4) + assertThat(targetMap).containsKey("app") + assertThat(targetMap).containsKey("domain") + assertThat(targetMap).containsKey("generic") + assertThat(targetMap).containsKey("scripts") + // Verify that dedicated groups only have their relevant tests & the generic group includes all + // other tests. + assertThat(targetMap["app"]?.affectedTestTargetsList).containsExactly("//app:AppTest") + assertThat(targetMap["domain"]?.affectedTestTargetsList).containsExactly("//domain:DomainTest") + assertThat(targetMap["generic"]?.affectedTestTargetsList) + .containsExactly( + "//data:DataTest", "//instrumentation:InstrumentationTest", "//testing:TestingTest", + "//utility:UtilityTest" + ) + assertThat(targetMap["scripts"]?.affectedTestTargetsList) + .containsExactly("//scripts:ScriptsTest") + } + + @Test + fun testUtility_appTests_shardWithSmallPartitions() { + initializeEmptyGitRepository() + switchToFeatureBranch() + createBasicTests("AppTest1", "AppTest2", "AppTest3", subpackage = "app") + + val reportedTargets = runScriptWithShardLimits( + maxTestCountPerLargeShard = 3, maxTestCountPerMediumShard = 2, maxTestCountPerSmallShard = 1 + ) + + // App module tests partition eagerly, so there should be 3 groups. Also, the code below + // verifies duplicates by ensuring no shards are empty and there are no duplicate tests. Note + // that it's done in this way to be resilient against potential randomness from the script. + val allTests = reportedTargets.flatMap { it.affectedTestTargetsList } + assertThat(reportedTargets).hasSize(3) + assertThat(reportedTargets[0].affectedTestTargetsList).isNotEmpty() + assertThat(reportedTargets[1].affectedTestTargetsList).isNotEmpty() + assertThat(reportedTargets[2].affectedTestTargetsList).isNotEmpty() + assertThat(allTests).containsExactly("//app:AppTest1", "//app:AppTest2", "//app:AppTest3") + } + + @Test + fun testUtility_dataTests_shardWithLargePartitions() { + initializeEmptyGitRepository() + switchToFeatureBranch() + createBasicTests("DataTest1", "DataTest2", "DataTest3", subpackage = "data") + + val reportedTargets = runScriptWithShardLimits( + maxTestCountPerLargeShard = 3, maxTestCountPerMediumShard = 2, maxTestCountPerSmallShard = 1 + ) + + // Data tests are partitioned such that they are combined into one partition. + assertThat(reportedTargets).hasSize(1) + assertThat(reportedTargets.first().affectedTestTargetsList) + .containsExactly("//data:DataTest1", "//data:DataTest2", "//data:DataTest3") + } + + @Test + fun testUtility_domainTests_shardWithLargePartitions() { + initializeEmptyGitRepository() + switchToFeatureBranch() + createBasicTests("DomainTest1", "DomainTest2", "DomainTest3", subpackage = "domain") + + val reportedTargets = runScriptWithShardLimits( + maxTestCountPerLargeShard = 3, maxTestCountPerMediumShard = 2, maxTestCountPerSmallShard = 1 + ) + + // Domain tests are partitioned such that they are combined into one partition. + assertThat(reportedTargets).hasSize(1) + assertThat(reportedTargets.first().affectedTestTargetsList) + .containsExactly("//domain:DomainTest1", "//domain:DomainTest2", "//domain:DomainTest3") + } + + @Test + fun testUtility_instrumentationTests_shardWithLargePartitions() { + initializeEmptyGitRepository() + switchToFeatureBranch() + createBasicTests( + "InstrumentationTest1", "InstrumentationTest2", "InstrumentationTest3", + subpackage = "instrumentation" + ) + + val reportedTargets = runScriptWithShardLimits( + maxTestCountPerLargeShard = 3, maxTestCountPerMediumShard = 2, maxTestCountPerSmallShard = 1 + ) + + // Instrumentation tests are partitioned such that they are combined into one partition. + assertThat(reportedTargets).hasSize(1) + assertThat(reportedTargets.first().affectedTestTargetsList) + .containsExactly( + "//instrumentation:InstrumentationTest1", "//instrumentation:InstrumentationTest2", + "//instrumentation:InstrumentationTest3" + ) + } + + @Test + fun testUtility_scriptsTests_shardWithMediumPartitions() { + initializeEmptyGitRepository() + switchToFeatureBranch() + createBasicTests("ScriptsTest1", "ScriptsTest2", "ScriptsTest3", subpackage = "scripts") + + val reportedTargets = runScriptWithShardLimits( + maxTestCountPerLargeShard = 3, maxTestCountPerMediumShard = 2, maxTestCountPerSmallShard = 1 + ) + + // See app module test above for specifics. Scripts tests are medium partitioned which means 3 + // tests will be split into two partitions. + val allTests = reportedTargets.flatMap { it.affectedTestTargetsList } + assertThat(reportedTargets).hasSize(2) + assertThat(reportedTargets[0].affectedTestTargetsList).isNotEmpty() + assertThat(reportedTargets[1].affectedTestTargetsList).isNotEmpty() + assertThat(allTests) + .containsExactly("//scripts:ScriptsTest1", "//scripts:ScriptsTest2", "//scripts:ScriptsTest3") + } + + @Test + fun testUtility_testingTests_shardWithLargePartitions() { + initializeEmptyGitRepository() + switchToFeatureBranch() + createBasicTests("TestingTest1", "TestingTest2", "TestingTest3", subpackage = "testing") + + val reportedTargets = runScriptWithShardLimits( + maxTestCountPerLargeShard = 3, maxTestCountPerMediumShard = 2, maxTestCountPerSmallShard = 1 + ) + + // Testing tests are partitioned such that they are combined into one partition. + assertThat(reportedTargets).hasSize(1) + assertThat(reportedTargets.first().affectedTestTargetsList) + .containsExactly("//testing:TestingTest1", "//testing:TestingTest2", "//testing:TestingTest3") + } + + @Test + fun testUtility_utilityTests_shardWithLargePartitions() { + initializeEmptyGitRepository() + switchToFeatureBranch() + createBasicTests("UtilityTest1", "UtilityTest2", "UtilityTest3", subpackage = "utility") + + val reportedTargets = runScriptWithShardLimits( + maxTestCountPerLargeShard = 3, maxTestCountPerMediumShard = 2, maxTestCountPerSmallShard = 1 + ) + + // Utility tests are partitioned such that they are combined into one partition. + assertThat(reportedTargets).hasSize(1) + assertThat(reportedTargets.first().affectedTestTargetsList) + .containsExactly("//utility:UtilityTest1", "//utility:UtilityTest2", "//utility:UtilityTest3") + } + + @Test + fun testUtility_singleShard_testOutputIncludesHumanReadablePrefix() { + initializeEmptyGitRepository() + switchToFeatureBranch() + createBasicTests("ExampleTest", subpackage = "app") + + val generatedLines = runScriptWithTextOutput() + + assertThat(generatedLines).hasSize(1) + assertThat(generatedLines.first()).startsWith("app-shard0") + } + + @Test + fun testUtility_multipleShards_testOutputIncludesHumanReadablePrefixForEachShard() { + initializeEmptyGitRepository() + switchToFeatureBranch() + createBasicTests("AppTest", subpackage = "app") + createBasicTests("ScriptsTest", subpackage = "scripts") + + // The sorting here counteracts the intentional randomness from the script. + val generatedLines = runScriptWithTextOutput().sorted() + + assertThat(generatedLines).hasSize(2) + assertThat(generatedLines[0]).matches("^app-shard[0-3];.+?$") + assertThat(generatedLines[1]).matches("^scripts-shard[0-3];.+?\$") + } + + @Test + fun testUtility_twoShards_computesTestsForBothShards() { + initializeEmptyGitRepository() + switchToFeatureBranch() + createBasicTests("AppTest1", "AppTest2", "AppTest3", subpackage = "app") + createBasicTests("ScriptsTest1", "ScriptsTest2", subpackage = "scripts") + + val reportedTargets = runScript() + + // Turn the targets into a map by cache name in order to guard against potential randomness from + // the script. + val targetMap = reportedTargets.associateBy { it.cacheBucketName } + assertThat(reportedTargets).hasSize(2) + assertThat(targetMap).hasSize(2) + assertThat(targetMap).containsKey("app") + assertThat(targetMap).containsKey("scripts") + assertThat(targetMap["app"]?.affectedTestTargetsList) + .containsExactly("//app:AppTest1", "//app:AppTest2", "//app:AppTest3") + assertThat(targetMap["scripts"]?.affectedTestTargetsList) + .containsExactly("//scripts:ScriptsTest1", "//scripts:ScriptsTest2") + } + + private fun runScriptWithTextOutput(computeAllTargets: Boolean = false): List { + val outputLog = tempFolder.newFile("output.log") + main( + arrayOf( + tempFolder.root.absolutePath, outputLog.absolutePath, "develop", + "compute_all_tests=$computeAllTargets" + ) + ) + return outputLog.readLines() + } + /** * Runs the compute_affected_tests utility & returns all of the output lines. Note that the output * here is that which is saved directly to the output file, not debug lines printed to the * console. */ - private fun runScript(): List { + private fun runScript(computeAllTargets: Boolean = false): List { + return parseOutputLogLines(runScriptWithTextOutput(computeAllTargets = computeAllTargets)) + } + + private fun runScriptWithShardLimits( + maxTestCountPerLargeShard: Int, + maxTestCountPerMediumShard: Int, + maxTestCountPerSmallShard: Int + ): List { val outputLog = tempFolder.newFile("output.log") - main(arrayOf(tempFolder.root.absolutePath, outputLog.absolutePath, "develop")) - return outputLog.readLines() + + // Note that main() can't be used since the shard counts need to be overwritten. Dagger would + // be a nicer means to do this, but it's not set up currently for scripts. + ComputeAffectedTests( + maxTestCountPerLargeShard = maxTestCountPerLargeShard, + maxTestCountPerMediumShard = maxTestCountPerMediumShard, + maxTestCountPerSmallShard = maxTestCountPerSmallShard + ).compute( + pathToRoot = tempFolder.root.absolutePath, + pathToOutputFile = outputLog.absolutePath, + baseDevelopBranchReference = "develop", + computeAllTestsSetting = false + ) + + return parseOutputLogLines(outputLog.readLines()) + } + + private fun parseOutputLogLines(outputLogLines: List): List { + return outputLogLines.map { + AffectedTestsBucket.getDefaultInstance().mergeFromCompressedBase64(it.split(";")[1]) + } } private fun createEmptyWorkspace() { @@ -363,17 +761,17 @@ class ComputeAffectedTestsTest { /** * Creates a new test for each specified test name. * + * @param subpackage the subpackage under which the tests should be created * @param withGeneratedDependencies whether each test should have a corresponding test dependency * generated * @param withExtraDependency if present, an extra library dependency that should be added to each * test - * @param subpackage if provided, the subpackage under which the tests should be created */ private fun createBasicTests( vararg testNames: String, + subpackage: String?, withGeneratedDependencies: Boolean = false, - withExtraDependency: String? = null, - subpackage: String? = null + withExtraDependency: String? = null ): List { return testNames.flatMap { testName -> testBazelWorkspace.createTest( @@ -385,7 +783,7 @@ class ComputeAffectedTestsTest { } } - private fun createAndCommitBasicTests( + private fun createAndCommitBasicAppTests( vararg testNames: String, withGeneratedDependencies: Boolean = false, withExtraDependency: String? = null @@ -393,7 +791,8 @@ class ComputeAffectedTestsTest { val changedFiles = createBasicTests( *testNames, withGeneratedDependencies = withGeneratedDependencies, - withExtraDependency = withExtraDependency + withExtraDependency = withExtraDependency, + subpackage = "app" ) testGitRepository.stageFilesForCommit(changedFiles.toSet()) testGitRepository.commit(message = "Introduce basic tests.") @@ -458,7 +857,7 @@ class ComputeAffectedTestsTest { } private fun changeAndCommitLibrary(name: String) { - val libFile = testBazelWorkspace.retrieveLibraryFile(name) + val libFile = testBazelWorkspace.retrieveLibraryFile("//:$name") libFile.appendText(";") // Add a character to change the file. testGitRepository.stageFileForCommit(libFile) testGitRepository.commit(message = "Modified library $name") diff --git a/scripts/src/javatests/org/oppia/android/scripts/ci/RetrieveAffectedTestsTest.kt b/scripts/src/javatests/org/oppia/android/scripts/ci/RetrieveAffectedTestsTest.kt new file mode 100644 index 00000000000..d43a7055f51 --- /dev/null +++ b/scripts/src/javatests/org/oppia/android/scripts/ci/RetrieveAffectedTestsTest.kt @@ -0,0 +1,147 @@ +package org.oppia.android.scripts.ci + +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.scripts.common.ProtoStringEncoder.Companion.toCompressedBase64 +import org.oppia.android.scripts.proto.AffectedTestsBucket +import org.oppia.android.testing.assertThrows +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.OutputStream +import java.io.PrintStream + +/** Tests for the retrieve_affected_tests utility. */ +// FunctionName: test names are conventionally named with underscores. +@Suppress("FunctionName") +class RetrieveAffectedTestsTest { + @Rule + @JvmField + var tempFolder = TemporaryFolder() + + private lateinit var pendingOutputStream: ByteArrayOutputStream + private lateinit var originalStandardOutputStream: OutputStream + + @Before + fun setUp() { + // Redirect script output for testing purposes. + pendingOutputStream = ByteArrayOutputStream() + originalStandardOutputStream = System.out + System.setOut(PrintStream(pendingOutputStream)) + } + + @After + fun tearDown() { + // Reinstate test output redirection. + System.setOut(PrintStream(pendingOutputStream)) + + // Print the status of the git repository to help with debugging in the cases of test failures + // and to help manually verify the expect git state at the end of each test. + println("git status (at end of test):") + } + + @Test + fun testUtility_noArguments_printsUsageStringAndExits() { + val exception = assertThrows(SecurityException::class) { runScript() } + + // Bazel catches the System.exit() call and throws a SecurityException. This is a bit hacky way + // to verify that System.exit() is called, but it's helpful. + assertThat(exception).hasMessageThat().contains("System.exit()") + assertThat(pendingOutputStream.toString()).contains("Usage:") + } + + @Test + fun testUtility_oneArgument_printsUsageStringAndExits() { + val exception = assertThrows(SecurityException::class) { runScript("arg1") } + + // Bazel catches the System.exit() call and throws a SecurityException. This is a bit hacky way + // to verify that System.exit() is called, but it's helpful. + assertThat(exception).hasMessageThat().contains("System.exit()") + assertThat(pendingOutputStream.toString()).contains("Usage:") + } + + @Test + fun testUtility_twoArguments_printsUsageStringAndExits() { + val exception = assertThrows(SecurityException::class) { runScript("arg1", "arg2") } + + // Bazel catches the System.exit() call and throws a SecurityException. This is a bit hacky way + // to verify that System.exit() is called, but it's helpful. + assertThat(exception).hasMessageThat().contains("System.exit()") + assertThat(pendingOutputStream.toString()).contains("Usage:") + } + + @Test + fun testUtility_invalidBase64_throwsException() { + assertThrows(IllegalArgumentException::class) { runScript("badbase64", "file1", "file2") } + } + + @Test + fun testUtility_validBase64_oneTest_writesCacheNameFile() { + val cacheNameFilePath = tempFolder.getNewTempFilePath("cache_name") + val testTargetFilePath = tempFolder.getNewTempFilePath("test_target_list") + val base64String = computeBase64String( + AffectedTestsBucket.newBuilder().apply { + cacheBucketName = "example" + addAffectedTestTargets("//example/to/a/test:DemonstrationTest") + }.build() + ) + + runScript(base64String, cacheNameFilePath, testTargetFilePath) + + assertThat(File(cacheNameFilePath).readText().trim()).isEqualTo("example") + } + + @Test + fun testUtility_validBase64_oneTest_writesTestTargetFileWithCorrectTarget() { + val cacheNameFilePath = tempFolder.getNewTempFilePath("cache_name") + val testTargetFilePath = tempFolder.getNewTempFilePath("test_target_list") + val base64String = computeBase64String( + AffectedTestsBucket.newBuilder().apply { + cacheBucketName = "example" + addAffectedTestTargets("//example/to/a/test:DemonstrationTest") + }.build() + ) + + runScript(base64String, cacheNameFilePath, testTargetFilePath) + + assertThat(File(testTargetFilePath).readText().trim()).isEqualTo( + "//example/to/a/test:DemonstrationTest" + ) + } + + @Test + fun testUtility_validBase64_multipleTests_writesTestTargetFileWithCorrectTargets() { + val cacheNameFilePath = tempFolder.getNewTempFilePath("cache_name") + val testTargetFilePath = tempFolder.getNewTempFilePath("test_target_list") + val base64String = computeBase64String( + AffectedTestsBucket.newBuilder().apply { + cacheBucketName = "example" + addAffectedTestTargets("//example/to/a/test:FirstDemonstrationTest") + addAffectedTestTargets("//example/to/b/test:SecondDemonstrationTest") + }.build() + ) + + runScript(base64String, cacheNameFilePath, testTargetFilePath) + + assertThat(File(testTargetFilePath).readText().trim()).isEqualTo( + "//example/to/a/test:FirstDemonstrationTest //example/to/b/test:SecondDemonstrationTest" + ) + } + + private fun runScript(vararg args: String) { + main(args.toList().toTypedArray()) + } + + private fun computeBase64String(affectedTestsBucket: AffectedTestsBucket): String = + affectedTestsBucket.toCompressedBase64() + + /** + * Returns the absolute file path of a new file that can be written under this [TemporaryFolder] + * (but does not create the file). + */ + private fun TemporaryFolder.getNewTempFilePath(name: String) = + File(tempFolder.root, name).absolutePath +} From d1da067d0faa26a03c6f52ed1e95d53283329cce Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Thu, 2 Sep 2021 01:34:22 -0700 Subject: [PATCH 12/93] Fix script. A newly computed variable wasn't updated to be used in an earlier change. --- .github/workflows/unit_tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index bbd04d6f9f6..2490056e076 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -143,7 +143,7 @@ jobs: # See https://stackoverflow.com/a/29903172 for cut logic. This is needed to remove the # user-friendly shard prefix from the matrix value. AFFECTED_TESTS_BUCKET_BASE64=$(echo "$AFFECTED_TESTS_BUCKET_BASE64_ENCODED_SHARD" | cut -d ";" -f 2) - bazel run //scripts:retrieve_affected_tests -- $AFFECTED_TESTS_BUCKET_BASE64_ENCODED_SHARD $(pwd)/test_bucket_name $(pwd)/bazel_test_targets + bazel run //scripts:retrieve_affected_tests -- $AFFECTED_TESTS_BUCKET_BASE64 $(pwd)/test_bucket_name $(pwd)/bazel_test_targets TEST_CATEGORY=$(cat ./test_bucket_name) BAZEL_TEST_TARGETS=$(cat ./bazel_test_targets) echo "Test category: $TEST_CATEGORY" From fbb3838e32e1a64a7b7be3ec5e95e5b3952ec840 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Thu, 2 Sep 2021 02:22:16 -0700 Subject: [PATCH 13/93] Fix broken tests & test configuration. Add docstrings for proto. --- .../oppia/android/scripts/proto/affected_tests.proto | 5 +++++ .../android/scripts/testing/TestBazelWorkspace.kt | 2 +- .../org/oppia/android/scripts/ci/BUILD.bazel | 4 +++- .../org/oppia/android/scripts/license/BUILD.bazel | 2 +- .../scripts/testing/TestBazelWorkspaceTest.kt | 12 ++++++------ 5 files changed, 16 insertions(+), 9 deletions(-) diff --git a/scripts/src/java/org/oppia/android/scripts/proto/affected_tests.proto b/scripts/src/java/org/oppia/android/scripts/proto/affected_tests.proto index e9ce67dda84..9d044ddf090 100644 --- a/scripts/src/java/org/oppia/android/scripts/proto/affected_tests.proto +++ b/scripts/src/java/org/oppia/android/scripts/proto/affected_tests.proto @@ -5,7 +5,12 @@ package proto; option java_package = "org.oppia.android.scripts.proto"; option java_multiple_files = true; +// Represents a bucket of tests that were affected by a change. message AffectedTestsBucket { + // The name of the GitHub Actions cache that should be downloaded prior to running any targets in + // this bucket. string cache_bucket_name = 1; + + // The list of fully-qualified Bazel test targets that belong to this bucket and should be run. repeated string affected_test_targets = 2; } diff --git a/scripts/src/java/org/oppia/android/scripts/testing/TestBazelWorkspace.kt b/scripts/src/java/org/oppia/android/scripts/testing/TestBazelWorkspace.kt index 20c9e18119d..68487b6062b 100644 --- a/scripts/src/java/org/oppia/android/scripts/testing/TestBazelWorkspace.kt +++ b/scripts/src/java/org/oppia/android/scripts/testing/TestBazelWorkspace.kt @@ -138,7 +138,7 @@ class TestBazelWorkspace(private val temporaryRootFolder: TemporaryFolder) { */ fun createLibrary(dependencyName: String): Pair> { val libTargetName = "${dependencyName}_lib" - check(libTargetName !in libraryFileMap) { "Library '$dependencyName' already exists" } + check("//:$libTargetName" !in libraryFileMap) { "Library '$dependencyName' already exists" } val prereqFiles = ensureWorkspaceIsConfiguredForKotlin() prepareBuildFileForLibraries(rootBuildFile) diff --git a/scripts/src/javatests/org/oppia/android/scripts/ci/BUILD.bazel b/scripts/src/javatests/org/oppia/android/scripts/ci/BUILD.bazel index 6eb235b5fef..ed75e26c324 100644 --- a/scripts/src/javatests/org/oppia/android/scripts/ci/BUILD.bazel +++ b/scripts/src/javatests/org/oppia/android/scripts/ci/BUILD.bazel @@ -6,7 +6,9 @@ load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_jvm_test") kt_jvm_test( name = "ComputeAffectedTestsTest", + size = "large", srcs = ["ComputeAffectedTestsTest.kt"], + shard_count = 4, deps = [ "//scripts/src/java/org/oppia/android/scripts/ci:compute_affected_tests_lib", "//scripts/src/java/org/oppia/android/scripts/common:proto_string_encoder", @@ -24,7 +26,7 @@ kt_jvm_test( deps = [ "//scripts/src/java/org/oppia/android/scripts/ci:retrieve_affected_tests_lib", "//scripts/src/java/org/oppia/android/scripts/common:proto_string_encoder", - "//testing:assertion_helpers", + "//testMavenDependenciesListCheckTesting:assertion_helpers", "//third_party:com_google_truth_truth", "//third_party:org_jetbrains_kotlin_kotlin-test-junit", ], diff --git a/scripts/src/javatests/org/oppia/android/scripts/license/BUILD.bazel b/scripts/src/javatests/org/oppia/android/scripts/license/BUILD.bazel index 707ec3e8081..5a55962d60d 100644 --- a/scripts/src/javatests/org/oppia/android/scripts/license/BUILD.bazel +++ b/scripts/src/javatests/org/oppia/android/scripts/license/BUILD.bazel @@ -16,7 +16,7 @@ kt_jvm_test( ) kt_jvm_test( - name = "MavenDependenciesListCheckTest", + name = "MavenDependenciesListCheckTMavenDependenciesListCheckTestest", size = "large", srcs = ["MavenDependenciesListCheckTest.kt"], deps = [ diff --git a/scripts/src/javatests/org/oppia/android/scripts/testing/TestBazelWorkspaceTest.kt b/scripts/src/javatests/org/oppia/android/scripts/testing/TestBazelWorkspaceTest.kt index 3700f414110..9bec557f4ac 100644 --- a/scripts/src/javatests/org/oppia/android/scripts/testing/TestBazelWorkspaceTest.kt +++ b/scripts/src/javatests/org/oppia/android/scripts/testing/TestBazelWorkspaceTest.kt @@ -354,7 +354,7 @@ class TestBazelWorkspaceTest { val buildContent = testBazelWorkspace.rootBuildFile.readAsJoinedString() assertThat(buildContent.countMatches("kt_jvm_test\\(")).isEqualTo(1) assertThat(buildContent).contains("srcs = [\"FirstTest.kt\"]") - assertThat(buildContent).contains("deps = [\":FirstTestDependency_lib\",]") + assertThat(buildContent).contains("deps = [\"//:FirstTestDependency_lib\",]") // And the generated library. assertThat(buildContent.countMatches("kt_jvm_library\\(")).isEqualTo(1) assertThat(buildContent).contains("srcs = [\"FirstTestDependency.kt\"]") @@ -483,7 +483,7 @@ class TestBazelWorkspaceTest { // Both dependencies should be included in the test's deps. val buildContent = testBazelWorkspace.rootBuildFile.readAsJoinedString() assertThat(buildContent.countMatches("kt_jvm_test\\(")).isEqualTo(1) - assertThat(buildContent).contains("deps = [\":FirstTestDependency_lib\",\"//:ExtraDep\",]") + assertThat(buildContent).contains("deps = [\"//:FirstTestDependency_lib\",\"//:ExtraDep\",]") } @Test @@ -602,7 +602,7 @@ class TestBazelWorkspaceTest { val buildContent = testBazelWorkspace.rootBuildFile.readAsJoinedString() assertThat(buildContent.countMatches("kt_jvm_test\\(")).isEqualTo(1) assertThat(buildContent).contains("srcs = [\"FirstTest.kt\"]") - assertThat(buildContent).contains("deps = [\":FirstTestDependency_lib\",]") + assertThat(buildContent).contains("deps = [\"//:FirstTestDependency_lib\",]") // And the generated library. assertThat(buildContent.countMatches("kt_jvm_library\\(")).isEqualTo(1) assertThat(buildContent).contains("srcs = [\"FirstTestDependency.kt\"]") @@ -709,7 +709,7 @@ class TestBazelWorkspaceTest { // Both dependencies should be included in the test's deps. val buildContent = testBazelWorkspace.rootBuildFile.readAsJoinedString() assertThat(buildContent.countMatches("kt_jvm_test\\(")).isEqualTo(1) - assertThat(buildContent).contains("deps = [\":FirstTestDependency_lib\",\"//:ExtraDep\",]") + assertThat(buildContent).contains("deps = [\"//:FirstTestDependency_lib\",\"//:ExtraDep\",]") } @Test @@ -745,7 +745,7 @@ class TestBazelWorkspaceTest { val (targetName, files) = testBazelWorkspace.createLibrary(dependencyName = "ExampleDep") - assertThat(targetName).isEqualTo("ExampleDep_lib") + assertThat(targetName).isEqualTo("//:ExampleDep_lib") assertThat(files.getFileNames()).containsExactly("WORKSPACE", "BUILD.bazel", "ExampleDep.kt") } @@ -854,7 +854,7 @@ class TestBazelWorkspaceTest { val testBazelWorkspace = TestBazelWorkspace(tempFolder) testBazelWorkspace.createLibrary(dependencyName = "ExampleLib") - val libFile = testBazelWorkspace.retrieveLibraryFile(dependencyName = "ExampleLib") + val libFile = testBazelWorkspace.retrieveLibraryFile(dependencyName = "//:ExampleLib") assertThat(libFile.exists()).isTrue() assertThat(libFile.isRelativeTo(tempFolder.root)).isTrue() From 6c91da96555a537a935105f8d14098ff642dcf93 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Thu, 2 Sep 2021 02:46:52 -0700 Subject: [PATCH 14/93] Fix mistake from earlier commit. --- scripts/src/javatests/org/oppia/android/scripts/ci/BUILD.bazel | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/src/javatests/org/oppia/android/scripts/ci/BUILD.bazel b/scripts/src/javatests/org/oppia/android/scripts/ci/BUILD.bazel index ed75e26c324..cb9eafb6d21 100644 --- a/scripts/src/javatests/org/oppia/android/scripts/ci/BUILD.bazel +++ b/scripts/src/javatests/org/oppia/android/scripts/ci/BUILD.bazel @@ -26,7 +26,7 @@ kt_jvm_test( deps = [ "//scripts/src/java/org/oppia/android/scripts/ci:retrieve_affected_tests_lib", "//scripts/src/java/org/oppia/android/scripts/common:proto_string_encoder", - "//testMavenDependenciesListCheckTesting:assertion_helpers", + "//testing:assertion_helpers", "//third_party:com_google_truth_truth", "//third_party:org_jetbrains_kotlin_kotlin-test-junit", ], From e3eb6f20563c9d8d9a3b995e48f404c14729a20d Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Thu, 2 Sep 2021 02:47:05 -0700 Subject: [PATCH 15/93] Try 10 max parallel actions instead. See https://github.com/oppia/oppia-android/pull/3757#issuecomment-911460981 for context. --- .github/workflows/unit_tests.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 2490056e076..93171606998 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -97,7 +97,7 @@ jobs: runs-on: ubuntu-18.04 strategy: fail-fast: false - max-parallel: 5 + max-parallel: 10 matrix: ${{ fromJson(needs.bazel_compute_affected_targets.outputs.matrix) }} env: ENABLE_CACHING: false From 62576a1fceb5509d0dcc6c32c3a8caddf5e12121 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Thu, 2 Sep 2021 03:18:56 -0700 Subject: [PATCH 16/93] Fix another error from an earlier commit. --- .../src/javatests/org/oppia/android/scripts/license/BUILD.bazel | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/src/javatests/org/oppia/android/scripts/license/BUILD.bazel b/scripts/src/javatests/org/oppia/android/scripts/license/BUILD.bazel index 5a55962d60d..707ec3e8081 100644 --- a/scripts/src/javatests/org/oppia/android/scripts/license/BUILD.bazel +++ b/scripts/src/javatests/org/oppia/android/scripts/license/BUILD.bazel @@ -16,7 +16,7 @@ kt_jvm_test( ) kt_jvm_test( - name = "MavenDependenciesListCheckTMavenDependenciesListCheckTestest", + name = "MavenDependenciesListCheckTest", size = "large", srcs = ["MavenDependenciesListCheckTest.kt"], deps = [ From 0644f70b139d9697c247b3a6d7134305393da8cb Mon Sep 17 00:00:00 2001 From: "translatewiki.net" Date: Thu, 2 Sep 2021 13:09:33 +0200 Subject: [PATCH 17/93] Localisation updates from https://translatewiki.net. --- app/src/main/res/values-ar/strings.xml | 382 +++++++++++++++++++ app/src/main/res/values-pt-rBR/strings.xml | 410 +++++++++++++++++++++ 2 files changed, 792 insertions(+) create mode 100644 app/src/main/res/values-ar/strings.xml create mode 100644 app/src/main/res/values-pt-rBR/strings.xml diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml new file mode 100644 index 00000000000..d95a0f8bfb3 --- /dev/null +++ b/app/src/main/res/values-ar/strings.xml @@ -0,0 +1,382 @@ + + + + أعلى قائمة التنقل + الصفحة الرئيسية + خيارات + التنزيلات + مساعدة + مشغل رحلة الاستكشاف + المساعدة + تغيير الملف الشخصي + خيارات المطورين + أدوات تحكم المشرف + فتح قائمة التنقل + غلق قائمة التنقل + تشغيل الصوت + إيقاف الصوت مؤقتًا + موافق + إلغاء + لغة الصوت + غير متصل بالإنترنت + من فضلك تأكد من وجود اتصال بالإنترنت عن طريق شبكة الوايفاي أو بيانات الهاتف، ثم حاول مرة أخرى. + حسنًا + موافق + إلغاء + متصل بالإنترنت عن طريق بيانات الهاتف + تشغيل الصوت عبر الإنترنت قد يستخدم الكثير من بيانات الهاتف. + لا تُظهِر هذه الرسالة مُجددًا + بطاقة المفهوم + الضغط هنا سوف يغلق بطاقة المفهوم + بطاقة المراجعة + هل تريد المغادرة إلى صفحة الموضوع؟ + لن يتم حفظ تقدمك. + مغادرة + إلغاء + هل تريد المغادرة إلى صفحة الموضوع؟ + لن يتم حفظ تقدمك. + إلغاء + مغادرة + تم الوصول إلى الحد الأقصى من سعة التخزين + سوف يتم حذف تقدمك في درس %s + استمرار + المغادرة بدون حفظ التقدم + العودة إلى الدرس + أتقن هذه المهارات + النسب والاستدلال النسبي + قم باختيار المهارات التي تريد أن تتدرب عليها. + ابدأ + استمرار + تقديم + التوجه إلى البطاقة السابقة + التوجه إلى البطاقة القادمة + تقديم + إعادة التشغيل + العودة إلى الموضوع + الردود السابقة (%d) + يضغط على %s + تعلم مرة أخرى + اعرض المزيد + اعرض أقل + الأسئلة المتكررة + الأسئلة المميزة + الأسئلة المتكررة + الأسئلة المتكررة + التحقق من رقم التعريف الشخصي + مقدمة عن أوبيا + الأسئلة الأكثر تكرارا + معلومات + الدروس + ممارسة + مراجعة + أدوات تحكم المشرف + صفحة الموضوع + الموضوع: %s + موضوع + المواضيع قيد التقدم + فصل %s: %s + تم انهاء الفصل %s الذي يحمل عنوان %s + الفصل %s الذي يحمل عنوان %s ما زال قيد التقدم + قم بإنهاء الفصل %s: %s لكي تفتح هذا الفصل. + أدخل النص. + أدخل كسر عشري في الصيغة (بسط/مقام)، أو عدد كسري في الصيغة (عدد بسط/مقام). + أدخل كسر عشري في الصيغة بسط/مقام. + أدخل رقم. + أدخل أرقام بالوحدات هنا. + تفعيل التعليق الصوتي لهذا الدرس. + القصص التي تم تشغيلها مؤخرًا + آخر القصص التي تم تشغيلها + عرض الكل + تم تشغيلها خلال الأسبوع الماضي + تم تشغيلها خلال الشهر الماضي + قائمة الفصول + صورة %s + كل المواضيع + القصص التي باستطاعتك تشغيلها + العودة للسابق + القصص التي تم تشغيلها مؤخرًا + المواضيع التي تم تنزيلها + تم التنزيل + وضع الممارسة + السؤال %d من أصل %d + تمّ + تم الإنتهاء + تم الإنتهاء من جميع الأسئلة! يمكنك بدأ مجموعة أسئلة أخرى، أو العودة للموضوع. + في تقدم + مكتمل + عرض قائمة الفصول + إخفاء قائمة الفصول + تشغيل/إيقاف مؤقت للصوت + التفضيلات + صفحة تقدم الملف الشخصي + بحث + من فضلك قم باستخدام أرقام، أو مسافات، أو الرمز (/) + من فضلك أدخل كسر عشري صالح (5/3 أو 1 2/3 على سبيل المثال) + من فضلك لا تضع 0 في المقام + لا يجب أن يحتوي أي رقم في الكسر العشري على أكثر من 7 أرقام. + من فضلك إبدأ إجابتك برقم (0 او 0.5 على سبيل المثال) + من فضلك قم بإدخال رقم صالح. + يمكن أن تحتوي الإجابة على 15 رقمًا (0-9) على الأكثر أو الرموز (. أو -) + من فضلك قم بكتابة نسبة تتكون من أرقام مفصولة بنقطتين رأسيتين (1:2 أو 1:2:3 على سبيل المثال). + من فضلك أدخل نسبة صحيحة (1:2 أو 1:2:3 على سبيل المثال) + إجابتك تحتوي على نقطتين رأسيتين (:) متتاليتين. + عدد الحدود لا يساوي العدد المطلوب. + لا يمكن أن تحتوي النسب على 0 كعنصر. + الحجم مجهول + %d بايت + %d ك.ب + %d م.ب + %d ج.ب + صحيح! + الموضوع: %s + %1$s في %2$s + {{PLURAL|one=\nفصلًا واحدًا\n|\n%d من الفصول + {{PLURAL|one=\nقصة واحدة\n|\n%d من القصص + {{PLURAL|one=\n%d من %d فصل مكتمل\n|\n%d من %d من الفصول مكتملة + {{PLURAL|one=\nدرسًا واحدًا\n|\n%d من الدروس + {{PLURAL|one=\nقصة واحدة مكتملة\n|zero=\n%d قصة مكتملة\n|\n%d من القصص مكتملة + {{PLURAL|one=\nموضوع واحد في تقدم\n|zero=\n%d موضوع في تقدم\n|\n%d من المواضيع في تقدم + صفحة اختيار الملف الشخصي + المشرف + اختر الملف الشخصي + إضافة ملف شخصي + إعداد العديد من الملفات الشخصية + أضف حتى 10 مستخدمين إلى حسابك. مثالي للعائلات والفصول الدراسية. + أدوات تحكم المشرف + اللغة + أدوات تحكم المشرف + أمّن الحساب لإضافة ملفات شخصية + أمّن الحساب للوصول إلى أدوات تحكم المشرف + إذن المشرف مطلوب + أدخل رقم التعريف الشخصي الخاص بالمشرف لكي تُنشئ حسابًا جديدًا. + أدخل رقم التعريف الشخصي الخاص بالمشرف لكي تصل إلى أدوات تحكم المشرف. + رقم التعريف الشخصي الخاص بالمشرف + رقم التعريف الشخصي الخاص بالمشرف غير صحيح. من فضلك حاول مجددًا. + من فضلك أدخل رقم التعريف الشخصي الخاص بالمشرف. + تقديم + إغلاق + قبل أن نضيف ملفات شخصية، نريد أن نحمي حسابك الشخصي عن طريق إنشاء رقم تعريف شخصي. هذا يمكنك من السماح بالتنزيلات وإدارة الملفات الشخصية على الجهاز. + لا تستخدم رقم تعريف شخصي استخدمته من قبل لحسابات الشخصية مثل حساب البنك أو حساب الضمان الإجتماعي. + رقم تعريف شخصي جديد مكون من 5 أرقام + تأكيد رقم التعريف الشخصي المكون من 5 أرقام + رقم التعريف الشخصي يجب أن يتكون من 5 أرقام. + برجاء التأكد من تطابق رقمي التعريف الشخصي. + حفظ + أمّن الحساب لإضافة ملفات شخصية + إضافة ملف شخصي + إضافة ملف شخصي + الاسم* + رقم تعريف شخصي مكون من 3 أرقام* + تأكيد رقم التعريف الشخصي المكون من 3 أرقام + السماح بالوصول للتنزيل + المستخدم قادر على تنزيل ومسح المحتوى بدون رقم التعريف الشخصي الخاص بالمشرف. + إنشاء + إغلاق + مع وجود رقم التعريف الشخصي، لا يمكن لأي شخص آخر الوصول إلى الملف الشخصي بخلاف هذا المستخدم. + فشلنا في حفظ الصورة. برجاء المحاولة مجددًا. + هذا الاسم مستخدَم بالفعل من قبل ملف شخصي آخر. + من فضلك أدخل اسمًا لهذا الملف الشخصي. + يمكن أن يحتوي الاسم على أحرفٍ فقط، جرّب اسمًا آخر؟ + رقم التعريف الشخصي يجب أن يتكون من 3 أرقام. + برجاء التأكد من تطابق رقمي التعريف الشخصي. + المزيد من المعلومات عن رقم التعريف الشخصي المكون من 3 أرقام. + الحقول المعلمة بـ* مطلوبة. + صورة الملف الشخصي الحالية. + تعديل صورة الملف الشخصي + مرحبًا بكم في أوبيا! + تعلّم أي شيءٍ تريده بطريقة فعّالة وممتعة. + أضف مستخدمين إلى حسابك. + شارك الخبرة وأنشئ حتى 10 ملفات شخصية. + تنزيل لحالة عدم وجود الإنترنت. + استمرّ في تعلّم دروسك بدون اتصال بالإنترنت. + استمتع! + استمتع بمغامراتك التعليمية مع دروسنا الفعّالة المجانية. + تخطي + التالي + ابدأ + شاشة العرض %d من %d + أهلًا، %s! + من فضلك أدخل رقم التعريف الشخصي الخاص بالمشرف. + من فضلك أدخل رقم التعريف الشخصي الخاص بك. + نسيت رقم التعريف الشخصي الخاص بي. + رقم تعريف شخصي غير صحيح. + عرض + إخفاء + إغلاق + تم تغيير رقم التعريف الشخصي بنجاح + نسيت رقم التعريف الشخصي؟ + لإعادة ضبط رقم التعريف الشخصي الخاص بك، برجاء حذف تطبيق أوبيا وإعادة تثبيته مرة أخرى.\n\nبرجاء العلم أنه في حالة عدم اتصال الجهاز بالإنترنت قد تفقد تقدم المستخدم في عدة حسابات. + الذهاب إلى متجر بلاي (Play Store). + إظهار/إخفاء أيقونة كلمة السر + أيقونة كلمة المرور ظاهرة. + أيقونة كلمة المرور مخفية. + أدخل رقم التعريف الشخصي الخاص بك + رقم التعريف الشخصي الخاص بالمشرف + الوصول إلى إعدادات المشرف + رقم التعريف الشخصي الخاص بالمشرف مطلوب لتغيير رقم التعريف الشخصي الخاص بالمستخدم + إلغاء + تقديم + رقم التعريف الشخصي الخاص بالمشرف غير صحيح. من فضلك حاول مجددًا. + رقم التعريف الشخصي الجديد الخاص ب%1$s. + أدخل رقم تعريف شخصي جديد + تنزيلاتي + التنزيلات + التحديثات (2) + هل تريد الخروج من ملفك الشخصي؟ + إلغاء + خروج + الملفات الشخصية + تم الإنشاء في %s + آخر استخدام + تغيير الاسم + إعادة ضبط رقم التعريف الشخصي + حذف الملف الشخصي + هل تريد حذف هذا الملف الشخصي نهائيًا؟ + سيتم حذف كل التقدم ولن تتمكن من استعادته. + حذف + إلغاء + السماح بالوصول للتنزيل + المستخدم قادر على تنزيل ومسح المحتوى بدون كلمة السر الخاصة بالمشرف. + صورة الملف الشخصي + إلغاء + عرض صورة الملف الشخصي + اختيار من المعرض + إعادة تسمية الملف الشخصي + الاسم الجديد + حفظ + إعادة ضبط رقم التعريف الشخصي + أدخل رقم تعريف شخصي جديد للمستخدم كي يستخدمه عند الدخول إلى ملفه الشخصي. + رقم تعريف شخصي مكون من 3 أرقام + رقم تعريف شخصي مكون من 5 أرقام + تأكيد رقم التعريف الشخصي المكون من 3 أرقام + تأكيد رقم التعريف الشخصي المكون من 5 أرقام + رقم التعريف الشخصي يجب أن يتكون من 3 أرقام. + رقم التعريف الشخصي يجب أن يتكون من 5 أرقام. + إنشاء رقم تعريف شخصي مكون من 3 أرقام + *مطلوب + زر العودة + التالي + عام + تعديل الحساب + إدارة الملف الشخصي + تعديل الملفات الشخصية + أُذونات التنزيل + تنزيل وتحديث عن طريق شبكة الوايفاي فقط + سيتم تحميل وتحديث المواضيع عن طريق شبكة الوايفاي فقط. أي تنزيلات أو تحديثات عن طريق بيانات الهاتف سيتم وضعها في قائمة الانتظار. + تحديث المواضيع تلقائيًا + سيتم تحديث الموضوعات التي تم تنزيلها والتي يتوفر بها محتوى جديد تلقائيًا. + معلومات التطبيق + إصدار التطبيق + إجراءات الحساب + تسجيل الخروج + إلغاء + حسنا + هل أنت متأكد من رغبتك في تسجيل الخروج من ملفك الشخصي؟ + إصدار التطبيق %s + تم تثبيت آخر إصدار في %s. قم باستخدام رقم الإصدار في الأعلى للإبلاغ عن أعطال التطبيق. + إصدار التطبيق + لغة التطبيق + لغة الصوت الافتراضية + حجم النص المقروء + حجم النص المقروء + سيظهر نص القصة بهذا الشكل. + أ + الصوت الافتراضي + لغة التطبيق + حجم النص المقروء + صغير + متوسط + كبير + كبير جدًا + شريط تغيير حجم النص. + الملف الشخصي + قصتان + من المواضيع في تقدم + موضوع في تقدم + قصص مكتملة + قصة مكتملة + خيارات + قصص مكتملة + تعلّم مهارات حسابية جديدة عن طريق القصص التي توضح لك كيف تستخدمها في حياتك اليومية + \"مرحبًا %s\" + ما الذي تريد أن تتعلمه؟ + عظيم + هيّا نبدأ. + نعم + لا... + اختر موضوعًا\nآخرًا. + هل أنت مهتم بـ:\n%s + ملاحظة جديدة متاحة + إظهار الملاحظات والحل + العودة للسابق + الملاحظات + عرض الحل + عرض الملاحظة + عرض/إخفاء قائمة الملاحظات الخاصة ب%s + عرض/إخفاء الحل + الحل الوحيد هو : + سوف يتم إظهار الحل. هل أنت متأكد؟ + إظهار + الآن + منذ %s + أمس + العودة إلى الموضوع + الشرح: + إذا تساوى عنصران، قم بدمجهم. + الربط بالعنصر %d + إلغاء ربط العناصر عند %d + تحريك العنصر إلى الأسفل إلى %d + تحريك العنصر إلى الأعلى عند %d + أعلى + أسفل + %s %s + {{PLURAL|one=\nدقيقة واحدة\n|\n%d من الدقائق + {{PLURAL|one=\nساعة واحدة\n|\n%d من الساعات + {{PLURAL|one=\nيوم واحد\n|\n%d من الأيام + topic_revision_recyclerview_tag + ongoing_recycler_view_tag + برجاء اختيار خيار واحد على الأقل. + إصدار تطبيق غير مدعوم + هذا الإصدار من التطبيق لم يعد مدعومًا. من فضلك قم بتحديث التطبيق من خلال متجر بلاي (Play Store) + إغلاق التطبيق + إلى + أدخل نسبة في الصيغة س:ص. + أصغر حجمًا للنص + أكبر حجمًا للنص + قريباً + قصص موصّى بها + قصص من أجلك + وضع الممارسة + صفحة مراجعة المهارات + تقدم الصوت + تغيير اللغة + الصوت مشغل + الصوت متوقف + إجابة مقدمة صحيحة + إجابة مقدمة صحيحة: %s + إجابة مقدمة غير صحيحة + إجابة مقدمة غير صحيحة: %s + خيارات المطورين + تمييز الفصول كمكتملة + تمييز القصص كمكتملة + تمييز المواضيع كمكتملة + عرض سجلات الأحداث (Event Logs) + تعديل تقدم الدرس + تمييز الفصول كمكتملة + تمييز القصص كمكتملة + تمييز المواضيع كمكتملة + عرض السجلات (Logs) + سجلات الأحداث (Event Logs) + تغيير تصرفات التطبيق + عرض جميع الملاحظات/الحل + فرض نوع الشبكة + تعطيل التطبيق + الكل + اكتمل التحديد + مكتبات برمجية (Third-party Dependencies) + إصدار %s + رخص حقوق النسخ + عارض رخصة حقوق النسخ + diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml new file mode 100644 index 00000000000..221312ff17e --- /dev/null +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,410 @@ + + + + Cabeçalho de navegação + Início + Opções + Meus Downloads + Ajuda + Ajuda + Trocar Perfil + Opções de Desenvolvedor + Controles de Administrador + Menu de Navegação Aberto + Fechar Menu de Navegação + Tocar áudio + Pausar áudio + OK + Cancelar + Idioma de Áudio + Atualmente Offline + Certifique-se de que o Wi-Fi ou os dados móveis estejam ativados e tente novamente. + OK + OK + Cancelar + Atualmente em Dados Móveis + O streaming de áudio pode usar muitos dados móveis. + Não mostre esta mensagem novamente + Cartão de Conceito + Clicar aqui fechará o cartão de conceito. + Cartão de Revisão + Pretende ir para a página do tópico? + Seu progresso não será salvo. + Sair + Cancelar + Pretende ir para a página do tópico? + Seu progresso não será salvo. + Cancelar + Sair + Capacidade máxima de armazenamento atingida + O progresso salvo para a lição \"%s\" será excluído. + Continuar + Sair sem salvar o progresso + De volta à aula + Domine Essas Habilidades + Razão e Proporção + Selecione as habilidades que você gostaria de praticar. + Começar + Continuar + Enviar + Voltar ao cartão anterior + Avançar para o próximo cartão + Enviar + Repetir + Retornar ao Tópico + Respostas Anteriores (%d) + Cliques em %s + Aprender Novamente + Veja Mais + Veja Menos + FAQs + Perguntas em Destaque + Perguntas Frequentes + FAQs (Perguntas Frequentes) + Verificação de PIN + Introdução à Oppia + Perguntas Frequentes (FAQs) + Info + Lições + Prática + Revisão + Controles do Administrador + Página do tópico + Tópico: %s + Tópico + Tópicos em Andamento + Capítulo %s: %s + O capítulo %s com o título %s foi concluído + O capítulo %s com o título %s está em andamento + Conclua o capítulo %s: %s para desbloquear este capítulo. + Inserir texto. + Insira uma fração na forma x/x, ou um número misto na forma x x/x. + Insira uma fração no formato x/x. + Digite um número. + Escreva números com unidades aqui. + Habilitar narração de áudio para esta lição. + Histórias Reproduzidas Recentemente + Últimas Histórias Reproduzidas + Ver Tudo + Reproduzidas na Última Semana + Reproduzidas no Último Mês + Lista de Capítulos + Imagem para %s + Todos os Tópicos + Histórias que Você Pode Reproduzir + Navegar para cima + Histórias Reproduzidas Recentemente + Tópico Baixado + Baixado + Modo de Prática + Pergunta %d de %d + Concluído + Concluído + Você concluiu todas as perguntas! Você pode escolher reproduzir outro conjunto de perguntas ou retornar ao tópico. + Em Andamento + Completado + Mostrar lista de capítulos + Esconder lista de capítulos + Reproduzir/Pausar Áudio + Preferências + Página de Progresso do Perfil + Buscar + Use apenas dígitos numéricos, espaços ou barras (/) + Insira uma fração válida (por exemplo, 5/3 ou 1 2/3) + Por favor, não coloque 0 no denominador + Nenhum dos números da fração deve ter mais de 7 dígitos. + Comece sua resposta com um número (por exemplo, \"0\" em 0,5) + Por favor, insira um número válido. + A resposta pode conter no máximo 15 dígitos (0–9) ou símbolos (. ou -). + Escreva uma proporção que consista em dígitos separados por dois pontos (por exemplo, 1:2 ou 1:2:3). + Insira uma proporção válida (por exemplo, 1:2 ou 1:2:3). + Sua resposta tem dois dois-pontos (:) do lado um do outro. + O número de termos não é igual ao exigido. + Proporções não podem ter 0 como elemento. + Tamanho desconhecido + %d Bytes + %d KB + %d MB + %d GB + Correto! + Tópico: %s + + 1 Capítulo\n + \n %d Capítulos\n + + + 1 História\n + \n %d Histórias\n + + + %d de %d Capítulo Concluído + %d de %d Capítulos Concluídos + + + 1 Lição\n + \n %d Lições\n + + + 1 História Concluída\n + %d Histórias Concluídas\n + \n %d Histórias Concluídas\n + + + 1 Tópico em Andamento\n + %d Tópicos em Andamento\n + \n %d Tópicos em Andamento\n + + Página de seleção de perfil + Administrador + Selecione seu perfil + Adicionar Perfil + Configurar Múltiplos Perfis + Adicione até 10 usuários à sua conta. Perfeito para famílias e salas de aula. + Controles do Administrador + Idioma + Controles do Administrador + Autorize para adicionar perfis + Autorize para acessar os Controles do Administrador + Autorização do Administrador Necessária + Insira o PIN do administrador para criar uma nova conta. + Insira o PIN do administrador para acessar os Controles do Administrador. + PIN do Administrador + PIN do administrador incorreto. Por favor, tente novamente. + Insira o PIN do administrador. + Enviar + Fechar + Antes de adicionarmos perfis, precisamos proteger sua conta criando um PIN. Isso dá a você a capacidade de autorizar downloads e gerenciar perfis no dispositivo. + Use um PIN que você definiu para contas pessoais, como bancos ou previdência social. + Novo PIN de 5 dígitos + Confirmar PIN de 5 dígitos + Seu PIN deve ter 5 dígitos. + Certifique-se de que os dois PINs coincidam. + Salvar + Autorize para adicionar perfis + Adicionar Perfil + Adicionar Perfil + Permitir Acesso a Download + O usuário pode baixar e excluir conteúdo sem o PIN do administrador. + Criar + Fechar + Com um PIN, ninguém mais pode acessar um perfil além deste usuário atribuído. + Falha ao armazenar sua imagem de avatar. Por favor, tente novamente. + Este nome já está em uso por outro perfil. + Por favor, insira um nome para este perfil. + Os nomes podem ter apenas letras. Tente outro nome. + Seu PIN deve ter 3 dígitos. + Certifique-se de que os dois PINs coincidam. + Mais informações sobre PINs de 3 dígitos. + Os campos marcados com * são obrigatórios. + Foto de perfil atual + Editar foto de perfil + Bem-vindo à Oppia! + Aprenda o que você quiser de uma forma eficaz e divertida. + Adicione usuários à sua conta. + Compartilhe a experiência e crie até 10 perfis. + Baixe para usar offline. + Continue aprendendo suas lições sem conexão com a internet. + Divirta-se! + Aproveite suas aventuras de aprendizado com nossas lições gratuitas e eficazes. + Pular + Próximo + Começar + Olá, %s! + Por favor, insira o PIN do Administrador. + Por favor, insira seu PIN. + Eu esqueci meu pin. + PIN incorreto. + Mostrar + Esconder + Fechar + A alteração do PIN foi bem-sucedida + Esqueceu o PIN? + Para redefinir o seu PIN, desinstale a Oppia e reinstale-a depois.\n\nLembre-se de que, se o dispositivo não estiver online, você pode perder o progresso do usuário em várias contas. + Ir para a Play Store + Mostrar/Esconder ícone da senha + Ícone de mostrar a senha + Ícone de esconder a senha + Insira seu PIN + PIN do Administrador + Acesso às Configurações do Administrador + PIN do administrador necessário para alterar o PIN do usuário + Cancelar + Enviar + PIN do administrador incorreto. Por favor, tente novamente. + Insira um Novo Pin + Meus Downloads + Downloads + Atualizações (2) + Você gostaria de sair do seu perfil? + Cancelar + Sair + Perfis + Criado em %s + Usado por último + Renomear + Redefinir PIN + Exclusão de Perfil + Apagar este perfil permanentemente? + Todo o progresso será apagado e não pode ser recuperado. + Apagar + Cancelar + Permitir Acesso a Download + O usuário pode baixar e apagar conteúdo sem senha de Administrador + Imagem de perfil + Cancelar + Visualizar Foto do Perfil + Escolha da Biblioteca + Renomear Perfil + Novo Nome + salvar + Redefinir PIN + Insira um novo PIN para o usuário usar ao acessar seu perfil. + PIN de 3 Dígitos + PIN de 5 Dígitos + Confirmar PIN de 3 Dígitos + Confirmar PIN de 5 dígitos + Seu PIN deve ter 3 dígitos. + Seu PIN deve ter 5 dígitos. + Criar um PIN de 3 Dígitos + Botão de Voltar + Próximo + Geral + Editar conta + Gerenciamento de Perfil + Editar perfis + Permissões de Download + Baixar e atualizar apenas com Wi-fi + Os tópicos serão baixados e atualizados apenas com Wi-fi. Quaisquer downloads ou atualizações de dados do celular serão enfileirados. + Atualizar tópicos automaticamente + Os tópicos baixados com novo conteúdo disponível serão atualizados automaticamente. + Informações do Aplicativo + Versão do Aplicativo + Ações da Conta + Sair + Cancelar + Ok + Tem certeza que deseja sair do seu perfil? + Versão do Aplicativo %s + A última atualização foi instalada em %s. Use o número da versão acima para enviar feedback sobre erros. + Versão do Aplicativo + Idioma do Aplicativo + Idioma Padrão de Áudio + Tamanho do Texto de Leitura + Tamanho do Texto de Leitura + O texto da história ficará assim. + A + Áudio Padrão + Idioma do Aplicativo + Tamanho do Texto de Leitura + Pequeno + Médio + Grande + Extra Grande + Deslize a barra para controlar o tamanho do texto. + Perfil + 2 Histórias + Tópicos em Andamento + Tópico em Andamento + Histórias Concluídas + História Concluída + Opções + Histórias Concluídas + Aprenda novas habilidades matemáticas com histórias que mostram como usá-las no seu dia a dia + \"Bem-vindo %s!\" + O que você quer aprender? + Ótimo + Vamos começar. + Sim + Não... + Escolha um\ntópico diferente. + Você está interessado em:\n%s? + Nova dica disponível + Mostrar dicas e solução + Navegar para cima + Dicas + Revelar Solução + Revelar Dica + Mostrar/Esconder lista de dicas de %s + Mostrar/Esconder solução + A única solução é : + Isso revelará a solução. Tem certeza? + Revelar + Agora + %s atrás + ontem + Voltar ao tópico + Explicação: + Se dois itens forem iguais, junte-os. + Vincular para o item %d + Desvincular itens em %d + Mover o item para baixo para %d + Mover o item para cima para %d + Para cima + Para baixo + %s %s + + um minuto + %d minutos + + + uma hora + %d horas + + + \num dia + \n%d dias + + Por favor, selecione pelo menos uma opção. + Versão do aplicativo não suportada + Esta versão do aplicativo não é mais suportada. Atualize-a na Play Store. + Fechar aplicativo + para + Insira uma razão no formato x:y. + Menor tamanho de texto + Maior tamanho de texto + Em Breve + Histórias Recomendadas + Histórias Para Você + Modo de Prática + Página de revisão de habilidades + Progresso do áudio + Alterar idioma + Áudio, LIGADO + Áudio, DESLIGADO + Resposta correta enviada + Resposta correta enviada: %s + Resposta incorreta enviada + Resposta incorreta enviada: %s + Opções do Desenvolvedor + Marcar Capítulos como Concluídos + Marcar Histórias como Concluídas + Marcar Tópicos como Concluídos + Ver Registro de Eventos + Alterar Progresso da Lição + Marcar Capítulos como Concluídos + Marcar Histórias como Concluídas + Marcar Tópicos como Concluídos + Ver Registros + Registro de Eventos + Mostrar todas as dicas/soluções + Todos + MARCAR COMO CONCLUÍDO + Rede está selecionanda + Padrão + Wifi + Celular + Sem conexão + Dependências de Terceiros + versão %s + Licença de Direitos Autorais + Visualizador de Licença de Direitos Autorais + Voltar para %s + lista de dependências de terceiros + lista de licenças de direitos autorais + Retomar Lição + Continuar + Recomeçar + From f2a412d017b4b01d1b76e38cd5bf7b592e740e28 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Thu, 2 Sep 2021 14:17:47 -0700 Subject: [PATCH 18/93] 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). --- oppia_android_application.bzl | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oppia_android_application.bzl b/oppia_android_application.bzl index 8e2498185b7..8c17e56cbec 100644 --- a/oppia_android_application.bzl +++ b/oppia_android_application.bzl @@ -45,7 +45,7 @@ def _convert_module_aab_to_structured_zip_impl(ctx): mkdir -p $WORKING_DIR/assets $WORKING_DIR/dex $WORKING_DIR/manifest $WORKING_DIR/root mv $WORKING_DIR/*.dex $WORKING_DIR/dex/ mv $WORKING_DIR/AndroidManifest.xml $WORKING_DIR/manifest/ - ls -d $WORKING_DIR/* | grep -v -w -E "res|assets|dex|manifest|root|resources.pb" | xargs mv -t $WORKING_DIR/root/ + ls -d $WORKING_DIR/* | grep -v -w -E "res|assets|dex|manifest|root|resources.pb" | xargs -n 1 -I {{}} mv {{}} root/ # Zip up the result--this will be used by bundletool to build a deployable AAB. Note that these # strange file path bits are needed because zip will always retain the directory structure From c8248ca05ce45706667c617a545a4b7bc530913e Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Sat, 4 Sep 2021 00:28:17 -0700 Subject: [PATCH 19/93] Introduce initial domain layer for translations. Documentation, thorough tests, and detailed description of these changes are still needed. --- config/config_proto_assets.bzl | 30 ++ .../oppia/android/config/AndroidManifest.xml | 5 + .../java/org/oppia/android/config/BUILD.bazel | 35 +++ .../languages/supported_languages.textproto | 109 +++++++ .../languages/supported_regions.textproto | 22 ++ .../domain/locale/AndroidLocaleProfile.kt | 21 ++ .../oppia/android/domain/locale/BUILD.bazel | 64 ++++ .../domain/locale/DisplayLocaleImpl.kt | 158 ++++++++++ .../domain/locale/LanguageConfigRetriever.kt | 20 ++ .../android/domain/locale/LocaleController.kt | 278 ++++++++++++++++++ .../domain/locale/MachineLocaleImpl.kt | 72 +++++ .../android/domain/locale/OppiaLocale.kt | 91 ++++++ .../android/domain/translation/BUILD.bazel | 28 ++ .../translation/TranslationController.kt | 242 +++++++++++++++ model/BUILD.bazel | 14 + model/src/main/proto/languages.proto | 265 +++++++++++++++++ model/src/main/proto/translation.proto | 6 + .../android/testing/time/FakeOppiaClock.kt | 6 - third_party/versions.bzl | 2 +- .../android/util/caching/AssetRepository.kt | 38 ++- .../oppia/android/util/system/OppiaClock.kt | 11 +- .../android/util/system/OppiaClockImpl.kt | 7 - 22 files changed, 1500 insertions(+), 24 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/AndroidLocaleProfile.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/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/locale/MachineLocaleImpl.kt create mode 100644 domain/src/main/java/org/oppia/android/domain/locale/OppiaLocale.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 model/src/main/proto/languages.proto 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..ba2b60beadb --- /dev/null +++ b/config/src/java/org/oppia/android/config/BUILD.bazel @@ -0,0 +1,35 @@ +# 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", +) 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..08483233de3 --- /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 { + ietf_bcp47_id { + ietf_language_tag: "hi-en" + } + } + audio_translation_id { + ietf_bcp47_id { + ietf_language_tag: "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..6e85b2bceaa --- /dev/null +++ b/config/src/java/org/oppia/android/config/languages/supported_regions.textproto @@ -0,0 +1,22 @@ +region_definitions { + region: BRAZIL + region_id { + ietf_region_tag: "BR" + } + 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/domain/src/main/java/org/oppia/android/domain/locale/AndroidLocaleProfile.kt b/domain/src/main/java/org/oppia/android/domain/locale/AndroidLocaleProfile.kt new file mode 100644 index 00000000000..20911659969 --- /dev/null +++ b/domain/src/main/java/org/oppia/android/domain/locale/AndroidLocaleProfile.kt @@ -0,0 +1,21 @@ +package org.oppia.android.domain.locale + +import java.util.Locale + +data class AndroidLocaleProfile(val languageCode: String, val regionCode: String) { + fun matches( + machineLocale: OppiaLocale.MachineLocale, + otherProfile: AndroidLocaleProfile + ): Boolean { + return machineLocale.run { + languageCode.equalsIgnoreCase(otherProfile.languageCode) + } && machineLocale.run { + regionCode.equalsIgnoreCase(otherProfile.regionCode) + } + } + + companion object { + fun createFrom(androidLocale: Locale): AndroidLocaleProfile = + AndroidLocaleProfile(androidLocale.language, androidLocale.country) + } +} 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..bd585da9c7f --- /dev/null +++ b/domain/src/main/java/org/oppia/android/domain/locale/BUILD.bazel @@ -0,0 +1,64 @@ +""" +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 = "oppia_locale", + srcs = [ + "OppiaLocale.kt", + ], + visibility = ["//:oppia_api_visibility"], + deps = [ + "//model:languages_java_proto_lite", + "//third_party:androidx_annotation_annotation", + ], +) + +kt_android_library( + name = "locale_controller", + srcs = [ + "LocaleController.kt", + ], + visibility = ["//:oppia_api_visibility"], + deps = [ + ":dagger", + ":impl", + ":language_config_retriever", + ":oppia_locale", + "//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", + ], +) + +kt_android_library( + name = "impl", + srcs = [ + "AndroidLocaleProfile.kt", + "DisplayLocaleImpl.kt", + "MachineLocaleImpl.kt", + ], + deps = [ + ":oppia_locale", + "//utility/src/main/java/org/oppia/android/util/data:data_providers", + "//utility/src/main/java/org/oppia/android/util/system:oppia_clock", + ], +) + +kt_android_library( + name = "language_config_retriever", + srcs = [ + "LanguageConfigRetriever.kt", + ], + deps = [ + ":dagger", + "//model:languages_java_proto_lite", + "//utility/src/main/java/org/oppia/android/util/caching:assets", + ], +) + +dagger_rules() 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..ab6875e7846 --- /dev/null +++ b/domain/src/main/java/org/oppia/android/domain/locale/DisplayLocaleImpl.kt @@ -0,0 +1,158 @@ +package org.oppia.android.domain.locale + +import android.content.res.Resources +import android.os.Build +import androidx.annotation.ArrayRes +import androidx.annotation.StringRes +import java.text.DateFormat +import java.util.Locale +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.system.OppiaClock + +// TODO(#3766): Restrict to be 'internal'. +class DisplayLocaleImpl( + private val oppiaClock: OppiaClock, + localeContext: OppiaLocaleContext, + private val machineLocale: MachineLocale +): OppiaLocale.DisplayLocale(localeContext) { + private val formattingLocale by lazy { computeLocale() } + 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) + } + + override fun getCurrentDateString(): String = dateFormat.format(oppiaClock.getCurrentDate()) + + override fun getCurrentTimeString(): String = timeFormat.format(oppiaClock.getCurrentDate()) + + override fun getCurrentDateTimeString(): String = + dateTimeFormat.format(oppiaClock.getCurrentDate()) + + override fun String.formatInLocale(vararg args: Any?): String = format(formattingLocale, *args) + + override fun Resources.getStringInLocale(@StringRes id: Int): String = getString(id) + + override fun Resources.getStringInLocale(@StringRes id: Int, vararg formatArgs: Any?): String = + getStringInLocale(id).formatInLocale(*formatArgs) + + override fun Resources.getStringArrayInLocale(@ArrayRes id: Int): List = + getStringArray(id).toList() + + private fun computeLocale(): Locale { + // 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() + computePotentialFallbackLanguageProfiles() + + // Either find the first supported profile or force the locale to use the exact definition + // values. + val selectedProfile = + potentialProfiles.findFirstSupported() + ?: getLanguageId().computeForcedProfile(localeContext.regionDefinition) + + return Locale(selectedProfile.languageCode, selectedProfile.regionCode) + } + + private fun computePotentialLanguageProfiles(): List { + return if (localeContext.languageDefinition.minAndroidSdkVersion <= Build.VERSION.SDK_INT) { + listOf( + getLanguageId().computeLocaleProfileFromAndroidId(), + getLanguageId().computeLocaleProfileFromIetfDefinitions(localeContext.regionDefinition), + getLanguageId().computeLocaleProfileFromMacaronicLanguage() + ) + } else listOf() + } + + private fun computePotentialFallbackLanguageProfiles(): List { + val fallbackLanguageMinSdk = localeContext.fallbackLanguageDefinition.minAndroidSdkVersion + return if (fallbackLanguageMinSdk <= Build.VERSION.SDK_INT) { + listOf( + getFallbackLanguageId().computeLocaleProfileFromAndroidId(), + getFallbackLanguageId().computeLocaleProfileFromIetfDefinitions( + localeContext.regionDefinition + ), + getFallbackLanguageId().computeLocaleProfileFromMacaronicLanguage() + ) + } else listOf() + } + + private fun LanguageId.computeLocaleProfileFromAndroidId(): AndroidLocaleProfile? { + return if (hasAndroidResourcesLanguageId()) { + androidResourcesLanguageId.run { maybeConstructProfile(languageCode, regionCode) } + } else null + } + + private fun LanguageId.computeLocaleProfileFromIetfDefinitions( + regionDefinition: RegionSupportDefinition + ): AndroidLocaleProfile? { + if (!hasIetfBcp47Id()) return null + if (!regionDefinition.hasRegionId()) return null + return maybeConstructProfile( + ietfBcp47Id.ietfLanguageTag, regionDefinition.regionId.ietfRegionTag + ) + } + + private fun LanguageId.computeLocaleProfileFromMacaronicLanguage(): AndroidLocaleProfile? { + if (!hasMacaronicId()) return null + val (languageCode, regionCode) = macaronicId.combinedLanguageCode.divide("-") ?: return null + return maybeConstructProfile(languageCode, regionCode) + } + + /** + * 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 { + return AndroidLocaleProfile( + ietfBcp47Id.ietfLanguageTag, regionDefinition.regionId.ietfRegionTag + ) + } + + private fun maybeConstructProfile( + languageCode: String, regionCode: String + ): AndroidLocaleProfile? { + return if (languageCode.isNotEmpty() && regionCode.isNotEmpty()) { + AndroidLocaleProfile(languageCode, regionCode) + } else null + } + + private fun List.findFirstSupported(): AndroidLocaleProfile? = find { + it?.let { profileToMatch -> + availableLocaleProfiles.any { availableProfile -> + availableProfile.matches(machineLocale, profileToMatch) + } + } ?: false // Ignore null profiles. + } + + private companion object { + private const val DATE_FORMAT_LENGTH = DateFormat.LONG + private const val TIME_FORMAT_LENGTH = DateFormat.SHORT + + private val availableLocaleProfiles by lazy { + Locale.getAvailableLocales().map(AndroidLocaleProfile::createFrom) + } + } +} + +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/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..22968ebd236 --- /dev/null +++ b/domain/src/main/java/org/oppia/android/domain/locale/LanguageConfigRetriever.kt @@ -0,0 +1,20 @@ +package org.oppia.android.domain.locale + +import javax.inject.Inject +import org.oppia.android.app.model.SupportedLanguages +import org.oppia.android.app.model.SupportedRegions +import org.oppia.android.util.caching.AssetRepository + +class LanguageConfigRetriever @Inject constructor(private val assetRepository: AssetRepository) { + fun loadSupportedLanguages(): SupportedLanguages { + return assetRepository.tryLoadProtoFromLocalAssets( + "supported_languages", SupportedLanguages.getDefaultInstance() + ) + } + + 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..81910ee0cc4 --- /dev/null +++ b/domain/src/main/java/org/oppia/android/domain/locale/LocaleController.kt @@ -0,0 +1,278 @@ +package org.oppia.android.domain.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.app.model.SupportedLanguages +import org.oppia.android.app.model.SupportedRegions +import org.oppia.android.util.data.DataProvider +import java.util.Locale +import org.oppia.android.domain.oppialogger.OppiaLogger +import java.util.concurrent.locks.ReentrantLock +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.concurrent.withLock +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.domain.locale.OppiaLocale.ContentLocale +import org.oppia.android.domain.locale.OppiaLocale.DisplayLocale +import org.oppia.android.domain.locale.OppiaLocale.MachineLocale +import org.oppia.android.util.data.AsyncDataSubscriptionManager +import org.oppia.android.util.data.AsyncResult +import org.oppia.android.util.data.DataProviders +import org.oppia.android.util.data.DataProviders.Companion.transformAsync +import org.oppia.android.util.system.OppiaClock + +// TODO: document how notifications work (everything is rooted from changing Locale). +private const val ANDROID_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" + +@Singleton +class LocaleController @Inject constructor( + private val dataProviders: DataProviders, + private val languageConfigRetriever: LanguageConfigRetriever, + private val oppiaLogger: OppiaLogger, + private val asyncDataSubscriptionManager: AsyncDataSubscriptionManager, + private val oppiaClock: OppiaClock +) { + private val definitionsLock = ReentrantLock() + private lateinit var supportedLanguages: SupportedLanguages + private lateinit var supportedRegions: SupportedRegions + + private val machineLocaleImpl: MachineLocale by lazy { MachineLocaleImpl(oppiaClock) } + + // TODO: this won't work in very specific cases (restore from bundle for new process). Tie to + // tracking TODO. + fun reconstituteDisplayLocale(oppiaLocaleContext: OppiaLocaleContext): DisplayLocale { + return DisplayLocaleImpl(oppiaClock, oppiaLocaleContext, machineLocaleImpl) + } + + // TODO: document that retrieving country is a fixed thing. Explain usage mode. + fun retrieveAppStringDisplayLocale(language: OppiaLanguage): DataProvider { + val providerId = "$APP_STRING_LOCALE_DATA_BASE_PROVIDER_ID.${language.name}" + return getAndroidLocale().transformAsync(providerId) { systemLocaleProfile -> + computeLocaleResult(language, systemLocaleProfile, APP_STRINGS) + } + } + + fun retrieveWrittenTranslationsLocale(language: OppiaLanguage): DataProvider { + val providerId = "$WRITTEN_TRANSLATION_LOCALE_BASE_DATA_PROVIDER_ID.${language.name}" + return getAndroidLocale().transformAsync(providerId) { systemLocaleProfile -> + computeLocaleResult(language, systemLocaleProfile, CONTENT_STRINGS) + } + } + + fun retrieveAudioTranslationsLocale(language: OppiaLanguage): DataProvider { + val providerId = "$AUDIO_TRANSLATIONS_LOCALE_BASE_DATA_PROVIDER_ID.${language.name}" + return getAndroidLocale().transformAsync(providerId) { systemLocaleProfile -> + computeLocaleResult(language, systemLocaleProfile, AUDIO_TRANSLATIONS) + } + } + + fun getMachineLocale(): MachineLocale = machineLocaleImpl + + // TODO: document only matches to app language definitions. + fun retrieveSystemLanguage(): DataProvider { + val providerId = SYSTEM_LANGUAGE_DATA_PROVIDER_ID + return getAndroidLocale().transformAsync(providerId) { systemLocaleProfile -> + // TODO: fix failover + AsyncResult.success( + retrieveLanguageDefinitionFromSystemCode(systemLocaleProfile.languageCode)?.language + ?: OppiaLanguage.LANGUAGE_UNSPECIFIED + ) + } + } + + // TODO: document that this can't be called due to Locale being prohibited broadly in the + // codebase. Might be nice to find a more private signal mechanism. + fun updateDefaultLocale(newLocale: Locale) { + // TODO: add regex prohibiting this + Locale.setDefault(newLocale) + asyncDataSubscriptionManager.notifyChangeAsync(ANDROID_LOCALE_DATA_PROVIDER_ID) + } + + private fun getAndroidLocale(): DataProvider { + return dataProviders.createInMemoryDataProvider(ANDROID_LOCALE_DATA_PROVIDER_ID) { + AndroidLocaleProfile.createFrom(Locale.getDefault()) + } + } + + 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(oppiaClock, localeContext, machineLocaleImpl) + CONTENT_STRINGS, AUDIO_TRANSLATIONS -> ContentLocale(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 currentSystemLanguageCode by lazy { systemLocaleProfile.languageCode } + val matchedDefinition = retrieveLanguageDefinition(language) + return if (usageMode == APP_STRINGS) { + matchedDefinition + ?: retrieveLanguageDefinitionFromSystemCode(currentSystemLanguageCode) + ?: computeDefaultLanguageDefinitionForSystemLanguage(currentSystemLanguageCode) + } 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 language code, 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( + languageCode: String + ): 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. + return definitions.languageDefinitionsList.find { + machineLocaleImpl.run { + languageCode.equalsIgnoreCase(it.retrieveAppLanguageCode()) + } + } + } + + 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 { + machineLocaleImpl.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.retrieveAppLanguageCode(): String? { + return when (appStringId.languageTypeCase) { + LanguageSupportDefinition.LanguageId.LanguageTypeCase.IETF_BCP47_ID -> + appStringId.ietfBcp47Id.ietfLanguageTag + LanguageSupportDefinition.LanguageId.LanguageTypeCase.MACARONIC_ID -> + appStringId.macaronicId.combinedLanguageCode // Likely won't match against system languages. + 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/locale/MachineLocaleImpl.kt b/domain/src/main/java/org/oppia/android/domain/locale/MachineLocaleImpl.kt new file mode 100644 index 00000000000..8f05530f14e --- /dev/null +++ b/domain/src/main/java/org/oppia/android/domain/locale/MachineLocaleImpl.kt @@ -0,0 +1,72 @@ +package org.oppia.android.domain.locale + +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Calendar +import java.util.Date +import java.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 + +// TODO: documentation. Explain that US locale is always used for machine-readable strings. +// TODO(#3766): Restrict to be 'internal'. +class MachineLocaleImpl( + private val oppiaClock: OppiaClock +): OppiaLocale.MachineLocale(machineLocaleContext) { + private val parsableDateFormat by lazy { SimpleDateFormat("yyyy-MM-dd", 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?.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 17 downTo 3 -> TimeOfDay.EVENING + else -> null + } + } + + override fun parseOppiaDate(dateString: String): OppiaDate? { + val parsedDate = try { + parsableDateFormat.parse(dateString) + } catch (e: ParseException) { + null + } + return parsedDate?.let { OppiaDateImpl(it, oppiaClock.getCurrentDate()) } + } + + 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/domain/src/main/java/org/oppia/android/domain/locale/OppiaLocale.kt b/domain/src/main/java/org/oppia/android/domain/locale/OppiaLocale.kt new file mode 100644 index 00000000000..85adf063509 --- /dev/null +++ b/domain/src/main/java/org/oppia/android/domain/locale/OppiaLocale.kt @@ -0,0 +1,91 @@ +package org.oppia.android.domain.locale + +import android.content.res.Resources +import androidx.annotation.ArrayRes +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.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 + +sealed class OppiaLocale(val localeContext: OppiaLocaleContext) { + // TODO: verify exclusivity of regions/languages table in tests. + + fun getCurrentLanguage(): OppiaLanguage = localeContext.languageDefinition.language + + fun getLanguageId(): LanguageId = when (localeContext.usageMode) { + APP_STRINGS -> localeContext.languageDefinition.appStringId + CONTENT_STRINGS -> localeContext.languageDefinition.contentStringId + AUDIO_TRANSLATIONS -> localeContext.languageDefinition.audioTranslationId + USAGE_MODE_UNSPECIFIED, UNRECOGNIZED, null -> LanguageId.getDefaultInstance() + } + + fun getFallbackLanguageId(): LanguageId = when (localeContext.usageMode) { + APP_STRINGS -> localeContext.fallbackLanguageDefinition.appStringId + CONTENT_STRINGS -> localeContext.fallbackLanguageDefinition.contentStringId + AUDIO_TRANSLATIONS -> localeContext.fallbackLanguageDefinition.audioTranslationId + USAGE_MODE_UNSPECIFIED, UNRECOGNIZED, null -> LanguageId.getDefaultInstance() + } + + fun getCurrentRegion(): OppiaRegion = localeContext.regionDefinition.region + + // TODO: documentation (https://developer.android.com/reference/java/util/Locale). + abstract class MachineLocale(localeContext: OppiaLocaleContext): OppiaLocale(localeContext) { + abstract fun String.formatForMachines(vararg args: Any?): String + + abstract fun String.toMachineLowerCase(): String + + abstract fun String.toMachineUpperCase(): String + + abstract fun String.capitalizeForMachines(): String + + abstract fun String.decapitalizeForMachines(): String + + // TODO: regex to block ignoreCase. + abstract fun String?.equalsIgnoreCase(other: String?): Boolean + + // TODO: documentation. See below. + abstract fun getCurrentTimeOfDay(): TimeOfDay? + + // TODO: documentation. Explain this is always corresponding to the local timezone of the device + // (which isn't tied to the locale). + abstract fun parseOppiaDate(dateString: String): OppiaDate? + + enum class TimeOfDay { + MORNING, + AFTERNOON, + EVENING + } + + interface OppiaDate { + fun isBeforeToday(): Boolean + } + } + + abstract class DisplayLocale(localeContext: OppiaLocaleContext): OppiaLocale(localeContext) { + abstract fun getCurrentDateString(): String + + abstract fun getCurrentTimeString(): String + + abstract fun getCurrentDateTimeString(): String + + // TODO: mention bidi wrapping & machine readable args + // TODO: document that receiver is the format (unlike String.format()). + abstract fun String.formatInLocale(vararg args: Any?): String + + abstract fun Resources.getStringInLocale(@StringRes id: Int): String + + abstract fun Resources.getStringInLocale(@StringRes id: Int, vararg formatArgs: Any?): String + + // TODO: for this & others, document that they won't necessarily follow the locale of this object + // (they actually depend on the locale specified in Resources). + abstract fun Resources.getStringArrayInLocale(@ArrayRes id: Int): List + } + + class ContentLocale(localeContext: OppiaLocaleContext): OppiaLocale(localeContext) +} 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..fc023d498ca --- /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 = "oppia_locale", + srcs = [ + "TranslationController.kt", + ], + visibility = ["//:oppia_api_visibility"], + deps = [ + "//domain/src/main/java/org/oppia/android/domain/locale:locale_controller", + "//domain/src/main/java/org/oppia/android/domain/locale:oppia_locale", + "//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", + ], +) + +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..0147cf85503 --- /dev/null +++ b/domain/src/main/java/org/oppia/android/domain/translation/TranslationController.kt @@ -0,0 +1,242 @@ +package org.oppia.android.domain.translation + +import java.util.concurrent.locks.ReentrantLock +import javax.inject.Inject +import kotlin.concurrent.withLock +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.OppiaLocale +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 + +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" + +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. + + private val dataLock = ReentrantLock() + private val appLanguageSettings = mutableMapOf() + private val writtenTranslationLanguageSettings = + mutableMapOf() + private val audioVoiceoverLanguageSettings = + mutableMapOf() + + fun getSystemLanguageLocale(): DataProvider { + return getSystemLanguage().transformAsync(SYSTEM_LANGUAGE_LOCALE_DATA_PROVIDER_ID) { language -> + localeController.retrieveAppStringDisplayLocale(language).retrieveData() + } + } + + fun getAppLanguage(profileId: ProfileId): DataProvider { + return getAppLanguageLocale(profileId).transform(APP_LANGUAGE_DATA_PROVIDER_ID) { locale -> + locale.getCurrentLanguage() + } + } + + 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() + } + } + + fun updateAppLanguage(profileId: ProfileId, selection: AppLanguageSelection): DataProvider { + return dataProviders.createInMemoryDataProviderAsync(UPDATE_APP_LANGUAGE_DATA_PROVIDER_ID) { + updateAppLanguageSelection(profileId, selection) + return@createInMemoryDataProviderAsync AsyncResult.success(null) + } + } + + fun getWrittenTranslationContentLanguage(profileId: ProfileId): DataProvider { + val providerId = WRITTEN_TRANSLATION_CONTENT_DATA_PROVIDER_ID + return getWrittenTranslationContentLocale(profileId).transform(providerId) { locale -> + locale.getCurrentLanguage() + } + } + + 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() + } + } + + 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(null) + } + } + + fun getAudioTranslationContentLanguage(profileId: ProfileId): DataProvider { + val providerId = AUDIO_TRANSLATION_CONTENT_DATA_PROVIDER_ID + return getAudioTranslationContentLocale(profileId).transform(providerId) { locale -> + locale.getCurrentLanguage() + } + } + + 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() + } + } + + 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(null) + } + } + + fun extractString(html: SubtitledHtml, context: WrittenTranslationContext): String { + return context.translationsMap[html.contentId]?.html ?: html.html + } + + 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/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/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/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..3e0e61bb74a 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 @@ -32,12 +32,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/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/src/main/java/org/oppia/android/util/caching/AssetRepository.kt b/utility/src/main/java/org/oppia/android/util/caching/AssetRepository.kt index 0e7514e838c..68d2de1d488 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 @@ -31,7 +31,7 @@ class AssetRepository @Inject constructor( private val textFileAssets = mutableMapOf() /** Map of asset names to file contents for proto file assets. */ - private val protoFileAssets = mutableMapOf() + private val protoFileAssets = mutableMapOf() /** Returns the whole text contents of the file corresponding to the specified asset name. */ fun loadTextFileFromLocalAssets(assetName: String): String { @@ -61,18 +61,35 @@ class AssetRepository @Inject constructor( * 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 + return maybeProtoFromLocalAssetsOrFail(assetName, baseMessage) + ?: error("Asset doesn't exist: $assetName") } - /** Returns the size of the specified proto asset. */ + /** + * 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 { + return maybeProtoFromLocalAssetsOrFail(assetName, defaultMessage) ?: defaultMessage + } + + /** Returns the size of the specified proto asset, or -1 if the asset doesn't exist. */ fun getLocalAssetProtoSize(assetName: String): Int { - return loadProtoBlobFromLocalAssets(assetName).size + 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 { + private fun loadProtoBlobFromLocalAssets(assetName: String): ByteArray? { primeProtoBlobFromLocalAssets(assetName) return protoFileAssets.getValue(assetName) } @@ -80,7 +97,10 @@ class AssetRepository @Inject constructor( private fun primeProtoBlobFromLocalAssets(assetName: String) { repositoryLock.withLock { if (assetName !in protoFileAssets) { - protoFileAssets[assetName] = context.assets.open("$assetName.pb").use { it.readBytes() } + val files = context.assets.list(/* path= */ ".")?.toList() ?: listOf() + protoFileAssets[assetName] = if (assetName in files) { + context.assets.open("$assetName.pb").use { it.readBytes() } + } else null } } } 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 - } } From 512f0b3fb32f2cc41ff7fc424afdb3279eb03308 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 8 Sep 2021 15:46:44 -0700 Subject: [PATCH 20/93] Initial app layer implementation for translations. This demonstrates working string selection for system-based and overwritten app languages, including necessary activity recreation & layout direction overwriting. This also includes a bunch of Dagger infra refactoring so that some app layer packages can now be modularized (including the new packages). --- app/BUILD.bazel | 49 ++++- .../android/app/activity/ActivityComponent.kt | 170 +---------------- .../ActivityComponentFactory.kt | 5 +- .../app/activity/ActivityComponentImpl.kt | 175 ++++++++++++++++++ .../app/activity/ActivityIntentFactories.kt | 20 ++ .../activity/ActivityIntentFactoriesModule.kt | 21 +++ .../android/app/activity/ActivityModule.kt | 4 +- .../oppia/android/app/activity/BUILD.bazel | 74 ++++++++ .../activity/InjectableAppCompatActivity.kt | 74 ++++++-- .../AdministratorControlsActivity.kt | 3 +- .../AdministratorControlsFragment.kt | 3 +- .../appversion/AppVersionActivity.kt | 3 +- .../appversion/AppVersionFragment.kt | 3 +- .../app/application/ApplicationComponent.kt | 4 +- .../app/application/ApplicationInjector.kt | 3 +- .../ApplicationInjectorProvider.kt | 8 +- .../app/application/ApplicationModule.kt | 4 +- .../app/application/OppiaApplication.kt | 5 +- .../CompletedStoryListActivity.kt | 3 +- .../CompletedStoryListFragment.kt | 3 +- .../customview/LessonThumbnailImageView.kt | 10 +- ...maticAppDeprecationNoticeDialogFragment.kt | 3 +- .../devoptions/DeveloperOptionsActivity.kt | 3 +- .../devoptions/DeveloperOptionsFragment.kt | 3 +- .../ForceNetworkTypeActivity.kt | 3 +- .../ForceNetworkTypeFragment.kt | 3 +- .../testing/ForceNetworkTypeTestActivity.kt | 3 +- .../MarkChaptersCompletedActivity.kt | 3 +- .../MarkChaptersCompletedFragment.kt | 3 +- .../MarkChaptersCompletedTestActivity.kt | 3 +- .../MarkStoriesCompletedActivity.kt | 3 +- .../MarkStoriesCompletedFragment.kt | 3 +- .../MarkStoriesCompletedTestActivity.kt | 3 +- .../MarkTopicsCompletedActivity.kt | 3 +- .../MarkTopicsCompletedFragment.kt | 3 +- .../MarkTopicsCompletedTestActivity.kt | 3 +- .../testing/DeveloperOptionsTestActivity.kt | 3 +- .../vieweventlogs/ViewEventLogsActivity.kt | 3 +- .../vieweventlogs/ViewEventLogsFragment.kt | 3 +- .../testing/ViewEventLogsTestActivity.kt | 3 +- .../app/drawer/NavigationDrawerFragment.kt | 3 +- .../oppia/android/app/fragment/BUILD.bazel | 93 ++++++++++ .../android/app/fragment/FragmentComponent.kt | 128 ------------- .../FragmentComponentBuilderInjector.kt | 7 + .../FragmentComponentBuilderModule.kt | 10 + .../app/fragment/FragmentComponentFactory.kt | 9 + .../app/fragment/FragmentComponentImpl.kt | 139 ++++++++++++++ .../android/app/fragment/FragmentModule.kt | 4 +- .../app/fragment/InjectableDialogFragment.kt | 22 ++- .../app/fragment/InjectableFragment.kt | 17 +- .../oppia/android/app/help/HelpActivity.kt | 3 +- .../oppia/android/app/help/HelpFragment.kt | 3 +- .../android/app/help/faq/FAQListActivity.kt | 3 +- .../android/app/help/faq/FAQListFragment.kt | 3 +- .../help/faq/faqsingle/FAQSingleActivity.kt | 3 +- .../help/thirdparty/LicenseListActivity.kt | 3 +- .../help/thirdparty/LicenseListFragment.kt | 3 +- .../thirdparty/LicenseTextViewerActivity.kt | 3 +- .../thirdparty/LicenseTextViewerFragment.kt | 3 +- .../ThirdPartyDependencyListActivity.kt | 3 +- .../ThirdPartyDependencyListFragment.kt | 3 +- .../HintsAndSolutionDialogFragment.kt | 3 +- .../oppia/android/app/home/HomeActivity.kt | 3 +- .../oppia/android/app/home/HomeFragment.kt | 3 +- .../promotedlist/ComingSoonTopicsListView.kt | 8 +- .../promotedlist/PromotedStoryListView.kt | 8 +- .../recentlyplayed/RecentlyPlayedActivity.kt | 22 ++- .../recentlyplayed/RecentlyPlayedFragment.kt | 3 +- .../app/mydownloads/DownloadsTabFragment.kt | 3 +- .../app/mydownloads/MyDownloadsActivity.kt | 3 +- .../app/mydownloads/MyDownloadsFragment.kt | 3 +- .../app/mydownloads/UpdatesTabFragment.kt | 3 +- .../app/onboarding/OnboardingActivity.kt | 3 +- .../app/onboarding/OnboardingFragment.kt | 3 +- .../OngoingTopicListActivity.kt | 3 +- .../OngoingTopicListFragment.kt | 3 +- .../app/options/AppLanguageActivity.kt | 3 +- .../app/options/AppLanguageFragment.kt | 3 +- .../app/options/AudioLanguageActivity.kt | 3 +- .../app/options/AudioLanguageFragment.kt | 3 +- .../android/app/options/OptionsActivity.kt | 3 +- .../android/app/options/OptionsFragment.kt | 3 +- .../app/options/ReadingTextSizeActivity.kt | 3 +- .../app/options/ReadingTextSizeFragment.kt | 3 +- .../android/app/player/audio/AudioFragment.kt | 3 +- .../player/exploration/ExplorationActivity.kt | 3 +- .../player/exploration/ExplorationFragment.kt | 3 +- .../exploration/ExplorationManagerFragment.kt | 3 +- ...tsAndSolutionExplorationManagerFragment.kt | 3 +- .../state/DragDropSortInteractionView.kt | 10 +- .../ImageRegionSelectionInteractionView.kt | 11 +- .../player/state/SelectionInteractionView.kt | 9 +- .../android/app/player/state/StateFragment.kt | 3 +- .../testing/StateFragmentTestActivity.kt | 3 +- .../android/app/profile/AddProfileActivity.kt | 3 +- .../android/app/profile/AdminAuthActivity.kt | 3 +- .../android/app/profile/AdminPinActivity.kt | 3 +- .../profile/AdminSettingsDialogFragment.kt | 3 +- .../app/profile/PinPasswordActivity.kt | 3 +- .../app/profile/ProfileChooserActivity.kt | 5 +- .../app/profile/ProfileChooserFragment.kt | 3 +- .../app/profile/ResetPinDialogFragment.kt | 3 +- .../profileprogress/ProfilePictureActivity.kt | 3 +- .../ProfileProgressActivity.kt | 3 +- .../ProfileProgressFragment.kt | 3 +- .../app/resumelesson/ResumeLessonActivity.kt | 3 +- .../app/resumelesson/ResumeLessonFragment.kt | 3 +- .../settings/profile/ProfileEditActivity.kt | 3 +- .../settings/profile/ProfileEditFragment.kt | 3 +- .../settings/profile/ProfileListActivity.kt | 3 +- .../settings/profile/ProfileListFragment.kt | 3 +- .../settings/profile/ProfileRenameActivity.kt | 3 +- .../profile/ProfileResetPinActivity.kt | 3 +- .../org/oppia/android/app/shim/BUILD.bazel | 85 +++++++++ .../android/app/shim/IntentFactoryShim.kt | 4 - .../android/app/shim/IntentFactoryShimImpl.kt | 57 +++--- .../oppia/android/app/shim/ViewBindingShim.kt | 10 +- .../android/app/shim/ViewComponentFactory.kt | 11 -- .../android/app/splash/SplashActivity.kt | 30 ++- .../app/splash/SplashActivityPresenter.kt | 72 +++++-- .../oppia/android/app/story/StoryActivity.kt | 3 +- .../oppia/android/app/story/StoryFragment.kt | 3 +- .../app/testing/AudioFragmentTestActivity.kt | 3 +- .../ConceptCardFragmentTestActivity.kt | 3 +- .../app/testing/DragDropTestActivity.kt | 3 +- .../testing/ExplorationInjectionActivity.kt | 3 +- .../app/testing/ExplorationTestActivity.kt | 3 +- .../app/testing/HomeFragmentTestActivity.kt | 3 +- .../android/app/testing/HomeTestActivity.kt | 3 +- .../app/testing/HtmlParserTestActivity.kt | 3 +- .../ImageRegionSelectionTestActivity.kt | 3 +- .../ImageRegionSelectionTestFragment.kt | 3 +- .../MarginBindingAdaptersTestActivity.kt | 3 +- .../testing/NavigationDrawerTestActivity.kt | 3 +- .../ProfileChooserFragmentTestActivity.kt | 3 +- .../android/app/testing/SplashTestActivity.kt | 3 +- ...emblerMarginBindingAdaptersTestActivity.kt | 3 +- ...mblerPaddingBindingAdaptersTestActivity.kt | 3 +- .../TestFontScaleConfigurationUtilActivity.kt | 3 +- .../app/testing/TopicRevisionTestActivity.kt | 3 +- .../android/app/testing/TopicTestActivity.kt | 3 +- .../app/testing/TopicTestActivityForStory.kt | 3 +- .../ViewBindingAdaptersTestActivity.kt | 3 +- .../oppia/android/app/topic/TopicActivity.kt | 32 +++- .../oppia/android/app/topic/TopicFragment.kt | 3 +- .../topic/conceptcard/ConceptCardFragment.kt | 3 +- .../app/topic/info/TopicInfoFragment.kt | 3 +- .../app/topic/lessons/TopicLessonsFragment.kt | 3 +- .../topic/practice/TopicPracticeFragment.kt | 3 +- ...HintsAndSolutionQuestionManagerFragment.kt | 3 +- .../questionplayer/QuestionPlayerActivity.kt | 3 +- .../questionplayer/QuestionPlayerFragment.kt | 3 +- .../topic/revision/TopicRevisionFragment.kt | 3 +- .../revisioncard/RevisionCardActivity.kt | 3 +- .../revisioncard/RevisionCardFragment.kt | 3 +- .../AppLanguageActivityInjector.kt | 5 + .../AppLanguageApplicationInjector.kt | 5 + .../AppLanguageApplicationInjectorProvider.kt | 5 + .../translation/AppLanguageLocaleHandler.kt | 43 +++++ .../translation/AppLanguageResourceHandler.kt | 32 ++++ .../translation/AppLanguageWatcherMixin.kt | 44 +++++ .../oppia/android/app/translation/BUILD.bazel | 86 +++++++++ .../org/oppia/android/app/view/BUILD.bazel | 57 ++++++ .../oppia/android/app/view/ViewComponent.kt | 20 -- .../app/view/ViewComponentBuilderInjector.kt | 7 + .../app/view/ViewComponentBuilderModule.kt | 10 + .../android/app/view/ViewComponentFactory.kt | 8 + .../android/app/view/ViewComponentImpl.kt | 33 ++++ .../app/walkthrough/WalkthroughActivity.kt | 3 +- .../end/WalkthroughFinalFragment.kt | 3 +- .../topiclist/WalkthroughTopicListFragment.kt | 3 +- .../welcome/WalkthroughWelcomeFragment.kt | 3 +- 172 files changed, 1538 insertions(+), 606 deletions(-) rename app/src/main/java/org/oppia/android/app/{application => activity}/ActivityComponentFactory.kt (56%) create mode 100644 app/src/main/java/org/oppia/android/app/activity/ActivityComponentImpl.kt create mode 100644 app/src/main/java/org/oppia/android/app/activity/ActivityIntentFactories.kt create mode 100644 app/src/main/java/org/oppia/android/app/activity/ActivityIntentFactoriesModule.kt create mode 100644 app/src/main/java/org/oppia/android/app/activity/BUILD.bazel create mode 100644 app/src/main/java/org/oppia/android/app/fragment/BUILD.bazel create mode 100644 app/src/main/java/org/oppia/android/app/fragment/FragmentComponentBuilderInjector.kt create mode 100644 app/src/main/java/org/oppia/android/app/fragment/FragmentComponentBuilderModule.kt create mode 100644 app/src/main/java/org/oppia/android/app/fragment/FragmentComponentFactory.kt create mode 100644 app/src/main/java/org/oppia/android/app/fragment/FragmentComponentImpl.kt create mode 100644 app/src/main/java/org/oppia/android/app/shim/BUILD.bazel delete mode 100644 app/src/main/java/org/oppia/android/app/shim/ViewComponentFactory.kt create mode 100644 app/src/main/java/org/oppia/android/app/translation/AppLanguageActivityInjector.kt create mode 100644 app/src/main/java/org/oppia/android/app/translation/AppLanguageApplicationInjector.kt create mode 100644 app/src/main/java/org/oppia/android/app/translation/AppLanguageApplicationInjectorProvider.kt create mode 100644 app/src/main/java/org/oppia/android/app/translation/AppLanguageLocaleHandler.kt create mode 100644 app/src/main/java/org/oppia/android/app/translation/AppLanguageResourceHandler.kt create mode 100644 app/src/main/java/org/oppia/android/app/translation/AppLanguageWatcherMixin.kt create mode 100644 app/src/main/java/org/oppia/android/app/translation/BUILD.bazel create mode 100644 app/src/main/java/org/oppia/android/app/view/BUILD.bazel create mode 100644 app/src/main/java/org/oppia/android/app/view/ViewComponentBuilderInjector.kt create mode 100644 app/src/main/java/org/oppia/android/app/view/ViewComponentBuilderModule.kt create mode 100644 app/src/main/java/org/oppia/android/app/view/ViewComponentFactory.kt create mode 100644 app/src/main/java/org/oppia/android/app/view/ViewComponentImpl.kt diff --git a/app/BUILD.bazel b/app/BUILD.bazel index 02534a05c75..6c6c260d61b 100644 --- a/app/BUILD.bazel +++ b/app/BUILD.bazel @@ -21,6 +21,14 @@ package(default_visibility = ["//utility:__subpackages__"]) exports_files(["src/main/AndroidManifest.xml"]) +# Corresponds to being accessible to all app layer targets. +package_group( + name = "app_visibility", + packages = [ + "//app/...", + ], +) + # Source files for the migrated source files library. The files inside the migrated source files # library are dependencies in app module that have their own libraries. # Place your files here if: @@ -29,6 +37,8 @@ exports_files(["src/main/AndroidManifest.xml"]) # of EXCLUDED_APP_LIB_FILES. # keep sorted MIGRATED_SOURCE_FILES = glob([ + "src/main/java/org/oppia/android/app/activity/*.kt", + "src/main/java/org/oppia/android/app/fragment/*.kt", "src/main/java/org/oppia/android/app/utility/datetime/*.kt", ]) + [ "src/main/java/org/oppia/android/app/viewmodel/ObservableArrayList.kt", @@ -43,10 +53,10 @@ MIGRATED_SOURCE_FILES = glob([ # - It is not considered a listener # - It does not depend on any other file not included in this list +# TODO: see if this can be removed & folded into the main app module since it's not an annotation +# (and maybe inline the annotations lib). # keep sorted ANNOTATIONS = [ - "src/main/java/org/oppia/android/app/activity/ActivityScope.kt", - "src/main/java/org/oppia/android/app/fragment/FragmentScope.kt", "src/main/java/org/oppia/android/app/utility/KeyboardHelper.kt", ] @@ -276,7 +286,6 @@ VIEW_MODELS = [ "src/main/java/org/oppia/android/app/settings/profile/ProfileListViewModel.kt", "src/main/java/org/oppia/android/app/settings/profile/ProfileRenameViewModel.kt", "src/main/java/org/oppia/android/app/settings/profile/ProfileResetPinViewModel.kt", - "src/main/java/org/oppia/android/app/shim/IntentFactoryShim.kt", "src/main/java/org/oppia/android/app/story/ExplorationSelectionListener.kt", "src/main/java/org/oppia/android/app/story/StoryFragmentScroller.kt", "src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryChapterSummaryViewModel.kt", @@ -355,8 +364,6 @@ VIEWS_WITH_RESOURCE_IMPORTS = [ # keep sorted VIEWS = [ - "src/main/java/org/oppia/android/app/application/ApplicationInjector.kt", - "src/main/java/org/oppia/android/app/application/ApplicationInjectorProvider.kt", "src/main/java/org/oppia/android/app/customview/interaction/FractionInputInteractionView.kt", "src/main/java/org/oppia/android/app/customview/interaction/NumericInputInteractionView.kt", "src/main/java/org/oppia/android/app/customview/interaction/TextInputInteractionView.kt", @@ -366,10 +373,7 @@ VIEWS = [ "src/main/java/org/oppia/android/app/player/state/DragDropSortInteractionView.kt", "src/main/java/org/oppia/android/app/player/state/ImageRegionSelectionInteractionView.kt", "src/main/java/org/oppia/android/app/player/state/SelectionInteractionView.kt", - "src/main/java/org/oppia/android/app/shim/ViewBindingShim.kt", - "src/main/java/org/oppia/android/app/shim/ViewComponentFactory.kt", - "src/main/java/org/oppia/android/app/view/ViewComponent.kt", - "src/main/java/org/oppia/android/app/view/ViewScope.kt", + "//app/src/main/java/org/oppia/android/app/view:ViewComponentImpl.kt", ] + [ "update_" + views_with_resource_imports[0:-3] for views_with_resource_imports in VIEWS_WITH_RESOURCE_IMPORTS @@ -517,6 +521,9 @@ android_library( exports_manifest = True, manifest = "src/main/DatabindingResourcesManifest.xml", resource_files = glob(DATABINDING_LAYOUTS), + visibility = [ + "//app/src/main/java/org/oppia/android/app/shim:__pkg__", + ], deps = [ ":annotations", ":binding_adapters", @@ -571,6 +578,9 @@ kt_android_library( ":resources", ":snap_helper", ":view_models", + "//app/src/main/java/org/oppia/android/app/shim:view_binding_shim", + "//app/src/main/java/org/oppia/android/app/view:view_component_factory", + "//app/src/main/java/org/oppia/android/app/view:view_scope", "//third_party:androidx_appcompat_appcompat", "//third_party:androidx_core_core-ktx", "//third_party:androidx_databinding_databinding-common", @@ -590,6 +600,8 @@ kt_android_library( custom_package = "org.oppia.android.app", deps = [ ":dagger", + "//app/src/main/java/org/oppia/android/app/activity:activity_scope", + "//app/src/main/java/org/oppia/android/app/fragment:fragment_scope", ], ) @@ -600,11 +612,15 @@ kt_android_library( custom_package = "org.oppia.android.app.view.models", enable_data_binding = True, manifest = "src/main/ViewModelsManifest.xml", + visibility = [ + "//app/src/main/java/org/oppia/android/app/shim:__pkg__", + ], deps = [ ":annotations", ":dagger", ":listeners", ":resources", + "//app/src/main/java/org/oppia/android/app/shim:intent_factory_shim", "//app/src/main/java/org/oppia/android/app/viewmodel:observable_array_list", "//app/src/main/java/org/oppia/android/app/viewmodel:observable_view_model", "//app/src/main/java/org/oppia/android/app/viewmodel:view_model_provider", @@ -657,7 +673,15 @@ android_library( # Library containing all activity, fragment, and view-based UI flows in the app. kt_android_library( name = "app", - srcs = APP_FILES, + srcs = APP_FILES + [ + "//app/src/main/java/org/oppia/android/app/activity:ActivityComponentImpl.kt", + "//app/src/main/java/org/oppia/android/app/activity:ActivityIntentFactoriesModule.kt", + "//app/src/main/java/org/oppia/android/app/activity:ActivityModule.kt", + "//app/src/main/java/org/oppia/android/app/fragment:FragmentComponentBuilderModule.kt", + "//app/src/main/java/org/oppia/android/app/fragment:FragmentComponentImpl.kt", + "//app/src/main/java/org/oppia/android/app/fragment:FragmentModule.kt", + "//app/src/main/java/org/oppia/android/app/view:ViewComponentBuilderModule.kt", + ], custom_package = "org.oppia.android.app.ui", enable_data_binding = 1, manifest = "src/main/AppAndroidManifest.xml", @@ -669,6 +693,11 @@ kt_android_library( ":resources", ":view_models", ":views", + "//app/src/main/java/org/oppia/android/app/activity:activity_intent_factories_shim", + "//app/src/main/java/org/oppia/android/app/activity:injectable_app_compat_activity", + "//app/src/main/java/org/oppia/android/app/fragment:injectable_dialog_fragment", + "//app/src/main/java/org/oppia/android/app/fragment:injectable_fragment", + "//app/src/main/java/org/oppia/android/app/shim:prod_modules", "//data/src/main/java/org/oppia/android/data/backends/gae:network_config_prod_module", "//data/src/main/java/org/oppia/android/data/backends/gae:prod_module", "//model:arguments_java_proto_lite", diff --git a/app/src/main/java/org/oppia/android/app/activity/ActivityComponent.kt b/app/src/main/java/org/oppia/android/app/activity/ActivityComponent.kt index d88c91efee0..082642ca587 100644 --- a/app/src/main/java/org/oppia/android/app/activity/ActivityComponent.kt +++ b/app/src/main/java/org/oppia/android/app/activity/ActivityComponent.kt @@ -1,171 +1,5 @@ package org.oppia.android.app.activity -import androidx.appcompat.app.AppCompatActivity -import dagger.BindsInstance -import dagger.Subcomponent -import org.oppia.android.app.administratorcontrols.AdministratorControlsActivity -import org.oppia.android.app.administratorcontrols.appversion.AppVersionActivity -import org.oppia.android.app.completedstorylist.CompletedStoryListActivity -import org.oppia.android.app.devoptions.DeveloperOptionsActivity -import org.oppia.android.app.devoptions.forcenetworktype.ForceNetworkTypeActivity -import org.oppia.android.app.devoptions.forcenetworktype.testing.ForceNetworkTypeTestActivity -import org.oppia.android.app.devoptions.markchapterscompleted.MarkChaptersCompletedActivity -import org.oppia.android.app.devoptions.markchapterscompleted.testing.MarkChaptersCompletedTestActivity -import org.oppia.android.app.devoptions.markstoriescompleted.MarkStoriesCompletedActivity -import org.oppia.android.app.devoptions.markstoriescompleted.testing.MarkStoriesCompletedTestActivity -import org.oppia.android.app.devoptions.marktopicscompleted.MarkTopicsCompletedActivity -import org.oppia.android.app.devoptions.marktopicscompleted.testing.MarkTopicsCompletedTestActivity -import org.oppia.android.app.devoptions.testing.DeveloperOptionsTestActivity -import org.oppia.android.app.devoptions.vieweventlogs.ViewEventLogsActivity -import org.oppia.android.app.devoptions.vieweventlogs.testing.ViewEventLogsTestActivity -import org.oppia.android.app.fragment.FragmentComponent -import org.oppia.android.app.help.HelpActivity -import org.oppia.android.app.help.faq.FAQListActivity -import org.oppia.android.app.help.faq.faqsingle.FAQSingleActivity -import org.oppia.android.app.help.thirdparty.LicenseListActivity -import org.oppia.android.app.help.thirdparty.LicenseTextViewerActivity -import org.oppia.android.app.help.thirdparty.ThirdPartyDependencyListActivity -import org.oppia.android.app.home.HomeActivity -import org.oppia.android.app.home.recentlyplayed.RecentlyPlayedActivity -import org.oppia.android.app.mydownloads.MyDownloadsActivity -import org.oppia.android.app.onboarding.OnboardingActivity -import org.oppia.android.app.ongoingtopiclist.OngoingTopicListActivity -import org.oppia.android.app.options.AppLanguageActivity -import org.oppia.android.app.options.AudioLanguageActivity -import org.oppia.android.app.options.OptionsActivity -import org.oppia.android.app.options.ReadingTextSizeActivity -import org.oppia.android.app.player.exploration.ExplorationActivity -import org.oppia.android.app.player.state.testing.StateFragmentTestActivity -import org.oppia.android.app.profile.AddProfileActivity -import org.oppia.android.app.profile.AdminAuthActivity -import org.oppia.android.app.profile.AdminPinActivity -import org.oppia.android.app.profile.PinPasswordActivity -import org.oppia.android.app.profile.ProfileChooserActivity -import org.oppia.android.app.profileprogress.ProfilePictureActivity -import org.oppia.android.app.profileprogress.ProfileProgressActivity -import org.oppia.android.app.resumelesson.ResumeLessonActivity -import org.oppia.android.app.settings.profile.ProfileEditActivity -import org.oppia.android.app.settings.profile.ProfileListActivity -import org.oppia.android.app.settings.profile.ProfileRenameActivity -import org.oppia.android.app.settings.profile.ProfileResetPinActivity -import org.oppia.android.app.splash.SplashActivity -import org.oppia.android.app.story.StoryActivity -import org.oppia.android.app.testing.AudioFragmentTestActivity -import org.oppia.android.app.testing.ConceptCardFragmentTestActivity -import org.oppia.android.app.testing.DragDropTestActivity -import org.oppia.android.app.testing.ExplorationInjectionActivity -import org.oppia.android.app.testing.ExplorationTestActivity -import org.oppia.android.app.testing.HomeFragmentTestActivity -import org.oppia.android.app.testing.HomeTestActivity -import org.oppia.android.app.testing.HtmlParserTestActivity -import org.oppia.android.app.testing.ImageRegionSelectionTestActivity -import org.oppia.android.app.testing.MarginBindingAdaptersTestActivity -import org.oppia.android.app.testing.NavigationDrawerTestActivity -import org.oppia.android.app.testing.ProfileChooserFragmentTestActivity -import org.oppia.android.app.testing.SplashTestActivity -import org.oppia.android.app.testing.StateAssemblerMarginBindingAdaptersTestActivity -import org.oppia.android.app.testing.StateAssemblerPaddingBindingAdaptersTestActivity -import org.oppia.android.app.testing.TestFontScaleConfigurationUtilActivity -import org.oppia.android.app.testing.TopicRevisionTestActivity -import org.oppia.android.app.testing.TopicTestActivity -import org.oppia.android.app.testing.TopicTestActivityForStory -import org.oppia.android.app.testing.ViewBindingAdaptersTestActivity -import org.oppia.android.app.topic.TopicActivity -import org.oppia.android.app.topic.questionplayer.QuestionPlayerActivity -import org.oppia.android.app.topic.revisioncard.RevisionCardActivity -import org.oppia.android.app.walkthrough.WalkthroughActivity -import javax.inject.Provider +import org.oppia.android.app.translation.AppLanguageActivityInjector -/** Root subcomponent for all activities. */ -@Subcomponent(modules = [ActivityModule::class]) -@ActivityScope -interface ActivityComponent { - @Subcomponent.Builder - interface Builder { - @BindsInstance - fun setActivity(appCompatActivity: AppCompatActivity): Builder - - fun build(): ActivityComponent - } - - fun getFragmentComponentBuilderProvider(): Provider - - fun inject(addProfileActivity: AddProfileActivity) - fun inject(adminAuthActivity: AdminAuthActivity) - fun inject(administratorControlsActivity: AdministratorControlsActivity) - fun inject(adminPinActivity: AdminPinActivity) - fun inject(appLanguageActivity: AppLanguageActivity) - fun inject(appVersionActivity: AppVersionActivity) - fun inject(audioFragmentTestActivity: AudioFragmentTestActivity) - fun inject(audioLanguageActivity: AudioLanguageActivity) - fun inject(completedStoryListActivity: CompletedStoryListActivity) - fun inject(conceptCardFragmentTestActivity: ConceptCardFragmentTestActivity) - fun inject(developerOptionsActivity: DeveloperOptionsActivity) - fun inject(developerOptionsTestActivity: DeveloperOptionsTestActivity) - fun inject(dragDropTestActivity: DragDropTestActivity) - fun inject(explorationActivity: ExplorationActivity) - fun inject(explorationInjectionActivity: ExplorationInjectionActivity) - fun inject(explorationTestActivity: ExplorationTestActivity) - fun inject(faqListActivity: FAQListActivity) - fun inject(faqSingleActivity: FAQSingleActivity) - fun inject(forceNetworkTypeActivity: ForceNetworkTypeActivity) - fun inject(forceNetworkTypeTestActivity: ForceNetworkTypeTestActivity) - fun inject(helpActivity: HelpActivity) - fun inject(homeActivity: HomeActivity) - fun inject(homeFragmentTestActivity: HomeFragmentTestActivity) - fun inject(homeTestActivity: HomeTestActivity) - fun inject(htmlParserTestActivity: HtmlParserTestActivity) - fun inject(imageRegionSelectionTestActivity: ImageRegionSelectionTestActivity) - fun inject(licenseListActivity: LicenseListActivity) - fun inject(licenseTextViewerActivity: LicenseTextViewerActivity) - fun inject(markChaptersCompletedActivity: MarkChaptersCompletedActivity) - fun inject(markChaptersCompletedTestActivity: MarkChaptersCompletedTestActivity) - fun inject(markStoriesCompletedActivity: MarkStoriesCompletedActivity) - fun inject(markStoriesCompletedTestActivity: MarkStoriesCompletedTestActivity) - fun inject(markTopicsCompletedActivity: MarkTopicsCompletedActivity) - fun inject(marginBindableAdaptersTestActivity: MarginBindingAdaptersTestActivity) - fun inject(markTopicsCompletedTestActivity: MarkTopicsCompletedTestActivity) - fun inject(myDownloadsActivity: MyDownloadsActivity) - fun inject(navigationDrawerTestActivity: NavigationDrawerTestActivity) - fun inject(onboardingActivity: OnboardingActivity) - fun inject(ongoingTopicListActivity: OngoingTopicListActivity) - fun inject(optionActivity: OptionsActivity) - fun inject(pinPasswordActivity: PinPasswordActivity) - fun inject(profileChooserActivity: ProfileChooserActivity) - fun inject(profileChooserFragmentTestActivity: ProfileChooserFragmentTestActivity) - fun inject(profileEditActivity: ProfileEditActivity) - fun inject(profileListActivity: ProfileListActivity) - fun inject(profilePictureActivity: ProfilePictureActivity) - fun inject(profileProgressActivity: ProfileProgressActivity) - fun inject(profileRenameActivity: ProfileRenameActivity) - fun inject(profileResetPinActivity: ProfileResetPinActivity) - fun inject(questionPlayerActivity: QuestionPlayerActivity) - fun inject(readingTextSizeActivity: ReadingTextSizeActivity) - fun inject(recentlyPlayedActivity: RecentlyPlayedActivity) - fun inject(resumeLessonActivity: ResumeLessonActivity) - fun inject(revisionCardActivity: RevisionCardActivity) - fun inject(splashActivity: SplashActivity) - fun inject(splashTestActivity: SplashTestActivity) - fun inject( - stateAssemblerMarginBindingAdaptersTestActivity: - StateAssemblerMarginBindingAdaptersTestActivity - ) - - fun inject( - stateAssemblerPaddingBindingAdaptersTestActivity: - StateAssemblerPaddingBindingAdaptersTestActivity - ) - - fun inject(stateFragmentTestActivity: StateFragmentTestActivity) - fun inject(storyActivity: StoryActivity) - fun inject(testFontScaleConfigurationUtilActivity: TestFontScaleConfigurationUtilActivity) - fun inject(thirdPartyDependencyListActivity: ThirdPartyDependencyListActivity) - fun inject(topicActivity: TopicActivity) - fun inject(topicRevisionTestActivity: TopicRevisionTestActivity) - fun inject(topicTestActivity: TopicTestActivity) - fun inject(topicTestActivityForStory: TopicTestActivityForStory) - fun inject(viewBindingAdaptersTestActivity: ViewBindingAdaptersTestActivity) - fun inject(viewEventLogsActivity: ViewEventLogsActivity) - fun inject(viewEventLogsTestActivity: ViewEventLogsTestActivity) - fun inject(walkthroughActivity: WalkthroughActivity) -} +interface ActivityComponent: AppLanguageActivityInjector diff --git a/app/src/main/java/org/oppia/android/app/application/ActivityComponentFactory.kt b/app/src/main/java/org/oppia/android/app/activity/ActivityComponentFactory.kt similarity index 56% rename from app/src/main/java/org/oppia/android/app/application/ActivityComponentFactory.kt rename to app/src/main/java/org/oppia/android/app/activity/ActivityComponentFactory.kt index 5a903499cf4..5b8b9d1f8f2 100644 --- a/app/src/main/java/org/oppia/android/app/application/ActivityComponentFactory.kt +++ b/app/src/main/java/org/oppia/android/app/activity/ActivityComponentFactory.kt @@ -1,11 +1,10 @@ -package org.oppia.android.app.application +package org.oppia.android.app.activity import androidx.appcompat.app.AppCompatActivity -import org.oppia.android.app.activity.ActivityComponent interface ActivityComponentFactory { /** - * Returns a new [ActivityComponent] for the specified activity. This should only be used by + * Returns a new [ActivityComponentImpl] for the specified activity. This should only be used by * [org.oppia.android.app.activity.InjectableAppCompatActivity]. */ fun createActivityComponent(activity: AppCompatActivity): ActivityComponent diff --git a/app/src/main/java/org/oppia/android/app/activity/ActivityComponentImpl.kt b/app/src/main/java/org/oppia/android/app/activity/ActivityComponentImpl.kt new file mode 100644 index 00000000000..05316f3efd8 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/activity/ActivityComponentImpl.kt @@ -0,0 +1,175 @@ +package org.oppia.android.app.activity + +import androidx.appcompat.app.AppCompatActivity +import dagger.BindsInstance +import dagger.Subcomponent +import org.oppia.android.app.administratorcontrols.AdministratorControlsActivity +import org.oppia.android.app.administratorcontrols.appversion.AppVersionActivity +import org.oppia.android.app.completedstorylist.CompletedStoryListActivity +import org.oppia.android.app.devoptions.DeveloperOptionsActivity +import org.oppia.android.app.devoptions.forcenetworktype.ForceNetworkTypeActivity +import org.oppia.android.app.devoptions.forcenetworktype.testing.ForceNetworkTypeTestActivity +import org.oppia.android.app.devoptions.markchapterscompleted.MarkChaptersCompletedActivity +import org.oppia.android.app.devoptions.markchapterscompleted.testing.MarkChaptersCompletedTestActivity +import org.oppia.android.app.devoptions.markstoriescompleted.MarkStoriesCompletedActivity +import org.oppia.android.app.devoptions.markstoriescompleted.testing.MarkStoriesCompletedTestActivity +import org.oppia.android.app.devoptions.marktopicscompleted.MarkTopicsCompletedActivity +import org.oppia.android.app.devoptions.marktopicscompleted.testing.MarkTopicsCompletedTestActivity +import org.oppia.android.app.devoptions.testing.DeveloperOptionsTestActivity +import org.oppia.android.app.devoptions.vieweventlogs.ViewEventLogsActivity +import org.oppia.android.app.devoptions.vieweventlogs.testing.ViewEventLogsTestActivity +import org.oppia.android.app.fragment.FragmentComponentImpl +import org.oppia.android.app.help.HelpActivity +import org.oppia.android.app.help.faq.FAQListActivity +import org.oppia.android.app.help.faq.faqsingle.FAQSingleActivity +import org.oppia.android.app.help.thirdparty.LicenseListActivity +import org.oppia.android.app.help.thirdparty.LicenseTextViewerActivity +import org.oppia.android.app.help.thirdparty.ThirdPartyDependencyListActivity +import org.oppia.android.app.home.HomeActivity +import org.oppia.android.app.home.recentlyplayed.RecentlyPlayedActivity +import org.oppia.android.app.mydownloads.MyDownloadsActivity +import org.oppia.android.app.onboarding.OnboardingActivity +import org.oppia.android.app.ongoingtopiclist.OngoingTopicListActivity +import org.oppia.android.app.options.AppLanguageActivity +import org.oppia.android.app.options.AudioLanguageActivity +import org.oppia.android.app.options.OptionsActivity +import org.oppia.android.app.options.ReadingTextSizeActivity +import org.oppia.android.app.player.exploration.ExplorationActivity +import org.oppia.android.app.player.state.testing.StateFragmentTestActivity +import org.oppia.android.app.profile.AddProfileActivity +import org.oppia.android.app.profile.AdminAuthActivity +import org.oppia.android.app.profile.AdminPinActivity +import org.oppia.android.app.profile.PinPasswordActivity +import org.oppia.android.app.profile.ProfileChooserActivity +import org.oppia.android.app.profileprogress.ProfilePictureActivity +import org.oppia.android.app.profileprogress.ProfileProgressActivity +import org.oppia.android.app.resumelesson.ResumeLessonActivity +import org.oppia.android.app.settings.profile.ProfileEditActivity +import org.oppia.android.app.settings.profile.ProfileListActivity +import org.oppia.android.app.settings.profile.ProfileRenameActivity +import org.oppia.android.app.settings.profile.ProfileResetPinActivity +import org.oppia.android.app.splash.SplashActivity +import org.oppia.android.app.story.StoryActivity +import org.oppia.android.app.testing.AudioFragmentTestActivity +import org.oppia.android.app.testing.ConceptCardFragmentTestActivity +import org.oppia.android.app.testing.DragDropTestActivity +import org.oppia.android.app.testing.ExplorationInjectionActivity +import org.oppia.android.app.testing.ExplorationTestActivity +import org.oppia.android.app.testing.HomeFragmentTestActivity +import org.oppia.android.app.testing.HomeTestActivity +import org.oppia.android.app.testing.HtmlParserTestActivity +import org.oppia.android.app.testing.ImageRegionSelectionTestActivity +import org.oppia.android.app.testing.MarginBindingAdaptersTestActivity +import org.oppia.android.app.testing.NavigationDrawerTestActivity +import org.oppia.android.app.testing.ProfileChooserFragmentTestActivity +import org.oppia.android.app.testing.SplashTestActivity +import org.oppia.android.app.testing.StateAssemblerMarginBindingAdaptersTestActivity +import org.oppia.android.app.testing.StateAssemblerPaddingBindingAdaptersTestActivity +import org.oppia.android.app.testing.TestFontScaleConfigurationUtilActivity +import org.oppia.android.app.testing.TopicRevisionTestActivity +import org.oppia.android.app.testing.TopicTestActivity +import org.oppia.android.app.testing.TopicTestActivityForStory +import org.oppia.android.app.testing.ViewBindingAdaptersTestActivity +import org.oppia.android.app.topic.TopicActivity +import org.oppia.android.app.topic.questionplayer.QuestionPlayerActivity +import org.oppia.android.app.topic.revisioncard.RevisionCardActivity +import org.oppia.android.app.walkthrough.WalkthroughActivity +import javax.inject.Provider +import org.oppia.android.app.fragment.FragmentComponentBuilderInjector +import org.oppia.android.app.fragment.FragmentComponentBuilderModule + +// TODO(#59): Restrict access to this implementation by introducing injectors in each activity. + +/** Root subcomponent for all activities. */ +@Subcomponent(modules = [ + ActivityModule::class, FragmentComponentBuilderModule::class, ActivityIntentFactoriesModule::class +]) +@ActivityScope +interface ActivityComponentImpl: ActivityComponent, FragmentComponentBuilderInjector { + @Subcomponent.Builder + interface Builder { + @BindsInstance + fun setActivity(appCompatActivity: AppCompatActivity): Builder + + fun build(): ActivityComponentImpl + } + + fun inject(addProfileActivity: AddProfileActivity) + fun inject(adminAuthActivity: AdminAuthActivity) + fun inject(administratorControlsActivity: AdministratorControlsActivity) + fun inject(adminPinActivity: AdminPinActivity) + fun inject(appLanguageActivity: AppLanguageActivity) + fun inject(appVersionActivity: AppVersionActivity) + fun inject(audioFragmentTestActivity: AudioFragmentTestActivity) + fun inject(audioLanguageActivity: AudioLanguageActivity) + fun inject(completedStoryListActivity: CompletedStoryListActivity) + fun inject(conceptCardFragmentTestActivity: ConceptCardFragmentTestActivity) + fun inject(developerOptionsActivity: DeveloperOptionsActivity) + fun inject(developerOptionsTestActivity: DeveloperOptionsTestActivity) + fun inject(dragDropTestActivity: DragDropTestActivity) + fun inject(explorationActivity: ExplorationActivity) + fun inject(explorationInjectionActivity: ExplorationInjectionActivity) + fun inject(explorationTestActivity: ExplorationTestActivity) + fun inject(faqListActivity: FAQListActivity) + fun inject(faqSingleActivity: FAQSingleActivity) + fun inject(forceNetworkTypeActivity: ForceNetworkTypeActivity) + fun inject(forceNetworkTypeTestActivity: ForceNetworkTypeTestActivity) + fun inject(helpActivity: HelpActivity) + fun inject(homeActivity: HomeActivity) + fun inject(homeFragmentTestActivity: HomeFragmentTestActivity) + fun inject(homeTestActivity: HomeTestActivity) + fun inject(htmlParserTestActivity: HtmlParserTestActivity) + fun inject(imageRegionSelectionTestActivity: ImageRegionSelectionTestActivity) + fun inject(licenseListActivity: LicenseListActivity) + fun inject(licenseTextViewerActivity: LicenseTextViewerActivity) + fun inject(markChaptersCompletedActivity: MarkChaptersCompletedActivity) + fun inject(markChaptersCompletedTestActivity: MarkChaptersCompletedTestActivity) + fun inject(markStoriesCompletedActivity: MarkStoriesCompletedActivity) + fun inject(markStoriesCompletedTestActivity: MarkStoriesCompletedTestActivity) + fun inject(markTopicsCompletedActivity: MarkTopicsCompletedActivity) + fun inject(marginBindableAdaptersTestActivity: MarginBindingAdaptersTestActivity) + fun inject(markTopicsCompletedTestActivity: MarkTopicsCompletedTestActivity) + fun inject(myDownloadsActivity: MyDownloadsActivity) + fun inject(navigationDrawerTestActivity: NavigationDrawerTestActivity) + fun inject(onboardingActivity: OnboardingActivity) + fun inject(ongoingTopicListActivity: OngoingTopicListActivity) + fun inject(optionActivity: OptionsActivity) + fun inject(pinPasswordActivity: PinPasswordActivity) + fun inject(profileChooserActivity: ProfileChooserActivity) + fun inject(profileChooserFragmentTestActivity: ProfileChooserFragmentTestActivity) + fun inject(profileEditActivity: ProfileEditActivity) + fun inject(profileListActivity: ProfileListActivity) + fun inject(profilePictureActivity: ProfilePictureActivity) + fun inject(profileProgressActivity: ProfileProgressActivity) + fun inject(profileRenameActivity: ProfileRenameActivity) + fun inject(profileResetPinActivity: ProfileResetPinActivity) + fun inject(questionPlayerActivity: QuestionPlayerActivity) + fun inject(readingTextSizeActivity: ReadingTextSizeActivity) + fun inject(recentlyPlayedActivity: RecentlyPlayedActivity) + fun inject(resumeLessonActivity: ResumeLessonActivity) + fun inject(revisionCardActivity: RevisionCardActivity) + fun inject(splashActivity: SplashActivity) + fun inject(splashTestActivity: SplashTestActivity) + fun inject( + stateAssemblerMarginBindingAdaptersTestActivity: + StateAssemblerMarginBindingAdaptersTestActivity + ) + + fun inject( + stateAssemblerPaddingBindingAdaptersTestActivity: + StateAssemblerPaddingBindingAdaptersTestActivity + ) + + fun inject(stateFragmentTestActivity: StateFragmentTestActivity) + fun inject(storyActivity: StoryActivity) + fun inject(testFontScaleConfigurationUtilActivity: TestFontScaleConfigurationUtilActivity) + fun inject(thirdPartyDependencyListActivity: ThirdPartyDependencyListActivity) + fun inject(topicActivity: TopicActivity) + fun inject(topicRevisionTestActivity: TopicRevisionTestActivity) + fun inject(topicTestActivity: TopicTestActivity) + fun inject(topicTestActivityForStory: TopicTestActivityForStory) + fun inject(viewBindingAdaptersTestActivity: ViewBindingAdaptersTestActivity) + fun inject(viewEventLogsActivity: ViewEventLogsActivity) + fun inject(viewEventLogsTestActivity: ViewEventLogsTestActivity) + fun inject(walkthroughActivity: WalkthroughActivity) +} diff --git a/app/src/main/java/org/oppia/android/app/activity/ActivityIntentFactories.kt b/app/src/main/java/org/oppia/android/app/activity/ActivityIntentFactories.kt new file mode 100644 index 00000000000..a92a9106f30 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/activity/ActivityIntentFactories.kt @@ -0,0 +1,20 @@ +package org.oppia.android.app.activity + +import android.content.Intent +import org.oppia.android.app.model.ProfileId + +// TODO(#59): Split this up into separate interfaces & move them to the corresponding activities. +// This pattern will probably need to be used for all activities (& maybe fragments) as part of app +// layer Bazel modularization. + +// TODO: document that each of these must be injected within an activity context. +interface ActivityIntentFactories { + interface TopicActivityIntentFactory { + fun createIntent(profileId: ProfileId, topicId: String): Intent + fun createIntent(profileId: ProfileId, topicId: String, storyId: String): Intent + } + + interface RecentlyPlayedActivityIntentFactory { + fun createIntent(profileId: ProfileId): Intent + } +} diff --git a/app/src/main/java/org/oppia/android/app/activity/ActivityIntentFactoriesModule.kt b/app/src/main/java/org/oppia/android/app/activity/ActivityIntentFactoriesModule.kt new file mode 100644 index 00000000000..82338d03696 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/activity/ActivityIntentFactoriesModule.kt @@ -0,0 +1,21 @@ +package org.oppia.android.app.activity + +import dagger.Binds +import dagger.Module +import org.oppia.android.app.home.recentlyplayed.RecentlyPlayedActivity +import org.oppia.android.app.topic.TopicActivity + +// TODO(#59): Split this to be per-activity. + +@Module +interface ActivityIntentFactoriesModule { + @Binds + fun provideTopicActivityIntentFactory( + impl: TopicActivity.TopicActivityIntentFactoryImpl + ): ActivityIntentFactories.TopicActivityIntentFactory + + @Binds + fun provideRecentlyPlayedActivityIntentFactory( + impl: RecentlyPlayedActivity.RecentlyPlayedActivityIntentFactoryImpl + ): ActivityIntentFactories.RecentlyPlayedActivityIntentFactory +} diff --git a/app/src/main/java/org/oppia/android/app/activity/ActivityModule.kt b/app/src/main/java/org/oppia/android/app/activity/ActivityModule.kt index b5121ad7415..c2fa87ef233 100644 --- a/app/src/main/java/org/oppia/android/app/activity/ActivityModule.kt +++ b/app/src/main/java/org/oppia/android/app/activity/ActivityModule.kt @@ -1,8 +1,8 @@ package org.oppia.android.app.activity import dagger.Module -import org.oppia.android.app.fragment.FragmentComponent +import org.oppia.android.app.fragment.FragmentComponentImpl /** Root activity module. */ -@Module(subcomponents = [FragmentComponent::class]) +@Module(subcomponents = [FragmentComponentImpl::class]) class ActivityModule diff --git a/app/src/main/java/org/oppia/android/app/activity/BUILD.bazel b/app/src/main/java/org/oppia/android/app/activity/BUILD.bazel new file mode 100644 index 00000000000..ad899603c26 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/activity/BUILD.bazel @@ -0,0 +1,74 @@ +""" +Constructs for setting up activities for injection in the Dagger graph. +""" + +load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_android_library") + +# TODO(#59): Define these exported files as separate libraries from top-level targets. +exports_files([ + "ActivityComponentImpl.kt", + "ActivityIntentFactoriesModule.kt", + "ActivityModule.kt", +]) + +kt_android_library( + name = "activity_scope", + srcs = [ + "ActivityScope.kt", + ], + visibility = ["//app:app_visibility"], + deps = [ + "//third_party:javax_inject_javax_inject", + ], +) + +kt_android_library( + name = "injectable_app_compat_activity", + srcs = [ + "InjectableAppCompatActivity.kt", + ], + visibility = ["//app:app_visibility"], + deps = [ + ":activity_component", + ":activity_component_factory", + "//app/src/main/java/org/oppia/android/app/fragment:fragment_component", + "//app/src/main/java/org/oppia/android/app/fragment:fragment_component_builder_injector", + "//app/src/main/java/org/oppia/android/app/fragment:fragment_component_factory", + "//app/src/main/java/org/oppia/android/app/translation:app_language_activity_injector", + "//app/src/main/java/org/oppia/android/app/translation:app_language_application_injector", + "//app/src/main/java/org/oppia/android/app/translation:app_language_application_injector_provider", + ], +) + +kt_android_library( + name = "activity_component", + srcs = [ + "ActivityComponent.kt", + ], + visibility = ["//app:app_visibility"], + deps = [ + "//app/src/main/java/org/oppia/android/app/translation:app_language_activity_injector", + ], +) + +kt_android_library( + name = "activity_component_factory", + srcs = [ + "ActivityComponentFactory.kt", + ], + deps = [ + ":activity_component", + "//third_party:androidx_appcompat_appcompat", + ], +) + +kt_android_library( + name = "activity_intent_factories_shim", + srcs = [ + "ActivityIntentFactories.kt", + ], + visibility = ["//app:app_visibility"], + deps = [ + "//model:profile_java_proto_lite", + ], +) diff --git a/app/src/main/java/org/oppia/android/app/activity/InjectableAppCompatActivity.kt b/app/src/main/java/org/oppia/android/app/activity/InjectableAppCompatActivity.kt index c4214c8c187..94eff37c7a8 100644 --- a/app/src/main/java/org/oppia/android/app/activity/InjectableAppCompatActivity.kt +++ b/app/src/main/java/org/oppia/android/app/activity/InjectableAppCompatActivity.kt @@ -1,44 +1,82 @@ package org.oppia.android.app.activity +import android.content.Context +import android.content.res.Configuration import android.os.Bundle import android.os.PersistableBundle import androidx.appcompat.app.AppCompatActivity import androidx.fragment.app.Fragment -import org.oppia.android.app.application.ActivityComponentFactory import org.oppia.android.app.fragment.FragmentComponent +import org.oppia.android.app.fragment.FragmentComponentBuilderInjector +import org.oppia.android.app.fragment.FragmentComponentFactory +import org.oppia.android.app.translation.AppLanguageActivityInjector +import org.oppia.android.app.translation.AppLanguageApplicationInjectorProvider /** - * An [AppCompatActivity] that facilitates field injection to child activities and constituent fragments that extend - * [org.oppia.android.app.fragment.InjectableFragment]. + * An [AppCompatActivity] that facilitates field injection to child activities and constituent + * fragments that extend [org.oppia.android.app.fragment.InjectableFragment]. */ -abstract class InjectableAppCompatActivity : AppCompatActivity() { +abstract class InjectableAppCompatActivity : AppCompatActivity(), FragmentComponentFactory { /** - * The [ActivityComponent] corresponding to this activity. This cannot be used before [onCreate] is called, and can be - * used to inject lateinit fields in child activities during activity creation (which is recommended to be done in an - * override of [onCreate]). + * The [ActivityComponent] corresponding to this activity. This cannot be used before + * [attachBaseContext] is called, and can be used to inject lateinit fields in child activities + * during activity creation (which is recommended to be done in an override of [onCreate]). */ lateinit var activityComponent: ActivityComponent + override fun attachBaseContext(newBase: Context?) { + val applicationContext = checkNotNull(newBase?.applicationContext) { + "Expected attached Context to have an application context defined." + } + initializeActivityComponent(applicationContext) + + // Given how DataProviders work (i.e. by resolving data races using eventual consistency), it's + // possible to miss some updates in really unlikely situations. No additional work will be done + // to prevent these data races unless they're actually hit by users. It shouldn't, in practice, + // be possible since it requires changing the system language between activity transitions, and + // in most cases that should result in an activity recreation by the mixin, anyway. + val appLanguageAppInjectorProvider = + applicationContext as AppLanguageApplicationInjectorProvider + val appLanguageAppInjector = appLanguageAppInjectorProvider.getAppLanguageApplicationInjector() + val appLanguageActivityInjector = activityComponent as AppLanguageActivityInjector + val appLanguageLocaleHandler = appLanguageAppInjector.getAppLanguageHandler() + val appLanguageWatcherMixin = appLanguageActivityInjector.getAppLanguageWatcherMixin() + appLanguageWatcherMixin.initialize() + val newConfiguration = Configuration(newBase?.resources?.configuration) + appLanguageLocaleHandler.initializeLocaleForActivity(newConfiguration) + + // Notify the potential locale change at the end since it may result in another activity + // recreation. + appLanguageLocaleHandler.notifyPotentialLocaleChange() + + super.attachBaseContext(newBase?.createConfigurationContext(newConfiguration)) + } + override fun onCreate(savedInstanceState: Bundle?) { - // Note that the activity component must be initialized before onCreate() since it's possible for onCreate() to - // synchronously attach fragments (e.g. during a configuration change), which requires the activity component for - // createFragmentComponent(). This means downstream dependencies should not perform any major operations to the - // injected activity since it's not yet fully created. - initializeActivityComponent() + ensureLayoutDirection() super.onCreate(savedInstanceState) } override fun onCreate(savedInstanceState: Bundle?, persistentState: PersistableBundle?) { + ensureLayoutDirection() super.onCreate(savedInstanceState, persistentState) - initializeActivityComponent() } - private fun initializeActivityComponent() { - activityComponent = (application as ActivityComponentFactory).createActivityComponent(this) + override fun createFragmentComponent(fragment: Fragment): FragmentComponent { + val builderInjector = activityComponent as FragmentComponentBuilderInjector + return builderInjector.getFragmentComponentBuilderProvider().get().setFragment(fragment).build() + } + + private fun initializeActivityComponent(applicationContext: Context) { + val componentFactory = applicationContext as ActivityComponentFactory + activityComponent = componentFactory.createActivityComponent(this) } - fun createFragmentComponent(fragment: Fragment): FragmentComponent { - return activityComponent.getFragmentComponentBuilderProvider().get().setFragment(fragment) - .build() + private fun ensureLayoutDirection() { + // Ensure the root decor view has the correct layout direct setup per the base context. In some + // cases, Android will let the app recreate the activity & properly update the layout direction + // of the base context's configuration based on the selected Locale but not update the decor + // view. This ensures that views use the correct layout in these situations. + window.decorView.layoutDirection = baseContext.resources.configuration.layoutDirection } } diff --git a/app/src/main/java/org/oppia/android/app/administratorcontrols/AdministratorControlsActivity.kt b/app/src/main/java/org/oppia/android/app/administratorcontrols/AdministratorControlsActivity.kt index 52a0eaf9e87..8a0fb73f5f2 100644 --- a/app/src/main/java/org/oppia/android/app/administratorcontrols/AdministratorControlsActivity.kt +++ b/app/src/main/java/org/oppia/android/app/administratorcontrols/AdministratorControlsActivity.kt @@ -9,6 +9,7 @@ import org.oppia.android.app.administratorcontrols.appversion.AppVersionActivity import org.oppia.android.app.drawer.NAVIGATION_PROFILE_ID_ARGUMENT_KEY import org.oppia.android.app.settings.profile.ProfileListActivity import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl const val SELECTED_CONTROLS_TITLE_SAVED_KEY = "AdministratorControlsActivity.selected_controls_title" @@ -30,7 +31,7 @@ class AdministratorControlsActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) val extraControlsTitle = savedInstanceState?.getString(SELECTED_CONTROLS_TITLE_SAVED_KEY) lastLoadedFragment = if (savedInstanceState != null) { savedInstanceState.get(LAST_LOADED_FRAGMENT_EXTRA_KEY) as String diff --git a/app/src/main/java/org/oppia/android/app/administratorcontrols/AdministratorControlsFragment.kt b/app/src/main/java/org/oppia/android/app/administratorcontrols/AdministratorControlsFragment.kt index 3257e8c207a..5465c1e83a1 100644 --- a/app/src/main/java/org/oppia/android/app/administratorcontrols/AdministratorControlsFragment.kt +++ b/app/src/main/java/org/oppia/android/app/administratorcontrols/AdministratorControlsFragment.kt @@ -7,6 +7,7 @@ import android.view.View import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableFragment import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl /** Fragment that contains Administrator Controls of the application. */ class AdministratorControlsFragment : InjectableFragment() { @@ -26,7 +27,7 @@ class AdministratorControlsFragment : InjectableFragment() { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/administratorcontrols/appversion/AppVersionActivity.kt b/app/src/main/java/org/oppia/android/app/administratorcontrols/appversion/AppVersionActivity.kt index a1956c909a8..1041ff1559d 100644 --- a/app/src/main/java/org/oppia/android/app/administratorcontrols/appversion/AppVersionActivity.kt +++ b/app/src/main/java/org/oppia/android/app/administratorcontrols/appversion/AppVersionActivity.kt @@ -6,6 +6,7 @@ import android.os.Bundle import android.view.MenuItem import org.oppia.android.app.activity.InjectableAppCompatActivity import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl /** Activity for App Version. */ class AppVersionActivity : InjectableAppCompatActivity() { @@ -14,7 +15,7 @@ class AppVersionActivity : InjectableAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) appVersionActivityPresenter.handleOnCreate() } diff --git a/app/src/main/java/org/oppia/android/app/administratorcontrols/appversion/AppVersionFragment.kt b/app/src/main/java/org/oppia/android/app/administratorcontrols/appversion/AppVersionFragment.kt index 242776ce166..177041980f8 100644 --- a/app/src/main/java/org/oppia/android/app/administratorcontrols/appversion/AppVersionFragment.kt +++ b/app/src/main/java/org/oppia/android/app/administratorcontrols/appversion/AppVersionFragment.kt @@ -7,6 +7,7 @@ import android.view.View import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableFragment import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl /** Fragment that contains app version and last update time of the Oppia application. */ class AppVersionFragment : InjectableFragment() { @@ -15,7 +16,7 @@ class AppVersionFragment : InjectableFragment() { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( 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..783b778251c 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 @@ -4,7 +4,7 @@ import android.app.Application import androidx.work.Configuration import dagger.BindsInstance import dagger.Component -import org.oppia.android.app.activity.ActivityComponent +import org.oppia.android.app.activity.ActivityComponentImpl import org.oppia.android.app.devoptions.DeveloperOptionsModule import org.oppia.android.app.devoptions.DeveloperOptionsStarterModule import org.oppia.android.app.shim.IntentFactoryShimModule @@ -101,7 +101,7 @@ interface ApplicationComponent : ApplicationInjector { fun build(): ApplicationComponent } - fun getActivityComponentBuilderProvider(): Provider + fun getActivityComponentBuilderProvider(): Provider fun getApplicationStartupListeners(): Set diff --git a/app/src/main/java/org/oppia/android/app/application/ApplicationInjector.kt b/app/src/main/java/org/oppia/android/app/application/ApplicationInjector.kt index 86683c51ea7..67e46e2f3a5 100644 --- a/app/src/main/java/org/oppia/android/app/application/ApplicationInjector.kt +++ b/app/src/main/java/org/oppia/android/app/application/ApplicationInjector.kt @@ -1,6 +1,7 @@ package org.oppia.android.app.application +import org.oppia.android.app.translation.AppLanguageApplicationInjector import org.oppia.android.util.data.DataProvidersInjector /** Injector for application-level dependencies that can't be directly injected where needed. */ -interface ApplicationInjector : DataProvidersInjector +interface ApplicationInjector : DataProvidersInjector, AppLanguageApplicationInjector diff --git a/app/src/main/java/org/oppia/android/app/application/ApplicationInjectorProvider.kt b/app/src/main/java/org/oppia/android/app/application/ApplicationInjectorProvider.kt index 7b0562e2d1d..99832db2056 100644 --- a/app/src/main/java/org/oppia/android/app/application/ApplicationInjectorProvider.kt +++ b/app/src/main/java/org/oppia/android/app/application/ApplicationInjectorProvider.kt @@ -1,11 +1,17 @@ package org.oppia.android.app.application +import org.oppia.android.app.translation.AppLanguageApplicationInjector +import org.oppia.android.app.translation.AppLanguageApplicationInjectorProvider import org.oppia.android.util.data.DataProvidersInjector import org.oppia.android.util.data.DataProvidersInjectorProvider /** Provider for [ApplicationInjector]. The application context will implement this interface. */ -interface ApplicationInjectorProvider : DataProvidersInjectorProvider { +interface ApplicationInjectorProvider : DataProvidersInjectorProvider, + AppLanguageApplicationInjectorProvider { fun getApplicationInjector(): ApplicationInjector override fun getDataProvidersInjector(): DataProvidersInjector = getApplicationInjector() + + override fun getAppLanguageApplicationInjector(): AppLanguageApplicationInjector = + getApplicationInjector() } diff --git a/app/src/main/java/org/oppia/android/app/application/ApplicationModule.kt b/app/src/main/java/org/oppia/android/app/application/ApplicationModule.kt index afa2ec1478e..00954abdc7d 100644 --- a/app/src/main/java/org/oppia/android/app/application/ApplicationModule.kt +++ b/app/src/main/java/org/oppia/android/app/application/ApplicationModule.kt @@ -4,11 +4,11 @@ import android.app.Application import android.content.Context import dagger.Module import dagger.Provides -import org.oppia.android.app.activity.ActivityComponent +import org.oppia.android.app.activity.ActivityComponentImpl import javax.inject.Singleton /** Provides core infrastructure needed to support all other dependencies in the app. */ -@Module(subcomponents = [ActivityComponent::class]) +@Module(subcomponents = [ActivityComponentImpl::class]) class ApplicationModule { @Provides @Singleton diff --git a/app/src/main/java/org/oppia/android/app/application/OppiaApplication.kt b/app/src/main/java/org/oppia/android/app/application/OppiaApplication.kt index fc7badf5efe..f55abbc1a74 100644 --- a/app/src/main/java/org/oppia/android/app/application/OppiaApplication.kt +++ b/app/src/main/java/org/oppia/android/app/application/OppiaApplication.kt @@ -6,7 +6,8 @@ import androidx.multidex.MultiDexApplication import androidx.work.Configuration import androidx.work.WorkManager import com.google.firebase.FirebaseApp -import org.oppia.android.app.activity.ActivityComponent +import org.oppia.android.app.activity.ActivityComponentFactory +import org.oppia.android.app.activity.ActivityComponentImpl import org.oppia.android.domain.oppialogger.ApplicationStartupListener /** The root [Application] of the Oppia app. */ @@ -22,7 +23,7 @@ class OppiaApplication : .build() } - override fun createActivityComponent(activity: AppCompatActivity): ActivityComponent { + override fun createActivityComponent(activity: AppCompatActivity): ActivityComponentImpl { return component.getActivityComponentBuilderProvider().get().setActivity(activity).build() } diff --git a/app/src/main/java/org/oppia/android/app/completedstorylist/CompletedStoryListActivity.kt b/app/src/main/java/org/oppia/android/app/completedstorylist/CompletedStoryListActivity.kt index 5d2cf986741..86a54f52a49 100644 --- a/app/src/main/java/org/oppia/android/app/completedstorylist/CompletedStoryListActivity.kt +++ b/app/src/main/java/org/oppia/android/app/completedstorylist/CompletedStoryListActivity.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.os.Bundle import org.oppia.android.app.activity.InjectableAppCompatActivity import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl /** Activity for completed stories. */ class CompletedStoryListActivity : InjectableAppCompatActivity() { @@ -13,7 +14,7 @@ class CompletedStoryListActivity : InjectableAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) val internalProfileId: Int = intent.getIntExtra(COMPLETED_STORY_LIST_ACTIVITY_PROFILE_ID_KEY, -1) completedStoryListActivityPresenter.handleOnCreate(internalProfileId) diff --git a/app/src/main/java/org/oppia/android/app/completedstorylist/CompletedStoryListFragment.kt b/app/src/main/java/org/oppia/android/app/completedstorylist/CompletedStoryListFragment.kt index d12bda93ad1..4ef1f9f32a0 100644 --- a/app/src/main/java/org/oppia/android/app/completedstorylist/CompletedStoryListFragment.kt +++ b/app/src/main/java/org/oppia/android/app/completedstorylist/CompletedStoryListFragment.kt @@ -7,6 +7,7 @@ import android.view.View import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableFragment import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl /** Fragment for displaying completed stories. */ class CompletedStoryListFragment : InjectableFragment() { @@ -31,7 +32,7 @@ class CompletedStoryListFragment : InjectableFragment() { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/customview/LessonThumbnailImageView.kt b/app/src/main/java/org/oppia/android/app/customview/LessonThumbnailImageView.kt index ce24c947f2b..0f86008ea52 100644 --- a/app/src/main/java/org/oppia/android/app/customview/LessonThumbnailImageView.kt +++ b/app/src/main/java/org/oppia/android/app/customview/LessonThumbnailImageView.kt @@ -8,7 +8,7 @@ import androidx.fragment.app.FragmentManager import org.oppia.android.R import org.oppia.android.app.model.LessonThumbnail import org.oppia.android.app.model.LessonThumbnailGraphic -import org.oppia.android.app.shim.ViewComponentFactory +import org.oppia.android.app.view.ViewComponentFactory import org.oppia.android.domain.oppialogger.OppiaLogger import org.oppia.android.util.gcsresource.DefaultResourceBucketName import org.oppia.android.util.parser.image.DefaultGcsPrefix @@ -17,6 +17,7 @@ import org.oppia.android.util.parser.image.ImageTransformation import org.oppia.android.util.parser.image.ImageViewTarget import org.oppia.android.util.parser.image.ThumbnailDownloadUrlTemplate import javax.inject.Inject +import org.oppia.android.app.view.ViewComponentImpl /** A custom [AppCompatImageView] used to show lesson thumbnails. */ class LessonThumbnailImageView @JvmOverloads constructor( @@ -124,8 +125,11 @@ class LessonThumbnailImageView @JvmOverloads constructor( override fun onAttachedToWindow() { try { super.onAttachedToWindow() - (FragmentManager.findFragment(this) as ViewComponentFactory) - .createViewComponent(this).inject(this) + + val viewComponentFactory = FragmentManager.findFragment(this) as ViewComponentFactory + val viewComponent = viewComponentFactory.createViewComponent(this) as ViewComponentImpl + viewComponent.inject(this) + checkIfLoadingIsPossible() } catch (e: IllegalStateException) { if (::oppiaLogger.isInitialized) diff --git a/app/src/main/java/org/oppia/android/app/deprecation/AutomaticAppDeprecationNoticeDialogFragment.kt b/app/src/main/java/org/oppia/android/app/deprecation/AutomaticAppDeprecationNoticeDialogFragment.kt index a322bfe84ad..e6ade213c4b 100644 --- a/app/src/main/java/org/oppia/android/app/deprecation/AutomaticAppDeprecationNoticeDialogFragment.kt +++ b/app/src/main/java/org/oppia/android/app/deprecation/AutomaticAppDeprecationNoticeDialogFragment.kt @@ -5,6 +5,7 @@ import android.content.Context import android.os.Bundle import org.oppia.android.app.fragment.InjectableDialogFragment import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl /** * Dialog fragment to be shown when the pre-release version of the app should no longer be playable @@ -27,7 +28,7 @@ class AutomaticAppDeprecationNoticeDialogFragment : InjectableDialogFragment() { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { diff --git a/app/src/main/java/org/oppia/android/app/devoptions/DeveloperOptionsActivity.kt b/app/src/main/java/org/oppia/android/app/devoptions/DeveloperOptionsActivity.kt index 15bba8982ec..ac7ce90cf55 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/DeveloperOptionsActivity.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/DeveloperOptionsActivity.kt @@ -12,6 +12,7 @@ import org.oppia.android.app.devoptions.marktopicscompleted.MarkTopicsCompletedA import org.oppia.android.app.devoptions.vieweventlogs.ViewEventLogsActivity import org.oppia.android.app.drawer.NAVIGATION_PROFILE_ID_ARGUMENT_KEY import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl /** Activity for Developer Options. */ class DeveloperOptionsActivity : @@ -30,7 +31,7 @@ class DeveloperOptionsActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) internalProfileId = intent.getIntExtra(NAVIGATION_PROFILE_ID_ARGUMENT_KEY, -1) developerOptionsActivityPresenter.handleOnCreate() title = getString(R.string.developer_options_activity_title) diff --git a/app/src/main/java/org/oppia/android/app/devoptions/DeveloperOptionsFragment.kt b/app/src/main/java/org/oppia/android/app/devoptions/DeveloperOptionsFragment.kt index 7c482855453..bcdf3f8efb5 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/DeveloperOptionsFragment.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/DeveloperOptionsFragment.kt @@ -7,6 +7,7 @@ import android.view.View import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableFragment import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl /** Fragment that contains Developer Options of the application. */ class DeveloperOptionsFragment : InjectableFragment() { @@ -21,7 +22,7 @@ class DeveloperOptionsFragment : InjectableFragment() { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/devoptions/forcenetworktype/ForceNetworkTypeActivity.kt b/app/src/main/java/org/oppia/android/app/devoptions/forcenetworktype/ForceNetworkTypeActivity.kt index 4a057f69bce..3ba8e41ae82 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/forcenetworktype/ForceNetworkTypeActivity.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/forcenetworktype/ForceNetworkTypeActivity.kt @@ -6,6 +6,7 @@ import android.os.Bundle import org.oppia.android.R import org.oppia.android.app.activity.InjectableAppCompatActivity import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl /** Activity for forcing the network mode for the app. */ class ForceNetworkTypeActivity : InjectableAppCompatActivity() { @@ -14,7 +15,7 @@ class ForceNetworkTypeActivity : InjectableAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) forceNetworkTypeActivityPresenter.handleOnCreate() title = getString(R.string.force_network_type_activity_title) } diff --git a/app/src/main/java/org/oppia/android/app/devoptions/forcenetworktype/ForceNetworkTypeFragment.kt b/app/src/main/java/org/oppia/android/app/devoptions/forcenetworktype/ForceNetworkTypeFragment.kt index e6f5254526a..9e7b58b780f 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/forcenetworktype/ForceNetworkTypeFragment.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/forcenetworktype/ForceNetworkTypeFragment.kt @@ -7,6 +7,7 @@ import android.view.View import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableFragment import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl /** Fragment to provide functionality to force the network type of the app. */ class ForceNetworkTypeFragment : InjectableFragment() { @@ -20,7 +21,7 @@ class ForceNetworkTypeFragment : InjectableFragment() { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/devoptions/forcenetworktype/testing/ForceNetworkTypeTestActivity.kt b/app/src/main/java/org/oppia/android/app/devoptions/forcenetworktype/testing/ForceNetworkTypeTestActivity.kt index a9ba1613481..d66fabb5b1b 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/forcenetworktype/testing/ForceNetworkTypeTestActivity.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/forcenetworktype/testing/ForceNetworkTypeTestActivity.kt @@ -2,6 +2,7 @@ package org.oppia.android.app.devoptions.forcenetworktype.testing import android.os.Bundle import org.oppia.android.R +import org.oppia.android.app.activity.ActivityComponentImpl import org.oppia.android.app.activity.InjectableAppCompatActivity import org.oppia.android.app.devoptions.forcenetworktype.ForceNetworkTypeFragment @@ -10,7 +11,7 @@ class ForceNetworkTypeTestActivity : InjectableAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setHomeAsUpIndicator(R.drawable.ic_arrow_back_white_24dp) setContentView(R.layout.force_network_type_activity) diff --git a/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedActivity.kt b/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedActivity.kt index 505c8ab0739..43460a6cd7f 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedActivity.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedActivity.kt @@ -7,6 +7,7 @@ import android.view.MenuItem import org.oppia.android.R import org.oppia.android.app.activity.InjectableAppCompatActivity import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl /** Activity for Mark Chapters Completed. */ class MarkChaptersCompletedActivity : InjectableAppCompatActivity() { @@ -17,7 +18,7 @@ class MarkChaptersCompletedActivity : InjectableAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) internalProfileId = intent.getIntExtra(MARK_CHAPTERS_COMPLETED_ACTIVITY_PROFILE_ID_KEY, -1) markChaptersCompletedActivityPresenter.handleOnCreate(internalProfileId) title = getString(R.string.mark_chapters_completed_activity_title) diff --git a/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedFragment.kt b/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedFragment.kt index 692756132a1..305e47af40c 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedFragment.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedFragment.kt @@ -7,6 +7,7 @@ import android.view.View import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableFragment import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl /** Fragment to display all chapters and provide functionality to mark them completed. */ class MarkChaptersCompletedFragment : InjectableFragment() { @@ -32,7 +33,7 @@ class MarkChaptersCompletedFragment : InjectableFragment() { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/testing/MarkChaptersCompletedTestActivity.kt b/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/testing/MarkChaptersCompletedTestActivity.kt index 4121def28bb..2f492335b4d 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/testing/MarkChaptersCompletedTestActivity.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/testing/MarkChaptersCompletedTestActivity.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.Intent import android.os.Bundle import org.oppia.android.R +import org.oppia.android.app.activity.ActivityComponentImpl import org.oppia.android.app.activity.InjectableAppCompatActivity import org.oppia.android.app.devoptions.markchapterscompleted.MarkChaptersCompletedFragment @@ -14,7 +15,7 @@ class MarkChaptersCompletedTestActivity : InjectableAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setHomeAsUpIndicator(R.drawable.ic_arrow_back_white_24dp) setContentView(R.layout.mark_chapters_completed_activity) diff --git a/app/src/main/java/org/oppia/android/app/devoptions/markstoriescompleted/MarkStoriesCompletedActivity.kt b/app/src/main/java/org/oppia/android/app/devoptions/markstoriescompleted/MarkStoriesCompletedActivity.kt index 1fb098222ea..10c24979c80 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/markstoriescompleted/MarkStoriesCompletedActivity.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/markstoriescompleted/MarkStoriesCompletedActivity.kt @@ -7,6 +7,7 @@ import android.view.MenuItem import org.oppia.android.R import org.oppia.android.app.activity.InjectableAppCompatActivity import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl /** Activity for Mark Stories Completed. */ class MarkStoriesCompletedActivity : InjectableAppCompatActivity() { @@ -17,7 +18,7 @@ class MarkStoriesCompletedActivity : InjectableAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) internalProfileId = intent.getIntExtra(MARK_STORIES_COMPLETED_ACTIVITY_PROFILE_ID_KEY, -1) markStoriesCompletedActivityPresenter.handleOnCreate(internalProfileId) title = getString(R.string.mark_stories_completed_activity_title) diff --git a/app/src/main/java/org/oppia/android/app/devoptions/markstoriescompleted/MarkStoriesCompletedFragment.kt b/app/src/main/java/org/oppia/android/app/devoptions/markstoriescompleted/MarkStoriesCompletedFragment.kt index 18c89ccfead..103f9d39f27 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/markstoriescompleted/MarkStoriesCompletedFragment.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/markstoriescompleted/MarkStoriesCompletedFragment.kt @@ -7,6 +7,7 @@ import android.view.View import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableFragment import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl /** Fragment to display all stories and provide functionality to mark them completed. */ class MarkStoriesCompletedFragment : InjectableFragment() { @@ -31,7 +32,7 @@ class MarkStoriesCompletedFragment : InjectableFragment() { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/devoptions/markstoriescompleted/testing/MarkStoriesCompletedTestActivity.kt b/app/src/main/java/org/oppia/android/app/devoptions/markstoriescompleted/testing/MarkStoriesCompletedTestActivity.kt index c49574e042f..541136d1d5c 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/markstoriescompleted/testing/MarkStoriesCompletedTestActivity.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/markstoriescompleted/testing/MarkStoriesCompletedTestActivity.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.Intent import android.os.Bundle import org.oppia.android.R +import org.oppia.android.app.activity.ActivityComponentImpl import org.oppia.android.app.activity.InjectableAppCompatActivity import org.oppia.android.app.devoptions.markstoriescompleted.MarkStoriesCompletedFragment @@ -14,7 +15,7 @@ class MarkStoriesCompletedTestActivity : InjectableAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setHomeAsUpIndicator(R.drawable.ic_arrow_back_white_24dp) setContentView(R.layout.mark_stories_completed_activity) diff --git a/app/src/main/java/org/oppia/android/app/devoptions/marktopicscompleted/MarkTopicsCompletedActivity.kt b/app/src/main/java/org/oppia/android/app/devoptions/marktopicscompleted/MarkTopicsCompletedActivity.kt index c749d6a870f..5a83495f611 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/marktopicscompleted/MarkTopicsCompletedActivity.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/marktopicscompleted/MarkTopicsCompletedActivity.kt @@ -7,6 +7,7 @@ import android.view.MenuItem import org.oppia.android.R import org.oppia.android.app.activity.InjectableAppCompatActivity import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl /** Activity for Mark Topics Completed. */ class MarkTopicsCompletedActivity : InjectableAppCompatActivity() { @@ -17,7 +18,7 @@ class MarkTopicsCompletedActivity : InjectableAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) internalProfileId = intent.getIntExtra(MARK_TOPICS_COMPLETED_ACTIVITY_PROFILE_ID_KEY, -1) markTopicsCompletedActivityPresenter.handleOnCreate(internalProfileId) title = getString(R.string.mark_topics_completed_activity_title) diff --git a/app/src/main/java/org/oppia/android/app/devoptions/marktopicscompleted/MarkTopicsCompletedFragment.kt b/app/src/main/java/org/oppia/android/app/devoptions/marktopicscompleted/MarkTopicsCompletedFragment.kt index d4e689da2ee..3b0ae81eed4 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/marktopicscompleted/MarkTopicsCompletedFragment.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/marktopicscompleted/MarkTopicsCompletedFragment.kt @@ -7,6 +7,7 @@ import android.view.View import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableFragment import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl /** Fragment to display all topics and provide functionality to mark them completed. */ class MarkTopicsCompletedFragment : InjectableFragment() { @@ -31,7 +32,7 @@ class MarkTopicsCompletedFragment : InjectableFragment() { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/devoptions/marktopicscompleted/testing/MarkTopicsCompletedTestActivity.kt b/app/src/main/java/org/oppia/android/app/devoptions/marktopicscompleted/testing/MarkTopicsCompletedTestActivity.kt index 49bf5fbeaa0..df75ee78e8f 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/marktopicscompleted/testing/MarkTopicsCompletedTestActivity.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/marktopicscompleted/testing/MarkTopicsCompletedTestActivity.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.Intent import android.os.Bundle import org.oppia.android.R +import org.oppia.android.app.activity.ActivityComponentImpl import org.oppia.android.app.activity.InjectableAppCompatActivity import org.oppia.android.app.devoptions.marktopicscompleted.MarkTopicsCompletedFragment @@ -14,7 +15,7 @@ class MarkTopicsCompletedTestActivity : InjectableAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setHomeAsUpIndicator(R.drawable.ic_arrow_back_white_24dp) setContentView(R.layout.mark_topics_completed_activity) diff --git a/app/src/main/java/org/oppia/android/app/devoptions/testing/DeveloperOptionsTestActivity.kt b/app/src/main/java/org/oppia/android/app/devoptions/testing/DeveloperOptionsTestActivity.kt index 61721c6d568..a91fd898ace 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/testing/DeveloperOptionsTestActivity.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/testing/DeveloperOptionsTestActivity.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.Intent import android.os.Bundle import org.oppia.android.R +import org.oppia.android.app.activity.ActivityComponentImpl import org.oppia.android.app.activity.InjectableAppCompatActivity import org.oppia.android.app.devoptions.DeveloperOptionsActivity import org.oppia.android.app.devoptions.DeveloperOptionsFragment @@ -30,7 +31,7 @@ class DeveloperOptionsTestActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) setContentView(R.layout.developer_options_activity) internalProfileId = intent.getIntExtra(DEVELOPER_OPTIONS_TEST_ACTIVITY_PROFILE_ID_KEY, -1) if (getDeveloperOptionsFragment() == null) { diff --git a/app/src/main/java/org/oppia/android/app/devoptions/vieweventlogs/ViewEventLogsActivity.kt b/app/src/main/java/org/oppia/android/app/devoptions/vieweventlogs/ViewEventLogsActivity.kt index 3223136c279..4784e487005 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/vieweventlogs/ViewEventLogsActivity.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/vieweventlogs/ViewEventLogsActivity.kt @@ -6,6 +6,7 @@ import android.os.Bundle import org.oppia.android.R import org.oppia.android.app.activity.InjectableAppCompatActivity import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl /** Activity for View Event Logs. */ class ViewEventLogsActivity : InjectableAppCompatActivity() { @@ -14,7 +15,7 @@ class ViewEventLogsActivity : InjectableAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) viewEventLogsActivityPresenter.handleOnCreate() title = getString(R.string.view_event_logs_activity_title) } diff --git a/app/src/main/java/org/oppia/android/app/devoptions/vieweventlogs/ViewEventLogsFragment.kt b/app/src/main/java/org/oppia/android/app/devoptions/vieweventlogs/ViewEventLogsFragment.kt index fe9c9061c51..624af35c061 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/vieweventlogs/ViewEventLogsFragment.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/vieweventlogs/ViewEventLogsFragment.kt @@ -7,6 +7,7 @@ import android.view.View import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableFragment import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl /** Fragment to display all event logs. */ class ViewEventLogsFragment : InjectableFragment() { @@ -21,7 +22,7 @@ class ViewEventLogsFragment : InjectableFragment() { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/devoptions/vieweventlogs/testing/ViewEventLogsTestActivity.kt b/app/src/main/java/org/oppia/android/app/devoptions/vieweventlogs/testing/ViewEventLogsTestActivity.kt index b94e0543736..292be6b010e 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/vieweventlogs/testing/ViewEventLogsTestActivity.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/vieweventlogs/testing/ViewEventLogsTestActivity.kt @@ -4,6 +4,7 @@ import android.content.Context import android.content.Intent import android.os.Bundle import org.oppia.android.R +import org.oppia.android.app.activity.ActivityComponentImpl import org.oppia.android.app.activity.InjectableAppCompatActivity import org.oppia.android.app.devoptions.vieweventlogs.ViewEventLogsFragment @@ -12,7 +13,7 @@ class ViewEventLogsTestActivity : InjectableAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) supportActionBar?.setDisplayHomeAsUpEnabled(true) supportActionBar?.setHomeAsUpIndicator(R.drawable.ic_arrow_back_white_24dp) setContentView(R.layout.view_event_logs_activity) diff --git a/app/src/main/java/org/oppia/android/app/drawer/NavigationDrawerFragment.kt b/app/src/main/java/org/oppia/android/app/drawer/NavigationDrawerFragment.kt index ae620539f11..40cb8c0dbbf 100644 --- a/app/src/main/java/org/oppia/android/app/drawer/NavigationDrawerFragment.kt +++ b/app/src/main/java/org/oppia/android/app/drawer/NavigationDrawerFragment.kt @@ -9,6 +9,7 @@ import androidx.appcompat.widget.Toolbar import androidx.drawerlayout.widget.DrawerLayout import org.oppia.android.app.fragment.InjectableFragment import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl /** [NavigationDrawerFragment] to show navigation drawer. */ class NavigationDrawerFragment : @@ -21,7 +22,7 @@ class NavigationDrawerFragment : override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/fragment/BUILD.bazel b/app/src/main/java/org/oppia/android/app/fragment/BUILD.bazel new file mode 100644 index 00000000000..366ef21babc --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/fragment/BUILD.bazel @@ -0,0 +1,93 @@ +""" +Constructs for setting up fragments for injection in the Dagger graph. +""" + +load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_android_library") + +# TODO(#59): Define these exported files as separate libraries from top-level targets. +exports_files([ + "FragmentComponentBuilderModule.kt", + "FragmentComponentImpl.kt", + "FragmentModule.kt", +]) + +kt_android_library( + name = "fragment_scope", + srcs = [ + "FragmentScope.kt", + ], + visibility = ["//app:app_visibility"], + deps = [ + "//third_party:javax_inject_javax_inject", + ], +) + +kt_android_library( + name = "fragment_component", + srcs = [ + "FragmentComponent.kt", + ], + visibility = [ + "//app/src/main/java/org/oppia/android/app/activity:__pkg__", + ], + deps = [ + "//third_party:androidx_appcompat_appcompat", + ], +) + +kt_android_library( + name = "fragment_component_factory", + srcs = [ + "FragmentComponentFactory.kt", + ], + visibility = ["//app:app_visibility"], + deps = [ + ":fragment_component", + "//third_party:androidx_appcompat_appcompat", + "//third_party:javax_inject_javax_inject", + ], +) + +kt_android_library( + name = "fragment_component_builder_injector", + srcs = [ + "FragmentComponentBuilderInjector.kt", + ], + visibility = [ + "//app/src/main/java/org/oppia/android/app/activity:__pkg__", + ], + deps = [ + ":fragment_component", + "//third_party:javax_inject_javax_inject", + ], +) + +kt_android_library( + name = "injectable_fragment", + srcs = [ + "InjectableFragment.kt", + ], + visibility = ["//app:app_visibility"], + deps = [ + ":fragment_component_factory", + "//app/src/main/java/org/oppia/android/app/view:view_component", + "//app/src/main/java/org/oppia/android/app/view:view_component_builder_injector", + "//app/src/main/java/org/oppia/android/app/view:view_component_factory", + "//third_party:androidx_appcompat_appcompat", + ], +) + +kt_android_library( + name = "injectable_dialog_fragment", + srcs = [ + "InjectableDialogFragment.kt", + ], + visibility = ["//app:app_visibility"], + deps = [ + ":fragment_component_factory", + "//app/src/main/java/org/oppia/android/app/view:view_component", + "//app/src/main/java/org/oppia/android/app/view:view_component_builder_injector", + "//app/src/main/java/org/oppia/android/app/view:view_component_factory", + "//third_party:androidx_appcompat_appcompat", + ], +) diff --git a/app/src/main/java/org/oppia/android/app/fragment/FragmentComponent.kt b/app/src/main/java/org/oppia/android/app/fragment/FragmentComponent.kt index ac4776aeaf4..f49ef14c237 100644 --- a/app/src/main/java/org/oppia/android/app/fragment/FragmentComponent.kt +++ b/app/src/main/java/org/oppia/android/app/fragment/FragmentComponent.kt @@ -1,139 +1,11 @@ package org.oppia.android.app.fragment import androidx.fragment.app.Fragment -import dagger.BindsInstance -import dagger.Subcomponent -import org.oppia.android.app.administratorcontrols.AdministratorControlsFragment -import org.oppia.android.app.administratorcontrols.appversion.AppVersionFragment -import org.oppia.android.app.completedstorylist.CompletedStoryListFragment -import org.oppia.android.app.deprecation.AutomaticAppDeprecationNoticeDialogFragment -import org.oppia.android.app.devoptions.DeveloperOptionsFragment -import org.oppia.android.app.devoptions.forcenetworktype.ForceNetworkTypeFragment -import org.oppia.android.app.devoptions.markchapterscompleted.MarkChaptersCompletedFragment -import org.oppia.android.app.devoptions.markstoriescompleted.MarkStoriesCompletedFragment -import org.oppia.android.app.devoptions.marktopicscompleted.MarkTopicsCompletedFragment -import org.oppia.android.app.devoptions.vieweventlogs.ViewEventLogsFragment -import org.oppia.android.app.drawer.NavigationDrawerFragment -import org.oppia.android.app.help.HelpFragment -import org.oppia.android.app.help.faq.FAQListFragment -import org.oppia.android.app.help.thirdparty.LicenseListFragment -import org.oppia.android.app.help.thirdparty.LicenseTextViewerFragment -import org.oppia.android.app.help.thirdparty.ThirdPartyDependencyListFragment -import org.oppia.android.app.hintsandsolution.HintsAndSolutionDialogFragment -import org.oppia.android.app.home.HomeFragment -import org.oppia.android.app.home.recentlyplayed.RecentlyPlayedFragment -import org.oppia.android.app.mydownloads.DownloadsTabFragment -import org.oppia.android.app.mydownloads.MyDownloadsFragment -import org.oppia.android.app.mydownloads.UpdatesTabFragment -import org.oppia.android.app.onboarding.OnboardingFragment -import org.oppia.android.app.ongoingtopiclist.OngoingTopicListFragment -import org.oppia.android.app.options.AppLanguageFragment -import org.oppia.android.app.options.AudioLanguageFragment -import org.oppia.android.app.options.OptionsFragment -import org.oppia.android.app.options.ReadingTextSizeFragment -import org.oppia.android.app.player.audio.AudioFragment -import org.oppia.android.app.player.exploration.ExplorationFragment -import org.oppia.android.app.player.exploration.ExplorationManagerFragment -import org.oppia.android.app.player.exploration.HintsAndSolutionExplorationManagerFragment -import org.oppia.android.app.player.state.StateFragment -import org.oppia.android.app.player.state.itemviewmodel.InteractionViewModelModule -import org.oppia.android.app.profile.AdminSettingsDialogFragment -import org.oppia.android.app.profile.ProfileChooserFragment -import org.oppia.android.app.profile.ResetPinDialogFragment -import org.oppia.android.app.profileprogress.ProfileProgressFragment -import org.oppia.android.app.resumelesson.ResumeLessonFragment -import org.oppia.android.app.settings.profile.ProfileEditFragment -import org.oppia.android.app.settings.profile.ProfileListFragment -import org.oppia.android.app.shim.IntentFactoryShimModule -import org.oppia.android.app.shim.ViewBindingShimModule -import org.oppia.android.app.story.StoryFragment -import org.oppia.android.app.testing.ImageRegionSelectionTestFragment -import org.oppia.android.app.topic.TopicFragment -import org.oppia.android.app.topic.conceptcard.ConceptCardFragment -import org.oppia.android.app.topic.info.TopicInfoFragment -import org.oppia.android.app.topic.lessons.TopicLessonsFragment -import org.oppia.android.app.topic.practice.TopicPracticeFragment -import org.oppia.android.app.topic.questionplayer.HintsAndSolutionQuestionManagerFragment -import org.oppia.android.app.topic.questionplayer.QuestionPlayerFragment -import org.oppia.android.app.topic.revision.TopicRevisionFragment -import org.oppia.android.app.topic.revisioncard.RevisionCardFragment -import org.oppia.android.app.view.ViewComponent -import org.oppia.android.app.walkthrough.end.WalkthroughFinalFragment -import org.oppia.android.app.walkthrough.topiclist.WalkthroughTopicListFragment -import org.oppia.android.app.walkthrough.welcome.WalkthroughWelcomeFragment -import javax.inject.Provider -/** Root subcomponent for all fragments. */ -@Subcomponent( - modules = [ - FragmentModule::class, InteractionViewModelModule::class, IntentFactoryShimModule::class, - ViewBindingShimModule::class - ] -) -@FragmentScope interface FragmentComponent { - @Subcomponent.Builder interface Builder { - @BindsInstance fun setFragment(fragment: Fragment): Builder fun build(): FragmentComponent } - - fun getViewComponentBuilderProvider(): Provider - - fun inject(administratorControlsFragment: AdministratorControlsFragment) - fun inject(adminSettingsDialogFragment: AdminSettingsDialogFragment) - fun inject(appLanguageFragment: AppLanguageFragment) - fun inject(appVersionFragment: AppVersionFragment) - fun inject(audioFragment: AudioFragment) - fun inject(audioLanguageFragment: AudioLanguageFragment) - fun inject(autoAppDeprecationNoticeDialogFragment: AutomaticAppDeprecationNoticeDialogFragment) - fun inject(completedStoryListFragment: CompletedStoryListFragment) - fun inject(conceptCardFragment: ConceptCardFragment) - fun inject(developerOptionsFragment: DeveloperOptionsFragment) - fun inject(downloadsTabFragment: DownloadsTabFragment) - fun inject(explorationFragment: ExplorationFragment) - fun inject(explorationManagerFragment: ExplorationManagerFragment) - fun inject(faqListFragment: FAQListFragment) - fun inject(forceNetworkTypeFragment: ForceNetworkTypeFragment) - fun inject(helpFragment: HelpFragment) - fun inject(hintsAndSolutionDialogFragment: HintsAndSolutionDialogFragment) - fun inject(hintsAndSolutionExplorationManagerFragment: HintsAndSolutionExplorationManagerFragment) - fun inject(hintsAndSolutionQuestionManagerFragment: HintsAndSolutionQuestionManagerFragment) - fun inject(homeFragment: HomeFragment) - fun inject(imageRegionSelectionTestFragment: ImageRegionSelectionTestFragment) - fun inject(licenseListFragment: LicenseListFragment) - fun inject(licenseTextViewerFragment: LicenseTextViewerFragment) - fun inject(markChapterCompletedFragment: MarkChaptersCompletedFragment) - fun inject(markStoriesCompletedFragment: MarkStoriesCompletedFragment) - fun inject(markTopicsCompletedFragment: MarkTopicsCompletedFragment) - fun inject(myDownloadsFragment: MyDownloadsFragment) - fun inject(navigationDrawerFragment: NavigationDrawerFragment) - fun inject(onboardingFragment: OnboardingFragment) - fun inject(ongoingTopicListFragment: OngoingTopicListFragment) - fun inject(optionFragment: OptionsFragment) - fun inject(profileChooserFragment: ProfileChooserFragment) - fun inject(profileEditFragment: ProfileEditFragment) - fun inject(profileListFragment: ProfileListFragment) - fun inject(profileProgressFragment: ProfileProgressFragment) - fun inject(questionPlayerFragment: QuestionPlayerFragment) - fun inject(readingTextSizeFragment: ReadingTextSizeFragment) - fun inject(recentlyPlayedFragment: RecentlyPlayedFragment) - fun inject(resetPinDialogFragment: ResetPinDialogFragment) - fun inject(resumeLessonFragment: ResumeLessonFragment) - fun inject(revisionCardFragment: RevisionCardFragment) - fun inject(stateFragment: StateFragment) - fun inject(storyFragment: StoryFragment) - fun inject(thirdPartyDependencyListFragment: ThirdPartyDependencyListFragment) - fun inject(topicFragment: TopicFragment) - fun inject(topicInfoFragment: TopicInfoFragment) - fun inject(topicLessonsFragment: TopicLessonsFragment) - fun inject(topicPracticeFragment: TopicPracticeFragment) - fun inject(topicReviewFragment: TopicRevisionFragment) - fun inject(updatesTabFragment: UpdatesTabFragment) - fun inject(viewEventLogsFragment: ViewEventLogsFragment) - fun inject(walkthroughFinalFragment: WalkthroughFinalFragment) - fun inject(walkthroughTopicListFragment: WalkthroughTopicListFragment) - fun inject(walkthroughWelcomeFragment: WalkthroughWelcomeFragment) } diff --git a/app/src/main/java/org/oppia/android/app/fragment/FragmentComponentBuilderInjector.kt b/app/src/main/java/org/oppia/android/app/fragment/FragmentComponentBuilderInjector.kt new file mode 100644 index 00000000000..15a9e3bbb10 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/fragment/FragmentComponentBuilderInjector.kt @@ -0,0 +1,7 @@ +package org.oppia.android.app.fragment + +import javax.inject.Provider + +interface FragmentComponentBuilderInjector { + fun getFragmentComponentBuilderProvider(): Provider +} diff --git a/app/src/main/java/org/oppia/android/app/fragment/FragmentComponentBuilderModule.kt b/app/src/main/java/org/oppia/android/app/fragment/FragmentComponentBuilderModule.kt new file mode 100644 index 00000000000..2c77671f0cb --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/fragment/FragmentComponentBuilderModule.kt @@ -0,0 +1,10 @@ +package org.oppia.android.app.fragment + +import dagger.Binds +import dagger.Module + +@Module +interface FragmentComponentBuilderModule { + @Binds + fun bindFragmentComponentBuilder(impl: FragmentComponentImpl.Builder): FragmentComponent.Builder +} diff --git a/app/src/main/java/org/oppia/android/app/fragment/FragmentComponentFactory.kt b/app/src/main/java/org/oppia/android/app/fragment/FragmentComponentFactory.kt new file mode 100644 index 00000000000..dfbcf2a1382 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/fragment/FragmentComponentFactory.kt @@ -0,0 +1,9 @@ +package org.oppia.android.app.fragment + +import androidx.fragment.app.Fragment + +/** Factory for creating [FragmentComponent]s. */ +interface FragmentComponentFactory { + /** Returns a new [FragmentComponent] for the specified fragment. */ + fun createFragmentComponent(fragment: Fragment): FragmentComponent +} diff --git a/app/src/main/java/org/oppia/android/app/fragment/FragmentComponentImpl.kt b/app/src/main/java/org/oppia/android/app/fragment/FragmentComponentImpl.kt new file mode 100644 index 00000000000..4bcd2dbe8ec --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/fragment/FragmentComponentImpl.kt @@ -0,0 +1,139 @@ +package org.oppia.android.app.fragment + +import androidx.fragment.app.Fragment +import dagger.BindsInstance +import dagger.Subcomponent +import org.oppia.android.app.administratorcontrols.AdministratorControlsFragment +import org.oppia.android.app.administratorcontrols.appversion.AppVersionFragment +import org.oppia.android.app.completedstorylist.CompletedStoryListFragment +import org.oppia.android.app.deprecation.AutomaticAppDeprecationNoticeDialogFragment +import org.oppia.android.app.devoptions.DeveloperOptionsFragment +import org.oppia.android.app.devoptions.forcenetworktype.ForceNetworkTypeFragment +import org.oppia.android.app.devoptions.markchapterscompleted.MarkChaptersCompletedFragment +import org.oppia.android.app.devoptions.markstoriescompleted.MarkStoriesCompletedFragment +import org.oppia.android.app.devoptions.marktopicscompleted.MarkTopicsCompletedFragment +import org.oppia.android.app.devoptions.vieweventlogs.ViewEventLogsFragment +import org.oppia.android.app.drawer.NavigationDrawerFragment +import org.oppia.android.app.help.HelpFragment +import org.oppia.android.app.help.faq.FAQListFragment +import org.oppia.android.app.help.thirdparty.LicenseListFragment +import org.oppia.android.app.help.thirdparty.LicenseTextViewerFragment +import org.oppia.android.app.help.thirdparty.ThirdPartyDependencyListFragment +import org.oppia.android.app.hintsandsolution.HintsAndSolutionDialogFragment +import org.oppia.android.app.home.HomeFragment +import org.oppia.android.app.home.recentlyplayed.RecentlyPlayedFragment +import org.oppia.android.app.mydownloads.DownloadsTabFragment +import org.oppia.android.app.mydownloads.MyDownloadsFragment +import org.oppia.android.app.mydownloads.UpdatesTabFragment +import org.oppia.android.app.onboarding.OnboardingFragment +import org.oppia.android.app.ongoingtopiclist.OngoingTopicListFragment +import org.oppia.android.app.options.AppLanguageFragment +import org.oppia.android.app.options.AudioLanguageFragment +import org.oppia.android.app.options.OptionsFragment +import org.oppia.android.app.options.ReadingTextSizeFragment +import org.oppia.android.app.player.audio.AudioFragment +import org.oppia.android.app.player.exploration.ExplorationFragment +import org.oppia.android.app.player.exploration.ExplorationManagerFragment +import org.oppia.android.app.player.exploration.HintsAndSolutionExplorationManagerFragment +import org.oppia.android.app.player.state.StateFragment +import org.oppia.android.app.player.state.itemviewmodel.InteractionViewModelModule +import org.oppia.android.app.profile.AdminSettingsDialogFragment +import org.oppia.android.app.profile.ProfileChooserFragment +import org.oppia.android.app.profile.ResetPinDialogFragment +import org.oppia.android.app.profileprogress.ProfileProgressFragment +import org.oppia.android.app.resumelesson.ResumeLessonFragment +import org.oppia.android.app.settings.profile.ProfileEditFragment +import org.oppia.android.app.settings.profile.ProfileListFragment +import org.oppia.android.app.shim.IntentFactoryShimModule +import org.oppia.android.app.shim.ViewBindingShimModule +import org.oppia.android.app.story.StoryFragment +import org.oppia.android.app.testing.ImageRegionSelectionTestFragment +import org.oppia.android.app.topic.TopicFragment +import org.oppia.android.app.topic.conceptcard.ConceptCardFragment +import org.oppia.android.app.topic.info.TopicInfoFragment +import org.oppia.android.app.topic.lessons.TopicLessonsFragment +import org.oppia.android.app.topic.practice.TopicPracticeFragment +import org.oppia.android.app.topic.questionplayer.HintsAndSolutionQuestionManagerFragment +import org.oppia.android.app.topic.questionplayer.QuestionPlayerFragment +import org.oppia.android.app.topic.revision.TopicRevisionFragment +import org.oppia.android.app.topic.revisioncard.RevisionCardFragment +import org.oppia.android.app.walkthrough.end.WalkthroughFinalFragment +import org.oppia.android.app.walkthrough.topiclist.WalkthroughTopicListFragment +import org.oppia.android.app.walkthrough.welcome.WalkthroughWelcomeFragment +import org.oppia.android.app.view.ViewComponentBuilderInjector +import org.oppia.android.app.view.ViewComponentBuilderModule + +// TODO(#59): Restrict access to this implementation by introducing injectors in each fragment. + +/** Root subcomponent for all fragments. */ +@Subcomponent( + modules = [ + FragmentModule::class, InteractionViewModelModule::class, IntentFactoryShimModule::class, + ViewBindingShimModule::class, ViewComponentBuilderModule::class + ] +) +@FragmentScope +interface FragmentComponentImpl: FragmentComponent, ViewComponentBuilderInjector { + @Subcomponent.Builder + interface Builder: FragmentComponent.Builder { + @BindsInstance + override fun setFragment(fragment: Fragment): Builder + + override fun build(): FragmentComponentImpl + } + + fun inject(administratorControlsFragment: AdministratorControlsFragment) + fun inject(adminSettingsDialogFragment: AdminSettingsDialogFragment) + fun inject(appLanguageFragment: AppLanguageFragment) + fun inject(appVersionFragment: AppVersionFragment) + fun inject(audioFragment: AudioFragment) + fun inject(audioLanguageFragment: AudioLanguageFragment) + fun inject(autoAppDeprecationNoticeDialogFragment: AutomaticAppDeprecationNoticeDialogFragment) + fun inject(completedStoryListFragment: CompletedStoryListFragment) + fun inject(conceptCardFragment: ConceptCardFragment) + fun inject(developerOptionsFragment: DeveloperOptionsFragment) + fun inject(downloadsTabFragment: DownloadsTabFragment) + fun inject(explorationFragment: ExplorationFragment) + fun inject(explorationManagerFragment: ExplorationManagerFragment) + fun inject(faqListFragment: FAQListFragment) + fun inject(forceNetworkTypeFragment: ForceNetworkTypeFragment) + fun inject(helpFragment: HelpFragment) + fun inject(hintsAndSolutionDialogFragment: HintsAndSolutionDialogFragment) + fun inject(hintsAndSolutionExplorationManagerFragment: HintsAndSolutionExplorationManagerFragment) + fun inject(hintsAndSolutionQuestionManagerFragment: HintsAndSolutionQuestionManagerFragment) + fun inject(homeFragment: HomeFragment) + fun inject(imageRegionSelectionTestFragment: ImageRegionSelectionTestFragment) + fun inject(licenseListFragment: LicenseListFragment) + fun inject(licenseTextViewerFragment: LicenseTextViewerFragment) + fun inject(markChapterCompletedFragment: MarkChaptersCompletedFragment) + fun inject(markStoriesCompletedFragment: MarkStoriesCompletedFragment) + fun inject(markTopicsCompletedFragment: MarkTopicsCompletedFragment) + fun inject(myDownloadsFragment: MyDownloadsFragment) + fun inject(navigationDrawerFragment: NavigationDrawerFragment) + fun inject(onboardingFragment: OnboardingFragment) + fun inject(ongoingTopicListFragment: OngoingTopicListFragment) + fun inject(optionFragment: OptionsFragment) + fun inject(profileChooserFragment: ProfileChooserFragment) + fun inject(profileEditFragment: ProfileEditFragment) + fun inject(profileListFragment: ProfileListFragment) + fun inject(profileProgressFragment: ProfileProgressFragment) + fun inject(questionPlayerFragment: QuestionPlayerFragment) + fun inject(readingTextSizeFragment: ReadingTextSizeFragment) + fun inject(recentlyPlayedFragment: RecentlyPlayedFragment) + fun inject(resetPinDialogFragment: ResetPinDialogFragment) + fun inject(resumeLessonFragment: ResumeLessonFragment) + fun inject(revisionCardFragment: RevisionCardFragment) + fun inject(stateFragment: StateFragment) + fun inject(storyFragment: StoryFragment) + fun inject(thirdPartyDependencyListFragment: ThirdPartyDependencyListFragment) + fun inject(topicFragment: TopicFragment) + fun inject(topicInfoFragment: TopicInfoFragment) + fun inject(topicLessonsFragment: TopicLessonsFragment) + fun inject(topicPracticeFragment: TopicPracticeFragment) + fun inject(topicReviewFragment: TopicRevisionFragment) + fun inject(updatesTabFragment: UpdatesTabFragment) + fun inject(viewEventLogsFragment: ViewEventLogsFragment) + fun inject(walkthroughFinalFragment: WalkthroughFinalFragment) + fun inject(walkthroughTopicListFragment: WalkthroughTopicListFragment) + fun inject(walkthroughWelcomeFragment: WalkthroughWelcomeFragment) +} diff --git a/app/src/main/java/org/oppia/android/app/fragment/FragmentModule.kt b/app/src/main/java/org/oppia/android/app/fragment/FragmentModule.kt index 5d4e455b847..9d39a4aeef1 100644 --- a/app/src/main/java/org/oppia/android/app/fragment/FragmentModule.kt +++ b/app/src/main/java/org/oppia/android/app/fragment/FragmentModule.kt @@ -1,8 +1,8 @@ package org.oppia.android.app.fragment import dagger.Module -import org.oppia.android.app.view.ViewComponent +import org.oppia.android.app.view.ViewComponentImpl /** Root fragment module. */ -@Module(subcomponents = [ViewComponent::class]) +@Module(subcomponents = [ViewComponentImpl::class]) class FragmentModule diff --git a/app/src/main/java/org/oppia/android/app/fragment/InjectableDialogFragment.kt b/app/src/main/java/org/oppia/android/app/fragment/InjectableDialogFragment.kt index bd822635685..0d3f73fef5e 100644 --- a/app/src/main/java/org/oppia/android/app/fragment/InjectableDialogFragment.kt +++ b/app/src/main/java/org/oppia/android/app/fragment/InjectableDialogFragment.kt @@ -1,24 +1,32 @@ package org.oppia.android.app.fragment import android.content.Context +import android.view.View import androidx.fragment.app.DialogFragment -import org.oppia.android.app.activity.InjectableAppCompatActivity +import org.oppia.android.app.view.ViewComponent +import org.oppia.android.app.view.ViewComponentBuilderInjector +import org.oppia.android.app.view.ViewComponentFactory /** * A fragment that facilitates field injection to children. This fragment can only be used with - * [InjectableAppCompatActivity] contexts. + * [org.oppia.android.app.activity.InjectableAppCompatActivity] contexts. */ -abstract class InjectableDialogFragment : DialogFragment() { +abstract class InjectableDialogFragment : DialogFragment(), ViewComponentFactory { /** - * The [FragmentComponent] corresponding to this fragment. This cannot be used before [onAttach] is called, and can be - * used to inject lateinit fields in child fragments during fragment attachment (which is recommended to be done in an - * override of [onAttach]). + * The [FragmentComponent] corresponding to this fragment. This cannot be used before [onAttach] + * is called, and can be used to inject lateinit fields in child fragments during fragment + * attachment (which is recommended to be done in an override of [onAttach]). */ lateinit var fragmentComponent: FragmentComponent override fun onAttach(context: Context) { super.onAttach(context) fragmentComponent = - (requireActivity() as InjectableAppCompatActivity).createFragmentComponent(this) + (requireActivity() as FragmentComponentFactory).createFragmentComponent(this) + } + + override fun createViewComponent(view: View): ViewComponent { + val builderInjector = fragmentComponent as ViewComponentBuilderInjector + return builderInjector.getViewComponentBuilderProvider().get().setView(view).build() } } diff --git a/app/src/main/java/org/oppia/android/app/fragment/InjectableFragment.kt b/app/src/main/java/org/oppia/android/app/fragment/InjectableFragment.kt index 465f3be1ec4..048abef81bd 100644 --- a/app/src/main/java/org/oppia/android/app/fragment/InjectableFragment.kt +++ b/app/src/main/java/org/oppia/android/app/fragment/InjectableFragment.kt @@ -3,29 +3,30 @@ package org.oppia.android.app.fragment import android.content.Context import android.view.View import androidx.fragment.app.Fragment -import org.oppia.android.app.activity.InjectableAppCompatActivity -import org.oppia.android.app.shim.ViewComponentFactory import org.oppia.android.app.view.ViewComponent +import org.oppia.android.app.view.ViewComponentFactory +import org.oppia.android.app.view.ViewComponentBuilderInjector /** * A fragment that facilitates field injection to children. This fragment can only be used with - * [InjectableAppCompatActivity] contexts. + * [org.oppia.android.app.activity.InjectableAppCompatActivity] contexts. */ abstract class InjectableFragment : Fragment(), ViewComponentFactory { /** - * The [FragmentComponent] corresponding to this fragment. This cannot be used before [onAttach] is called, and can be - * used to inject lateinit fields in child fragments during fragment attachment (which is recommended to be done in an - * override of [onAttach]). + * The [FragmentComponent] corresponding to this fragment. This cannot be used before [onAttach] + * is called, and can be used to inject lateinit fields in child fragments during fragment + * attachment (which is recommended to be done in an override of [onAttach]). */ lateinit var fragmentComponent: FragmentComponent override fun onAttach(context: Context) { super.onAttach(context) fragmentComponent = - (requireActivity() as InjectableAppCompatActivity).createFragmentComponent(this) + (requireActivity() as FragmentComponentFactory).createFragmentComponent(this) } override fun createViewComponent(view: View): ViewComponent { - return fragmentComponent.getViewComponentBuilderProvider().get().setView(view).build() + val builderInjector = fragmentComponent as ViewComponentBuilderInjector + return builderInjector.getViewComponentBuilderProvider().get().setView(view).build() } } diff --git a/app/src/main/java/org/oppia/android/app/help/HelpActivity.kt b/app/src/main/java/org/oppia/android/app/help/HelpActivity.kt index 97a0aea8387..02bd384446f 100644 --- a/app/src/main/java/org/oppia/android/app/help/HelpActivity.kt +++ b/app/src/main/java/org/oppia/android/app/help/HelpActivity.kt @@ -12,6 +12,7 @@ import org.oppia.android.app.help.faq.faqsingle.FAQSingleActivity import org.oppia.android.app.help.thirdparty.ThirdPartyDependencyListActivity import javax.inject.Inject import kotlin.properties.Delegates +import org.oppia.android.app.activity.ActivityComponentImpl const val HELP_OPTIONS_TITLE_SAVED_KEY = "HelpActivity.help_options_title" const val SELECTED_FRAGMENT_SAVED_KEY = "HelpActivity.selected_fragment" @@ -44,7 +45,7 @@ class HelpActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) val isFromNavigationDrawer = intent.getBooleanExtra( BOOL_IS_FROM_NAVIGATION_DRAWER_EXTRA_KEY, /* defaultValue= */ false diff --git a/app/src/main/java/org/oppia/android/app/help/HelpFragment.kt b/app/src/main/java/org/oppia/android/app/help/HelpFragment.kt index 85ecafaa330..daf4be72d54 100644 --- a/app/src/main/java/org/oppia/android/app/help/HelpFragment.kt +++ b/app/src/main/java/org/oppia/android/app/help/HelpFragment.kt @@ -7,6 +7,7 @@ import android.view.View import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableFragment import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl private const val IS_MULTIPANE_KEY = "HelpFragment.bool_is_multipane" @@ -28,7 +29,7 @@ class HelpFragment : InjectableFragment() { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/help/faq/FAQListActivity.kt b/app/src/main/java/org/oppia/android/app/help/faq/FAQListActivity.kt index b93a0af062e..bf99fea0402 100644 --- a/app/src/main/java/org/oppia/android/app/help/faq/FAQListActivity.kt +++ b/app/src/main/java/org/oppia/android/app/help/faq/FAQListActivity.kt @@ -6,6 +6,7 @@ import android.os.Bundle import org.oppia.android.app.activity.InjectableAppCompatActivity import org.oppia.android.app.help.faq.faqsingle.FAQSingleActivity import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl /** The FAQ page activity for placement of different FAQs. */ class FAQListActivity : InjectableAppCompatActivity(), RouteToFAQSingleListener { @@ -15,7 +16,7 @@ class FAQListActivity : InjectableAppCompatActivity(), RouteToFAQSingleListener override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) faqListActivityPresenter.handleOnCreate() } diff --git a/app/src/main/java/org/oppia/android/app/help/faq/FAQListFragment.kt b/app/src/main/java/org/oppia/android/app/help/faq/FAQListFragment.kt index d138b3a5666..e614dedc7a7 100644 --- a/app/src/main/java/org/oppia/android/app/help/faq/FAQListFragment.kt +++ b/app/src/main/java/org/oppia/android/app/help/faq/FAQListFragment.kt @@ -7,6 +7,7 @@ import android.view.View import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableFragment import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl /** Fragment that contains FAQ list in the app. */ class FAQListFragment : InjectableFragment() { @@ -15,7 +16,7 @@ class FAQListFragment : InjectableFragment() { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/help/faq/faqsingle/FAQSingleActivity.kt b/app/src/main/java/org/oppia/android/app/help/faq/faqsingle/FAQSingleActivity.kt index 1a92313fbef..4165ede7669 100644 --- a/app/src/main/java/org/oppia/android/app/help/faq/faqsingle/FAQSingleActivity.kt +++ b/app/src/main/java/org/oppia/android/app/help/faq/faqsingle/FAQSingleActivity.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.os.Bundle import org.oppia.android.app.activity.InjectableAppCompatActivity import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl /** The FAQ page activity for placement of single FAQ. */ class FAQSingleActivity : InjectableAppCompatActivity() { @@ -14,7 +15,7 @@ class FAQSingleActivity : InjectableAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) val question = intent.getStringExtra(FAQ_SINGLE_ACTIVITY_QUESTION) val answer = intent.getStringExtra(FAQ_SINGLE_ACTIVITY_ANSWER) faqSingleActivityPresenter.handleOnCreate(question, answer) diff --git a/app/src/main/java/org/oppia/android/app/help/thirdparty/LicenseListActivity.kt b/app/src/main/java/org/oppia/android/app/help/thirdparty/LicenseListActivity.kt index 43ac230a785..47318b5cb8e 100644 --- a/app/src/main/java/org/oppia/android/app/help/thirdparty/LicenseListActivity.kt +++ b/app/src/main/java/org/oppia/android/app/help/thirdparty/LicenseListActivity.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.os.Bundle import org.oppia.android.app.activity.InjectableAppCompatActivity import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl /** The activity that will show list of licenses corresponding to a third-party dependency. */ class LicenseListActivity : InjectableAppCompatActivity(), RouteToLicenseTextListener { @@ -14,7 +15,7 @@ class LicenseListActivity : InjectableAppCompatActivity(), RouteToLicenseTextLis override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) val dependencyIndex = intent.getIntExtra(THIRD_PARTY_DEPENDENCY_INDEX, 0) licenseListActivityPresenter.handleOnCreate(dependencyIndex, false) } diff --git a/app/src/main/java/org/oppia/android/app/help/thirdparty/LicenseListFragment.kt b/app/src/main/java/org/oppia/android/app/help/thirdparty/LicenseListFragment.kt index 7639bf01b04..1522691cf09 100644 --- a/app/src/main/java/org/oppia/android/app/help/thirdparty/LicenseListFragment.kt +++ b/app/src/main/java/org/oppia/android/app/help/thirdparty/LicenseListFragment.kt @@ -7,6 +7,7 @@ import android.view.View import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableFragment import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl /** Fragment that contains list of licenses for a third-party dependency in the app. */ class LicenseListFragment : InjectableFragment() { @@ -31,7 +32,7 @@ class LicenseListFragment : InjectableFragment() { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/help/thirdparty/LicenseTextViewerActivity.kt b/app/src/main/java/org/oppia/android/app/help/thirdparty/LicenseTextViewerActivity.kt index 8367a26a2f7..f9c9572bda1 100644 --- a/app/src/main/java/org/oppia/android/app/help/thirdparty/LicenseTextViewerActivity.kt +++ b/app/src/main/java/org/oppia/android/app/help/thirdparty/LicenseTextViewerActivity.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.os.Bundle import org.oppia.android.app.activity.InjectableAppCompatActivity import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl /** The activity that will show the license text of a copyright license. */ class LicenseTextViewerActivity : InjectableAppCompatActivity() { @@ -14,7 +15,7 @@ class LicenseTextViewerActivity : InjectableAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) val dependencyIndex = intent.getIntExtra(LICENSE_TEXT_VIEWER_ACTIVITY_DEP_INDEX, 0) val licenseIndex = intent.getIntExtra(LICENSE_TEXT_VIEWER_ACTIVITY_LICENSE_INDEX, 0) licenseTextViewerActivityPresenter.handleOnCreate(dependencyIndex, licenseIndex) diff --git a/app/src/main/java/org/oppia/android/app/help/thirdparty/LicenseTextViewerFragment.kt b/app/src/main/java/org/oppia/android/app/help/thirdparty/LicenseTextViewerFragment.kt index 8448f017ff2..31d39e048ff 100644 --- a/app/src/main/java/org/oppia/android/app/help/thirdparty/LicenseTextViewerFragment.kt +++ b/app/src/main/java/org/oppia/android/app/help/thirdparty/LicenseTextViewerFragment.kt @@ -7,6 +7,7 @@ import android.view.View import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableFragment import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl /** Fragment that displays text of a copyright license of a third-party dependency. */ class LicenseTextViewerFragment : InjectableFragment() { @@ -32,7 +33,7 @@ class LicenseTextViewerFragment : InjectableFragment() { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/help/thirdparty/ThirdPartyDependencyListActivity.kt b/app/src/main/java/org/oppia/android/app/help/thirdparty/ThirdPartyDependencyListActivity.kt index d1e85e27ef7..8ff7970a0f5 100644 --- a/app/src/main/java/org/oppia/android/app/help/thirdparty/ThirdPartyDependencyListActivity.kt +++ b/app/src/main/java/org/oppia/android/app/help/thirdparty/ThirdPartyDependencyListActivity.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.os.Bundle import org.oppia.android.app.activity.InjectableAppCompatActivity import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl /** The activity for displaying a list of third-party dependencies used to build Oppia Android. */ class ThirdPartyDependencyListActivity : @@ -17,7 +18,7 @@ class ThirdPartyDependencyListActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) thirdPartyDependencyListActivityPresenter.handleOnCreate(false) } diff --git a/app/src/main/java/org/oppia/android/app/help/thirdparty/ThirdPartyDependencyListFragment.kt b/app/src/main/java/org/oppia/android/app/help/thirdparty/ThirdPartyDependencyListFragment.kt index eaf189c03ab..857b744a22d 100644 --- a/app/src/main/java/org/oppia/android/app/help/thirdparty/ThirdPartyDependencyListFragment.kt +++ b/app/src/main/java/org/oppia/android/app/help/thirdparty/ThirdPartyDependencyListFragment.kt @@ -7,6 +7,7 @@ import android.view.View import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableFragment import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl private const val IS_MULTIPANE_KEY = "ThirdPartyDependencyListFragment.is_multipane" @@ -30,7 +31,7 @@ class ThirdPartyDependencyListFragment : InjectableFragment() { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragment.kt b/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragment.kt index 6ce3529b875..d57067fa69e 100644 --- a/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragment.kt +++ b/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragment.kt @@ -12,6 +12,7 @@ import org.oppia.android.app.model.State import org.oppia.android.util.extensions.getProto import org.oppia.android.util.extensions.putProto import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl private const val CURRENT_EXPANDED_LIST_INDEX_SAVED_KEY = "HintsAndSolutionDialogFragment.current_expanded_list_index" @@ -68,7 +69,7 @@ class HintsAndSolutionDialogFragment : override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreate(savedInstanceState: Bundle?) { diff --git a/app/src/main/java/org/oppia/android/app/home/HomeActivity.kt b/app/src/main/java/org/oppia/android/app/home/HomeActivity.kt index d4c17f8ecda..917403910e4 100644 --- a/app/src/main/java/org/oppia/android/app/home/HomeActivity.kt +++ b/app/src/main/java/org/oppia/android/app/home/HomeActivity.kt @@ -13,6 +13,7 @@ import org.oppia.android.app.model.ExitProfileDialogArguments import org.oppia.android.app.model.HighlightItem import org.oppia.android.app.topic.TopicActivity import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl /** The central activity for all users entering the app. */ class HomeActivity : @@ -34,7 +35,7 @@ class HomeActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) internalProfileId = intent?.getIntExtra(NAVIGATION_PROFILE_ID_ARGUMENT_KEY, -1)!! homeActivityPresenter.handleOnCreate() title = getString(R.string.menu_home) diff --git a/app/src/main/java/org/oppia/android/app/home/HomeFragment.kt b/app/src/main/java/org/oppia/android/app/home/HomeFragment.kt index 1dd8bd40439..ae101094208 100644 --- a/app/src/main/java/org/oppia/android/app/home/HomeFragment.kt +++ b/app/src/main/java/org/oppia/android/app/home/HomeFragment.kt @@ -9,6 +9,7 @@ import org.oppia.android.app.fragment.InjectableFragment import org.oppia.android.app.home.topiclist.TopicSummaryClickListener import org.oppia.android.app.model.TopicSummary import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl /** Fragment that contains an introduction to the app. */ class HomeFragment : InjectableFragment(), TopicSummaryClickListener { @@ -16,7 +17,7 @@ class HomeFragment : InjectableFragment(), TopicSummaryClickListener { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/home/promotedlist/ComingSoonTopicsListView.kt b/app/src/main/java/org/oppia/android/app/home/promotedlist/ComingSoonTopicsListView.kt index e2b5dfff7b3..0ad5345f748 100644 --- a/app/src/main/java/org/oppia/android/app/home/promotedlist/ComingSoonTopicsListView.kt +++ b/app/src/main/java/org/oppia/android/app/home/promotedlist/ComingSoonTopicsListView.kt @@ -9,9 +9,10 @@ import androidx.recyclerview.widget.RecyclerView import org.oppia.android.app.recyclerview.BindableAdapter import org.oppia.android.app.recyclerview.StartSnapHelper import org.oppia.android.app.shim.ViewBindingShim -import org.oppia.android.app.shim.ViewComponentFactory +import org.oppia.android.app.view.ViewComponentFactory import org.oppia.android.domain.oppialogger.OppiaLogger import javax.inject.Inject +import org.oppia.android.app.view.ViewComponentImpl private const val COMING_SOON_TOPIC_LIST_VIEW_TAG = "ComingSoonTopicsListView" @@ -34,8 +35,9 @@ class ComingSoonTopicsListView @JvmOverloads constructor( override fun onAttachedToWindow() { super.onAttachedToWindow() - (FragmentManager.findFragment(this) as ViewComponentFactory) - .createViewComponent(this).inject(this) + val viewComponentFactory = FragmentManager.findFragment(this) as ViewComponentFactory + val viewComponent = viewComponentFactory.createViewComponent(this) as ViewComponentImpl + viewComponent.inject(this) // The StartSnapHelper is used to snap between items rather than smooth scrolling, so that // the item is completely visible in [HomeFragment] as soon as learner lifts the finger diff --git a/app/src/main/java/org/oppia/android/app/home/promotedlist/PromotedStoryListView.kt b/app/src/main/java/org/oppia/android/app/home/promotedlist/PromotedStoryListView.kt index ecd097f0db2..962ceb426f0 100644 --- a/app/src/main/java/org/oppia/android/app/home/promotedlist/PromotedStoryListView.kt +++ b/app/src/main/java/org/oppia/android/app/home/promotedlist/PromotedStoryListView.kt @@ -9,9 +9,10 @@ import androidx.recyclerview.widget.RecyclerView import org.oppia.android.app.recyclerview.BindableAdapter import org.oppia.android.app.recyclerview.StartSnapHelper import org.oppia.android.app.shim.ViewBindingShim -import org.oppia.android.app.shim.ViewComponentFactory +import org.oppia.android.app.view.ViewComponentFactory import org.oppia.android.domain.oppialogger.OppiaLogger import javax.inject.Inject +import org.oppia.android.app.view.ViewComponentImpl private const val PROMOTED_STORY_LIST_VIEW_TAG = "PromotedStoryListView" @@ -34,8 +35,9 @@ class PromotedStoryListView @JvmOverloads constructor( override fun onAttachedToWindow() { super.onAttachedToWindow() - (FragmentManager.findFragment(this) as ViewComponentFactory) - .createViewComponent(this).inject(this) + val viewComponentFactory = FragmentManager.findFragment(this) as ViewComponentFactory + val viewComponent = viewComponentFactory.createViewComponent(this) as ViewComponentImpl + viewComponent.inject(this) // The StartSnapHelper is used to snap between items rather than smooth scrolling, so that // the item is completely visible in [HomeFragment] as soon as learner lifts the finger diff --git a/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedActivity.kt b/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedActivity.kt index 04161dedd96..bed5b387e29 100644 --- a/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedActivity.kt +++ b/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedActivity.kt @@ -3,6 +3,7 @@ package org.oppia.android.app.home.recentlyplayed import android.content.Context import android.content.Intent import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity import org.oppia.android.app.activity.InjectableAppCompatActivity import org.oppia.android.app.home.RouteToExplorationListener import org.oppia.android.app.model.ExplorationCheckpoint @@ -10,6 +11,9 @@ import org.oppia.android.app.player.exploration.ExplorationActivity import org.oppia.android.app.resumelesson.ResumeLessonActivity import org.oppia.android.app.topic.RouteToResumeLessonListener import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl +import org.oppia.android.app.activity.ActivityIntentFactories +import org.oppia.android.app.model.ProfileId /** Activity for recent stories. */ class RecentlyPlayedActivity : @@ -22,7 +26,7 @@ class RecentlyPlayedActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) val internalProfileId = intent.getIntExtra( RECENTLY_PLAYED_ACTIVITY_INTERNAL_PROFILE_ID_KEY, -1 @@ -31,15 +35,14 @@ class RecentlyPlayedActivity : } companion object { - // TODO(#1655): Re-restrict access to fields in tests post-Gradle. - const val RECENTLY_PLAYED_ACTIVITY_INTERNAL_PROFILE_ID_KEY = + private const val RECENTLY_PLAYED_ACTIVITY_INTERNAL_PROFILE_ID_KEY = "RecentlyPlayedActivity.internal_profile_id" /** Returns a new [Intent] to route to [RecentlyPlayedActivity]. */ fun createRecentlyPlayedActivityIntent(context: Context, internalProfileId: Int): Intent { - val intent = Intent(context, RecentlyPlayedActivity::class.java) - intent.putExtra(RECENTLY_PLAYED_ACTIVITY_INTERNAL_PROFILE_ID_KEY, internalProfileId) - return intent + return Intent(context, RecentlyPlayedActivity::class.java).apply { + putExtra(RECENTLY_PLAYED_ACTIVITY_INTERNAL_PROFILE_ID_KEY, internalProfileId) + } } } @@ -84,4 +87,11 @@ class RecentlyPlayedActivity : ) ) } + + class RecentlyPlayedActivityIntentFactoryImpl @Inject constructor( + private val activity: AppCompatActivity + ): ActivityIntentFactories.RecentlyPlayedActivityIntentFactory { + override fun createIntent(profileId: ProfileId): Intent = + createRecentlyPlayedActivityIntent(activity, profileId.internalId) + } } diff --git a/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedFragment.kt b/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedFragment.kt index c11740ba0b0..bbf03828914 100644 --- a/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedFragment.kt +++ b/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedFragment.kt @@ -8,6 +8,7 @@ import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableFragment import org.oppia.android.app.model.PromotedStory import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl private const val RECENTLY_PLAYED_FRAGMENT_INTERNAL_PROFILE_ID_KEY = "RecentlyPlayedFragment.internal_profile_id" @@ -32,7 +33,7 @@ class RecentlyPlayedFragment : InjectableFragment(), OngoingStoryClickListener { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/mydownloads/DownloadsTabFragment.kt b/app/src/main/java/org/oppia/android/app/mydownloads/DownloadsTabFragment.kt index 3a40400215e..79ea1d725eb 100644 --- a/app/src/main/java/org/oppia/android/app/mydownloads/DownloadsTabFragment.kt +++ b/app/src/main/java/org/oppia/android/app/mydownloads/DownloadsTabFragment.kt @@ -7,6 +7,7 @@ import android.view.View import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableFragment import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl /** Fragment that contains downloaded topic list. */ class DownloadsTabFragment : InjectableFragment() { @@ -15,7 +16,7 @@ class DownloadsTabFragment : InjectableFragment() { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/mydownloads/MyDownloadsActivity.kt b/app/src/main/java/org/oppia/android/app/mydownloads/MyDownloadsActivity.kt index af2dfe820df..f9856714aa8 100644 --- a/app/src/main/java/org/oppia/android/app/mydownloads/MyDownloadsActivity.kt +++ b/app/src/main/java/org/oppia/android/app/mydownloads/MyDownloadsActivity.kt @@ -7,6 +7,7 @@ import org.oppia.android.app.activity.InjectableAppCompatActivity import org.oppia.android.app.drawer.NAVIGATION_PROFILE_ID_ARGUMENT_KEY import org.oppia.android.app.home.HomeActivity import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl /** The activity for displaying [MyDownloadsFragment]. */ class MyDownloadsActivity : InjectableAppCompatActivity() { @@ -16,7 +17,7 @@ class MyDownloadsActivity : InjectableAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) myDownloadsActivityPresenter.handleOnCreate() internalProfileId = intent.getIntExtra(NAVIGATION_PROFILE_ID_ARGUMENT_KEY, -1) } diff --git a/app/src/main/java/org/oppia/android/app/mydownloads/MyDownloadsFragment.kt b/app/src/main/java/org/oppia/android/app/mydownloads/MyDownloadsFragment.kt index 4d687e35a27..f808fd23544 100644 --- a/app/src/main/java/org/oppia/android/app/mydownloads/MyDownloadsFragment.kt +++ b/app/src/main/java/org/oppia/android/app/mydownloads/MyDownloadsFragment.kt @@ -7,6 +7,7 @@ import android.view.View import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableFragment import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl /** Fragment that contains tabs for MyDownloads. */ class MyDownloadsFragment : InjectableFragment() { @@ -15,7 +16,7 @@ class MyDownloadsFragment : InjectableFragment() { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/mydownloads/UpdatesTabFragment.kt b/app/src/main/java/org/oppia/android/app/mydownloads/UpdatesTabFragment.kt index ad7030bc806..d0fd06ddaa6 100644 --- a/app/src/main/java/org/oppia/android/app/mydownloads/UpdatesTabFragment.kt +++ b/app/src/main/java/org/oppia/android/app/mydownloads/UpdatesTabFragment.kt @@ -7,6 +7,7 @@ import android.view.View import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableFragment import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl /** Fragment that contains downloaded topic list that needs update. */ class UpdatesTabFragment : InjectableFragment() { @@ -15,7 +16,7 @@ class UpdatesTabFragment : InjectableFragment() { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/onboarding/OnboardingActivity.kt b/app/src/main/java/org/oppia/android/app/onboarding/OnboardingActivity.kt index afc1be5f891..1ae5444cf3a 100644 --- a/app/src/main/java/org/oppia/android/app/onboarding/OnboardingActivity.kt +++ b/app/src/main/java/org/oppia/android/app/onboarding/OnboardingActivity.kt @@ -6,6 +6,7 @@ import android.os.Bundle import org.oppia.android.app.activity.InjectableAppCompatActivity import org.oppia.android.app.profile.ProfileChooserActivity import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl /** Activity that contains the onboarding flow for learners. */ class OnboardingActivity : InjectableAppCompatActivity(), RouteToProfileListListener { @@ -21,7 +22,7 @@ class OnboardingActivity : InjectableAppCompatActivity(), RouteToProfileListList override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) onboardingActivityPresenter.handleOnCreate() } diff --git a/app/src/main/java/org/oppia/android/app/onboarding/OnboardingFragment.kt b/app/src/main/java/org/oppia/android/app/onboarding/OnboardingFragment.kt index caf502c8953..7f2557ec5df 100644 --- a/app/src/main/java/org/oppia/android/app/onboarding/OnboardingFragment.kt +++ b/app/src/main/java/org/oppia/android/app/onboarding/OnboardingFragment.kt @@ -7,6 +7,7 @@ import android.view.View import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableFragment import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl /** Fragment that contains an onboarding flow of the app. */ class OnboardingFragment : InjectableFragment() { @@ -15,7 +16,7 @@ class OnboardingFragment : InjectableFragment() { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/ongoingtopiclist/OngoingTopicListActivity.kt b/app/src/main/java/org/oppia/android/app/ongoingtopiclist/OngoingTopicListActivity.kt index c5ebe547f33..0fc99be106c 100644 --- a/app/src/main/java/org/oppia/android/app/ongoingtopiclist/OngoingTopicListActivity.kt +++ b/app/src/main/java/org/oppia/android/app/ongoingtopiclist/OngoingTopicListActivity.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.os.Bundle import org.oppia.android.app.activity.InjectableAppCompatActivity import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl /** Activity for ongoing topics. */ class OngoingTopicListActivity : InjectableAppCompatActivity() { @@ -13,7 +14,7 @@ class OngoingTopicListActivity : InjectableAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) val internalProfileId: Int = intent.getIntExtra( ONGOING_TOPIC_LIST_ACTIVITY_PROFILE_ID_KEY, -1 ) diff --git a/app/src/main/java/org/oppia/android/app/ongoingtopiclist/OngoingTopicListFragment.kt b/app/src/main/java/org/oppia/android/app/ongoingtopiclist/OngoingTopicListFragment.kt index 0368de982af..7307301da84 100644 --- a/app/src/main/java/org/oppia/android/app/ongoingtopiclist/OngoingTopicListFragment.kt +++ b/app/src/main/java/org/oppia/android/app/ongoingtopiclist/OngoingTopicListFragment.kt @@ -7,6 +7,7 @@ import android.view.View import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableFragment import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl /** Fragment for displaying [OngoingTopicListActivity]. */ class OngoingTopicListFragment : InjectableFragment() { @@ -32,7 +33,7 @@ class OngoingTopicListFragment : InjectableFragment() { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/options/AppLanguageActivity.kt b/app/src/main/java/org/oppia/android/app/options/AppLanguageActivity.kt index cf99b5c7f93..6d046cf35be 100644 --- a/app/src/main/java/org/oppia/android/app/options/AppLanguageActivity.kt +++ b/app/src/main/java/org/oppia/android/app/options/AppLanguageActivity.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.os.Bundle import org.oppia.android.app.activity.InjectableAppCompatActivity import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl /** The activity to change the language of the app. */ class AppLanguageActivity : InjectableAppCompatActivity() { @@ -15,7 +16,7 @@ class AppLanguageActivity : InjectableAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) prefKey = intent.getStringExtra(APP_LANGUAGE_PREFERENCE_TITLE_EXTRA_KEY) prefSummaryValue = if (savedInstanceState == null) { intent.getStringExtra(APP_LANGUAGE_PREFERENCE_SUMMARY_VALUE_EXTRA_KEY) diff --git a/app/src/main/java/org/oppia/android/app/options/AppLanguageFragment.kt b/app/src/main/java/org/oppia/android/app/options/AppLanguageFragment.kt index 09318ad90fe..dc0883c206f 100644 --- a/app/src/main/java/org/oppia/android/app/options/AppLanguageFragment.kt +++ b/app/src/main/java/org/oppia/android/app/options/AppLanguageFragment.kt @@ -7,6 +7,7 @@ import android.view.View import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableFragment import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl private const val APP_LANGUAGE_PREFERENCE_TITLE_ARGUMENT_KEY = "AppLanguageFragment.app_language_preference_title" @@ -35,7 +36,7 @@ class AppLanguageFragment : override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/options/AudioLanguageActivity.kt b/app/src/main/java/org/oppia/android/app/options/AudioLanguageActivity.kt index d479a677d1a..a3b1351c89c 100644 --- a/app/src/main/java/org/oppia/android/app/options/AudioLanguageActivity.kt +++ b/app/src/main/java/org/oppia/android/app/options/AudioLanguageActivity.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.os.Bundle import org.oppia.android.app.activity.InjectableAppCompatActivity import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl /** The activity to change the Default Audio language of the app. */ class AudioLanguageActivity : InjectableAppCompatActivity() { @@ -16,7 +17,7 @@ class AudioLanguageActivity : InjectableAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) prefKey = intent.getStringExtra(AUDIO_LANGUAGE_PREFERENCE_TITLE_EXTRA_KEY) prefSummaryValue = if (savedInstanceState != null) { savedInstanceState.get(AUDIO_LANGUAGE_PREFERENCE_SUMMARY_VALUE_EXTRA_KEY) as String diff --git a/app/src/main/java/org/oppia/android/app/options/AudioLanguageFragment.kt b/app/src/main/java/org/oppia/android/app/options/AudioLanguageFragment.kt index 8c3e9222969..9a095a1f47b 100644 --- a/app/src/main/java/org/oppia/android/app/options/AudioLanguageFragment.kt +++ b/app/src/main/java/org/oppia/android/app/options/AudioLanguageFragment.kt @@ -7,6 +7,7 @@ import android.view.View import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableFragment import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl private const val AUDIO_LANGUAGE_PREFERENCE_TITLE_ARGUMENT_KEY = "AudioLanguageFragment.audio_language_preference_title" @@ -36,7 +37,7 @@ class AudioLanguageFragment : override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/options/OptionsActivity.kt b/app/src/main/java/org/oppia/android/app/options/OptionsActivity.kt index 71085618275..5136ef00a52 100644 --- a/app/src/main/java/org/oppia/android/app/options/OptionsActivity.kt +++ b/app/src/main/java/org/oppia/android/app/options/OptionsActivity.kt @@ -8,6 +8,7 @@ import org.oppia.android.R import org.oppia.android.app.activity.InjectableAppCompatActivity import org.oppia.android.app.drawer.NAVIGATION_PROFILE_ID_ARGUMENT_KEY import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl private const val SELECTED_OPTIONS_TITLE_SAVED_KEY = "OptionsActivity.selected_options_title" private const val SELECTED_FRAGMENT_SAVED_KEY = "OptionsActivity.selected_fragment" @@ -50,7 +51,7 @@ class OptionsActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) val isFromNavigationDrawer = intent.getBooleanExtra( BOOL_IS_FROM_NAVIGATION_DRAWER_EXTRA_KEY, /* defaultValue= */ false diff --git a/app/src/main/java/org/oppia/android/app/options/OptionsFragment.kt b/app/src/main/java/org/oppia/android/app/options/OptionsFragment.kt index 4c52acd54c7..882b2266f6a 100644 --- a/app/src/main/java/org/oppia/android/app/options/OptionsFragment.kt +++ b/app/src/main/java/org/oppia/android/app/options/OptionsFragment.kt @@ -7,6 +7,7 @@ import android.view.View import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableFragment import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl const val MESSAGE_READING_TEXT_SIZE_ARGUMENT_KEY = "OptionsFragment.message_reading_text_size" const val MESSAGE_APP_LANGUAGE_ARGUMENT_KEY = "OptionsFragment.message_app_language" @@ -42,7 +43,7 @@ class OptionsFragment : InjectableFragment() { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/options/ReadingTextSizeActivity.kt b/app/src/main/java/org/oppia/android/app/options/ReadingTextSizeActivity.kt index b5e1343c0c2..eb8e5d2a0fc 100644 --- a/app/src/main/java/org/oppia/android/app/options/ReadingTextSizeActivity.kt +++ b/app/src/main/java/org/oppia/android/app/options/ReadingTextSizeActivity.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.os.Bundle import org.oppia.android.app.activity.InjectableAppCompatActivity import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl /** The activity to change the text size of the reading content in the app. */ class ReadingTextSizeActivity : InjectableAppCompatActivity() { @@ -16,7 +17,7 @@ class ReadingTextSizeActivity : InjectableAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) prefKey = intent.getStringExtra(KEY_READING_TEXT_SIZE_PREFERENCE_TITLE) prefSummaryValue = ( if (savedInstanceState != null) { diff --git a/app/src/main/java/org/oppia/android/app/options/ReadingTextSizeFragment.kt b/app/src/main/java/org/oppia/android/app/options/ReadingTextSizeFragment.kt index 9f24331077f..9d98f6d1af4 100644 --- a/app/src/main/java/org/oppia/android/app/options/ReadingTextSizeFragment.kt +++ b/app/src/main/java/org/oppia/android/app/options/ReadingTextSizeFragment.kt @@ -7,6 +7,7 @@ import android.view.View import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableFragment import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl private const val READING_TEXT_SIZE_PREFERENCE_SUMMARY_VALUE_ARGUMENT_KEY = "ReadingTextSizeFragment.reading_text_size_preference_summary_value" @@ -30,7 +31,7 @@ class ReadingTextSizeFragment : InjectableFragment(), TextSizeRadioButtonListene override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/player/audio/AudioFragment.kt b/app/src/main/java/org/oppia/android/app/player/audio/AudioFragment.kt index 66c687c0180..933845fbfc9 100755 --- a/app/src/main/java/org/oppia/android/app/player/audio/AudioFragment.kt +++ b/app/src/main/java/org/oppia/android/app/player/audio/AudioFragment.kt @@ -8,6 +8,7 @@ import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableFragment import org.oppia.android.app.model.State import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl /** Fragment that controls audio for a content-card. */ class AudioFragment : @@ -35,7 +36,7 @@ class AudioFragment : override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivity.kt b/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivity.kt index 98b3dd8acd4..c3ee8c8048f 100755 --- a/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivity.kt +++ b/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivity.kt @@ -20,6 +20,7 @@ import org.oppia.android.app.player.state.listener.StateKeyboardButtonListener import org.oppia.android.app.player.stopplaying.StopStatePlayingSessionWithSavedProgressListener import org.oppia.android.app.topic.conceptcard.ConceptCardListener import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl const val TAG_HINTS_AND_SOLUTION_DIALOG = "HINTS_AND_SOLUTION_DIALOG" @@ -49,7 +50,7 @@ class ExplorationActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) internalProfileId = intent.getIntExtra(EXPLORATION_ACTIVITY_PROFILE_ID_ARGUMENT_KEY, -1) topicId = intent.getStringExtra(EXPLORATION_ACTIVITY_TOPIC_ID_ARGUMENT_KEY) storyId = intent.getStringExtra(EXPLORATION_ACTIVITY_STORY_ID_ARGUMENT_KEY) diff --git a/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationFragment.kt b/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationFragment.kt index 0ef77ff9b72..eee860760d5 100755 --- a/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationFragment.kt +++ b/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationFragment.kt @@ -8,6 +8,7 @@ import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableFragment import org.oppia.android.app.utility.FontScaleConfigurationUtil import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl /** Fragment that contains displays single exploration. */ class ExplorationFragment : InjectableFragment() { @@ -58,7 +59,7 @@ class ExplorationFragment : InjectableFragment() { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) val readingTextSize = arguments!!.getString(STORY_DEFAULT_FONT_SIZE_ARGUMENT_KEY) checkNotNull(readingTextSize) { "ExplorationFragment must be created with a reading text size" } diff --git a/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationManagerFragment.kt b/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationManagerFragment.kt index 0cc7d2b2b83..269b8ca4fc4 100644 --- a/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationManagerFragment.kt +++ b/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationManagerFragment.kt @@ -4,6 +4,7 @@ import android.content.Context import android.os.Bundle import org.oppia.android.app.fragment.InjectableFragment import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl /** * ManagerFragment of [ExplorationFragment] that observes data provider that retrieve default story @@ -15,7 +16,7 @@ class ExplorationManagerFragment : InjectableFragment() { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreate(savedInstanceState: Bundle?) { diff --git a/app/src/main/java/org/oppia/android/app/player/exploration/HintsAndSolutionExplorationManagerFragment.kt b/app/src/main/java/org/oppia/android/app/player/exploration/HintsAndSolutionExplorationManagerFragment.kt index 2840d2d4f55..76df5305473 100644 --- a/app/src/main/java/org/oppia/android/app/player/exploration/HintsAndSolutionExplorationManagerFragment.kt +++ b/app/src/main/java/org/oppia/android/app/player/exploration/HintsAndSolutionExplorationManagerFragment.kt @@ -7,6 +7,7 @@ import android.view.View import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableFragment import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl /** * ManagerFragment of [ExplorationFragment] that observes data provider that retrieve Exploration State. @@ -18,7 +19,7 @@ class HintsAndSolutionExplorationManagerFragment : InjectableFragment() { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/player/state/DragDropSortInteractionView.kt b/app/src/main/java/org/oppia/android/app/player/state/DragDropSortInteractionView.kt index 3e080235d05..7092c3e4c7c 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/DragDropSortInteractionView.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/DragDropSortInteractionView.kt @@ -15,12 +15,13 @@ import org.oppia.android.app.recyclerview.DragAndDropItemFacilitator import org.oppia.android.app.recyclerview.OnDragEndedListener import org.oppia.android.app.recyclerview.OnItemDragListener import org.oppia.android.app.shim.ViewBindingShim -import org.oppia.android.app.shim.ViewComponentFactory +import org.oppia.android.app.view.ViewComponentFactory import org.oppia.android.util.accessibility.AccessibilityChecker import org.oppia.android.util.gcsresource.DefaultResourceBucketName import org.oppia.android.util.parser.html.ExplorationHtmlParserEntityType import org.oppia.android.util.parser.html.HtmlParser import javax.inject.Inject +import org.oppia.android.app.view.ViewComponentImpl /** * A custom [RecyclerView] for displaying a list of items that can be re-ordered using @@ -58,8 +59,11 @@ class DragDropSortInteractionView @JvmOverloads constructor( override fun onAttachedToWindow() { super.onAttachedToWindow() - (FragmentManager.findFragment(this) as ViewComponentFactory) - .createViewComponent(this).inject(this) + + val viewComponentFactory = FragmentManager.findFragment(this) as ViewComponentFactory + val viewComponent = viewComponentFactory.createViewComponent(this) as ViewComponentImpl + viewComponent.inject(this) + isAccessibilityEnabled = accessibilityChecker.isScreenReaderEnabled() } diff --git a/app/src/main/java/org/oppia/android/app/player/state/ImageRegionSelectionInteractionView.kt b/app/src/main/java/org/oppia/android/app/player/state/ImageRegionSelectionInteractionView.kt index 8f81c687160..d0883b9b75c 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/ImageRegionSelectionInteractionView.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/ImageRegionSelectionInteractionView.kt @@ -10,7 +10,7 @@ import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import org.oppia.android.app.model.ImageWithRegions import org.oppia.android.app.shim.ViewBindingShim -import org.oppia.android.app.shim.ViewComponentFactory +import org.oppia.android.app.view.ViewComponentFactory import org.oppia.android.app.utility.ClickableAreasImage import org.oppia.android.app.utility.OnClickableAreaClickedListener import org.oppia.android.util.accessibility.AccessibilityChecker @@ -21,6 +21,7 @@ import org.oppia.android.util.parser.image.ImageDownloadUrlTemplate import org.oppia.android.util.parser.image.ImageLoader import org.oppia.android.util.parser.image.ImageViewTarget import javax.inject.Inject +import org.oppia.android.app.view.ViewComponentImpl /** * A custom [AppCompatImageView] with a list of [LabeledRegion] to work with @@ -129,9 +130,11 @@ class ImageRegionSelectionInteractionView @JvmOverloads constructor( override fun onAttachedToWindow() { super.onAttachedToWindow() - (FragmentManager.findFragment(this) as ViewComponentFactory) - .createViewComponent(this) - .inject(this) + + val viewComponentFactory = FragmentManager.findFragment(this) as ViewComponentFactory + val viewComponent = viewComponentFactory.createViewComponent(this) as ViewComponentImpl + viewComponent.inject(this) + isAccessibilityEnabled = accessibilityChecker.isScreenReaderEnabled() } diff --git a/app/src/main/java/org/oppia/android/app/player/state/SelectionInteractionView.kt b/app/src/main/java/org/oppia/android/app/player/state/SelectionInteractionView.kt index 8318796e27c..b0147626397 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/SelectionInteractionView.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/SelectionInteractionView.kt @@ -11,11 +11,12 @@ import org.oppia.android.app.player.state.itemviewmodel.SelectionInteractionCont import org.oppia.android.app.player.state.itemviewmodel.SelectionItemInputType import org.oppia.android.app.recyclerview.BindableAdapter import org.oppia.android.app.shim.ViewBindingShim -import org.oppia.android.app.shim.ViewComponentFactory +import org.oppia.android.app.view.ViewComponentFactory import org.oppia.android.util.gcsresource.DefaultResourceBucketName import org.oppia.android.util.parser.html.ExplorationHtmlParserEntityType import org.oppia.android.util.parser.html.HtmlParser import javax.inject.Inject +import org.oppia.android.app.view.ViewComponentImpl /** * A custom [RecyclerView] for displaying a variable list of items that may be selected by a user as part of the item @@ -47,8 +48,10 @@ class SelectionInteractionView @JvmOverloads constructor( override fun onAttachedToWindow() { super.onAttachedToWindow() - (FragmentManager.findFragment(this) as ViewComponentFactory) - .createViewComponent(this).inject(this) + + val viewComponentFactory = FragmentManager.findFragment(this) as ViewComponentFactory + val viewComponent = viewComponentFactory.createViewComponent(this) as ViewComponentImpl + viewComponent.inject(this) } fun setAllOptionsItemInputType(selectionItemInputType: SelectionItemInputType) { diff --git a/app/src/main/java/org/oppia/android/app/player/state/StateFragment.kt b/app/src/main/java/org/oppia/android/app/player/state/StateFragment.kt index 1d3707a1f21..4488ec806ce 100755 --- a/app/src/main/java/org/oppia/android/app/player/state/StateFragment.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/StateFragment.kt @@ -19,6 +19,7 @@ import org.oppia.android.app.player.state.listener.ReturnToTopicNavigationButton import org.oppia.android.app.player.state.listener.ShowHintAvailabilityListener import org.oppia.android.app.player.state.listener.SubmitNavigationButtonListener import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl /** Fragment that represents the current state of an exploration. */ class StateFragment : @@ -64,7 +65,7 @@ class StateFragment : override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/player/state/testing/StateFragmentTestActivity.kt b/app/src/main/java/org/oppia/android/app/player/state/testing/StateFragmentTestActivity.kt index 70c28526d95..d26a0c9cc93 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/testing/StateFragmentTestActivity.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/testing/StateFragmentTestActivity.kt @@ -17,6 +17,7 @@ import org.oppia.android.app.player.state.listener.RouteToHintsAndSolutionListen import org.oppia.android.app.player.state.listener.StateKeyboardButtonListener import org.oppia.android.app.player.stopplaying.StopStatePlayingSessionWithSavedProgressListener import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl internal const val TEST_ACTIVITY_PROFILE_ID_EXTRA_KEY = "StateFragmentTestActivity.test_activity_profile_id" @@ -46,7 +47,7 @@ class StateFragmentTestActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) stateFragmentTestActivityPresenter.handleOnCreate() } diff --git a/app/src/main/java/org/oppia/android/app/profile/AddProfileActivity.kt b/app/src/main/java/org/oppia/android/app/profile/AddProfileActivity.kt index 4e959e53ea1..48c92010925 100644 --- a/app/src/main/java/org/oppia/android/app/profile/AddProfileActivity.kt +++ b/app/src/main/java/org/oppia/android/app/profile/AddProfileActivity.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.os.Bundle import org.oppia.android.app.activity.InjectableAppCompatActivity import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl const val ADD_PROFILE_COLOR_RGB_EXTRA_KEY = "AddProfileActivity.add_profile_color_rgb" @@ -23,7 +24,7 @@ class AddProfileActivity : InjectableAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) addProfileFragmentPresenter.handleOnCreate() } diff --git a/app/src/main/java/org/oppia/android/app/profile/AdminAuthActivity.kt b/app/src/main/java/org/oppia/android/app/profile/AdminAuthActivity.kt index 9e9f8301951..a6297cf3ba0 100644 --- a/app/src/main/java/org/oppia/android/app/profile/AdminAuthActivity.kt +++ b/app/src/main/java/org/oppia/android/app/profile/AdminAuthActivity.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.os.Bundle import org.oppia.android.app.activity.InjectableAppCompatActivity import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl const val ADMIN_AUTH_ADMIN_PIN_EXTRA_KEY = "AdminAuthActivity.admin_auth_admin_pin" const val ADMIN_AUTH_COLOR_RGB_EXTRA_KEY = "AdminAuthActivity.admin_auth_color_rgb" @@ -39,7 +40,7 @@ class AdminAuthActivity : InjectableAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) adminAuthFragmentPresenter.handleOnCreate() } diff --git a/app/src/main/java/org/oppia/android/app/profile/AdminPinActivity.kt b/app/src/main/java/org/oppia/android/app/profile/AdminPinActivity.kt index e92fb78eb65..2e7f8e78b55 100644 --- a/app/src/main/java/org/oppia/android/app/profile/AdminPinActivity.kt +++ b/app/src/main/java/org/oppia/android/app/profile/AdminPinActivity.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.os.Bundle import org.oppia.android.app.activity.InjectableAppCompatActivity import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl const val ADMIN_PIN_PROFILE_ID_EXTRA_KEY = "AdminPinActivity.admin_pin_profile_id" const val ADMIN_PIN_COLOR_RGB_EXTRA_KEY = "AdminPinActivity.admin_pin_color_rgb" @@ -32,7 +33,7 @@ class AdminPinActivity : InjectableAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) adminPinActivityPresenter.handleOnCreate() } diff --git a/app/src/main/java/org/oppia/android/app/profile/AdminSettingsDialogFragment.kt b/app/src/main/java/org/oppia/android/app/profile/AdminSettingsDialogFragment.kt index a1f65a68c53..3dc4b1b0731 100644 --- a/app/src/main/java/org/oppia/android/app/profile/AdminSettingsDialogFragment.kt +++ b/app/src/main/java/org/oppia/android/app/profile/AdminSettingsDialogFragment.kt @@ -5,6 +5,7 @@ import android.content.Context import android.os.Bundle import org.oppia.android.app.fragment.InjectableDialogFragment import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl const val ADMIN_SETTINGS_PIN_ARGUMENT_KEY = "AdminSettingsDialogFragment.admin_settings_pin" @@ -25,7 +26,7 @@ class AdminSettingsDialogFragment : InjectableDialogFragment() { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { diff --git a/app/src/main/java/org/oppia/android/app/profile/PinPasswordActivity.kt b/app/src/main/java/org/oppia/android/app/profile/PinPasswordActivity.kt index 17ca1ff8368..981d9ed6616 100644 --- a/app/src/main/java/org/oppia/android/app/profile/PinPasswordActivity.kt +++ b/app/src/main/java/org/oppia/android/app/profile/PinPasswordActivity.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.os.Bundle import org.oppia.android.app.activity.InjectableAppCompatActivity import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl const val PIN_PASSWORD_PROFILE_ID_EXTRA_KEY = "PinPasswordActivity.pin_password_profile_id" const val PIN_PASSWORD_ADMIN_PIN_EXTRA_KEY = "PinPasswordActivity.pin_password_admin_pin" @@ -29,7 +30,7 @@ class PinPasswordActivity : InjectableAppCompatActivity(), ProfileRouteDialogInt override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) pinPasswordActivityPresenter.handleOnCreate() } diff --git a/app/src/main/java/org/oppia/android/app/profile/ProfileChooserActivity.kt b/app/src/main/java/org/oppia/android/app/profile/ProfileChooserActivity.kt index 540bb15f839..8c97a0e2db0 100644 --- a/app/src/main/java/org/oppia/android/app/profile/ProfileChooserActivity.kt +++ b/app/src/main/java/org/oppia/android/app/profile/ProfileChooserActivity.kt @@ -3,8 +3,9 @@ package org.oppia.android.app.profile import android.content.Context import android.content.Intent import android.os.Bundle -import org.oppia.android.app.activity.InjectableAppCompatActivity import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl +import org.oppia.android.app.activity.InjectableAppCompatActivity /** Activity that controls profile creation and selection. */ class ProfileChooserActivity : InjectableAppCompatActivity() { @@ -21,7 +22,7 @@ class ProfileChooserActivity : InjectableAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) profileChooserActivityPresenter.handleOnCreate() } } diff --git a/app/src/main/java/org/oppia/android/app/profile/ProfileChooserFragment.kt b/app/src/main/java/org/oppia/android/app/profile/ProfileChooserFragment.kt index 681d88c3c43..70dbcab456e 100644 --- a/app/src/main/java/org/oppia/android/app/profile/ProfileChooserFragment.kt +++ b/app/src/main/java/org/oppia/android/app/profile/ProfileChooserFragment.kt @@ -7,6 +7,7 @@ import android.view.View import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableFragment import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl /** Fragment that allows user to select a profile or create new ones. */ class ProfileChooserFragment : InjectableFragment(), RouteToAdminPinListener { @@ -15,7 +16,7 @@ class ProfileChooserFragment : InjectableFragment(), RouteToAdminPinListener { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/profile/ResetPinDialogFragment.kt b/app/src/main/java/org/oppia/android/app/profile/ResetPinDialogFragment.kt index 16e3aaf7378..e5f7ee0ca46 100644 --- a/app/src/main/java/org/oppia/android/app/profile/ResetPinDialogFragment.kt +++ b/app/src/main/java/org/oppia/android/app/profile/ResetPinDialogFragment.kt @@ -5,6 +5,7 @@ import android.content.Context import android.os.Bundle import org.oppia.android.app.fragment.InjectableDialogFragment import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl const val RESET_PIN_PROFILE_ID_ARGUMENT_KEY = "ResetPinDialogFragment.reset_pin_profile_id" const val RESET_PIN_NAME_ARGUMENT_KEY = "ResetPinDialogFragment.reset_pin_name" @@ -27,7 +28,7 @@ class ResetPinDialogFragment : InjectableDialogFragment() { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { diff --git a/app/src/main/java/org/oppia/android/app/profileprogress/ProfilePictureActivity.kt b/app/src/main/java/org/oppia/android/app/profileprogress/ProfilePictureActivity.kt index fd1a660e70a..87e7acff83e 100644 --- a/app/src/main/java/org/oppia/android/app/profileprogress/ProfilePictureActivity.kt +++ b/app/src/main/java/org/oppia/android/app/profileprogress/ProfilePictureActivity.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.os.Bundle import org.oppia.android.app.activity.InjectableAppCompatActivity import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl /** Activity to display profile picture. */ class ProfilePictureActivity : InjectableAppCompatActivity() { @@ -14,7 +15,7 @@ class ProfilePictureActivity : InjectableAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) val internalProfileId = intent.getIntExtra( PROFILE_PICTURE_ACTIVITY_PROFILE_ID_KEY, -1 ) diff --git a/app/src/main/java/org/oppia/android/app/profileprogress/ProfileProgressActivity.kt b/app/src/main/java/org/oppia/android/app/profileprogress/ProfileProgressActivity.kt index b1c248cbd8a..f090cbeb03c 100644 --- a/app/src/main/java/org/oppia/android/app/profileprogress/ProfileProgressActivity.kt +++ b/app/src/main/java/org/oppia/android/app/profileprogress/ProfileProgressActivity.kt @@ -9,6 +9,7 @@ import org.oppia.android.app.home.RouteToRecentlyPlayedListener import org.oppia.android.app.home.recentlyplayed.RecentlyPlayedActivity import org.oppia.android.app.ongoingtopiclist.OngoingTopicListActivity import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl /** Activity to display profile progress. */ class ProfileProgressActivity : @@ -24,7 +25,7 @@ class ProfileProgressActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) internalProfileId = intent.getIntExtra(PROFILE_PROGRESS_ACTIVITY_PROFILE_ID_KEY, -1) profileProgressActivityPresenter.handleOnCreate(internalProfileId) } diff --git a/app/src/main/java/org/oppia/android/app/profileprogress/ProfileProgressFragment.kt b/app/src/main/java/org/oppia/android/app/profileprogress/ProfileProgressFragment.kt index 545833decce..4b4ada48289 100644 --- a/app/src/main/java/org/oppia/android/app/profileprogress/ProfileProgressFragment.kt +++ b/app/src/main/java/org/oppia/android/app/profileprogress/ProfileProgressFragment.kt @@ -7,6 +7,7 @@ import android.view.View import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableFragment import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl /** Fragment that displays profile progress in the app. */ class ProfileProgressFragment : @@ -31,7 +32,7 @@ class ProfileProgressFragment : override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/resumelesson/ResumeLessonActivity.kt b/app/src/main/java/org/oppia/android/app/resumelesson/ResumeLessonActivity.kt index 5592ca5b4c0..ef4761cc51a 100644 --- a/app/src/main/java/org/oppia/android/app/resumelesson/ResumeLessonActivity.kt +++ b/app/src/main/java/org/oppia/android/app/resumelesson/ResumeLessonActivity.kt @@ -8,6 +8,7 @@ import org.oppia.android.app.home.RouteToExplorationListener import org.oppia.android.app.model.ExplorationCheckpoint import org.oppia.android.app.player.exploration.ExplorationActivity import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl /** Activity that allows the user to resume a saved exploration. */ class ResumeLessonActivity : InjectableAppCompatActivity(), RouteToExplorationListener { @@ -23,7 +24,7 @@ class ResumeLessonActivity : InjectableAppCompatActivity(), RouteToExplorationLi override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) internalProfileId = intent.getIntExtra(RESUME_LESSON_ACTIVITY_INTERNAL_PROFILE_ID_ARGUMENT_KEY, -1) topicId = diff --git a/app/src/main/java/org/oppia/android/app/resumelesson/ResumeLessonFragment.kt b/app/src/main/java/org/oppia/android/app/resumelesson/ResumeLessonFragment.kt index 3694ae94ad1..bb09c5c387c 100644 --- a/app/src/main/java/org/oppia/android/app/resumelesson/ResumeLessonFragment.kt +++ b/app/src/main/java/org/oppia/android/app/resumelesson/ResumeLessonFragment.kt @@ -10,6 +10,7 @@ import org.oppia.android.app.model.ExplorationCheckpoint import org.oppia.android.util.extensions.getProto import org.oppia.android.util.extensions.putProto import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl /** Fragment that allows the user to resume a saved exploration. */ class ResumeLessonFragment : InjectableFragment() { @@ -60,7 +61,7 @@ class ResumeLessonFragment : InjectableFragment() { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/settings/profile/ProfileEditActivity.kt b/app/src/main/java/org/oppia/android/app/settings/profile/ProfileEditActivity.kt index 2203f060674..a6d7ada45d2 100644 --- a/app/src/main/java/org/oppia/android/app/settings/profile/ProfileEditActivity.kt +++ b/app/src/main/java/org/oppia/android/app/settings/profile/ProfileEditActivity.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.os.Bundle import org.oppia.android.app.activity.InjectableAppCompatActivity import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl const val PROFILE_EDIT_PROFILE_ID_EXTRA_KEY = "ProfileEditActivity.profile_edit_profile_id" const val IS_MULTIPANE_EXTRA_KEY = "ProfileEditActivity.is_multipane" @@ -31,7 +32,7 @@ class ProfileEditActivity : InjectableAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) profileEditActivityPresenter.handleOnCreate() } diff --git a/app/src/main/java/org/oppia/android/app/settings/profile/ProfileEditFragment.kt b/app/src/main/java/org/oppia/android/app/settings/profile/ProfileEditFragment.kt index 28f5563758f..d5e639427ea 100644 --- a/app/src/main/java/org/oppia/android/app/settings/profile/ProfileEditFragment.kt +++ b/app/src/main/java/org/oppia/android/app/settings/profile/ProfileEditFragment.kt @@ -7,6 +7,7 @@ import android.view.View import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableFragment import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl /** Fragment that contains Profile Edit Screen. */ class ProfileEditFragment : InjectableFragment(), ProfileEditDialogInterface { @@ -27,7 +28,7 @@ class ProfileEditFragment : InjectableFragment(), ProfileEditDialogInterface { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/settings/profile/ProfileListActivity.kt b/app/src/main/java/org/oppia/android/app/settings/profile/ProfileListActivity.kt index defa87b9193..2b1fe1a1a9e 100644 --- a/app/src/main/java/org/oppia/android/app/settings/profile/ProfileListActivity.kt +++ b/app/src/main/java/org/oppia/android/app/settings/profile/ProfileListActivity.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.os.Bundle import org.oppia.android.app.activity.InjectableAppCompatActivity import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl /** Activity to display all profiles to admin. */ class ProfileListActivity : InjectableAppCompatActivity() { @@ -13,7 +14,7 @@ class ProfileListActivity : InjectableAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) profileListActivityPresenter.handleOnCreate() } diff --git a/app/src/main/java/org/oppia/android/app/settings/profile/ProfileListFragment.kt b/app/src/main/java/org/oppia/android/app/settings/profile/ProfileListFragment.kt index f6b2bfa5273..6957e592662 100644 --- a/app/src/main/java/org/oppia/android/app/settings/profile/ProfileListFragment.kt +++ b/app/src/main/java/org/oppia/android/app/settings/profile/ProfileListFragment.kt @@ -7,6 +7,7 @@ import android.view.View import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableFragment import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl private const val IS_MULTIPANE_ARGUMENT_KEY = "ProfileListFragment.is_multipane" @@ -27,7 +28,7 @@ class ProfileListFragment : InjectableFragment() { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/settings/profile/ProfileRenameActivity.kt b/app/src/main/java/org/oppia/android/app/settings/profile/ProfileRenameActivity.kt index d70170a6781..e746da148e8 100644 --- a/app/src/main/java/org/oppia/android/app/settings/profile/ProfileRenameActivity.kt +++ b/app/src/main/java/org/oppia/android/app/settings/profile/ProfileRenameActivity.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.os.Bundle import org.oppia.android.app.activity.InjectableAppCompatActivity import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl const val PROFILE_RENAME_PROFILE_ID_EXTRA_KEY = "ProfileRenameActivity.profile_rename_profile_id" @@ -23,7 +24,7 @@ class ProfileRenameActivity : InjectableAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) profileRenameActivityPresenter.handleOnCreate() } diff --git a/app/src/main/java/org/oppia/android/app/settings/profile/ProfileResetPinActivity.kt b/app/src/main/java/org/oppia/android/app/settings/profile/ProfileResetPinActivity.kt index 88475887c28..c3dd35852eb 100644 --- a/app/src/main/java/org/oppia/android/app/settings/profile/ProfileResetPinActivity.kt +++ b/app/src/main/java/org/oppia/android/app/settings/profile/ProfileResetPinActivity.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.os.Bundle import org.oppia.android.app.activity.InjectableAppCompatActivity import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl const val PROFILE_RESET_PIN_PROFILE_ID_EXTRA_KEY = "ProfileResetPinActivity.profile_reset_pin_profile_id" @@ -27,7 +28,7 @@ class ProfileResetPinActivity : InjectableAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) profileResetPinActivityPresenter.handleOnCreate() } diff --git a/app/src/main/java/org/oppia/android/app/shim/BUILD.bazel b/app/src/main/java/org/oppia/android/app/shim/BUILD.bazel new file mode 100644 index 00000000000..2e1e39d4d61 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/shim/BUILD.bazel @@ -0,0 +1,85 @@ +""" +Temporary shims for providing indirection in the Bazel build graph to unblock modularizing certain +parts of the app layer. +""" + +load("@dagger//:workspace_defs.bzl", "dagger_rules") +load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_android_library") + +IMPL_FILES = [ + "IntentFactoryShimImpl.kt", + "ViewBindingShimImpl.kt", +] + +# TODO(#1617): Remove genrules post-gradle +[ + genrule( + name = "update_" + file[0:-3], + srcs = [file], + outs = [file[0:-3] + "_updated.kt"], + cmd = """ + cat $(SRCS) | + sed 's/import org.oppia.android.R/import org.oppia.android.app.databinding.R/g' | + sed 's/import org.oppia.android.databinding./import org.oppia.android.app.databinding.databinding./g' > $(OUTS) + """, + ) + for file in IMPL_FILES +] + +# Files to be built by the app library. +UPDATED_IMPL_FILES = [ + "update_" + file_with_resource_imports[0:-3] + for file_with_resource_imports in IMPL_FILES +] + +kt_android_library( + name = "intent_factory_shim", + srcs = [ + "IntentFactoryShim.kt", + ], + visibility = ["//app:app_visibility"], + deps = [ + "//third_party:androidx_appcompat_appcompat", + ], +) + +kt_android_library( + name = "view_binding_shim", + srcs = [ + "ViewBindingShim.kt", + ], + visibility = ["//app:app_visibility"], + deps = [ + "//app:view_models", + ], +) + +kt_android_library( + name = "impl", + srcs = UPDATED_IMPL_FILES, + deps = [ + ":dagger", + ":intent_factory_shim", + ":view_binding_shim", + "//app:databinding_resources", + "//app/src/main/java/org/oppia/android/app/activity:activity_intent_factories_shim", + "//utility", + ], +) + +kt_android_library( + name = "prod_modules", + srcs = [ + "IntentFactoryShimModule.kt", + "ViewBindingShimModule.kt", + ], + visibility = ["//:oppia_prod_module_visibility"], + deps = [ + ":dagger", + ":impl", + ":intent_factory_shim", + ":view_binding_shim", + ], +) + +dagger_rules() diff --git a/app/src/main/java/org/oppia/android/app/shim/IntentFactoryShim.kt b/app/src/main/java/org/oppia/android/app/shim/IntentFactoryShim.kt index ff7a38f9e01..64920ae8753 100644 --- a/app/src/main/java/org/oppia/android/app/shim/IntentFactoryShim.kt +++ b/app/src/main/java/org/oppia/android/app/shim/IntentFactoryShim.kt @@ -14,10 +14,6 @@ import androidx.fragment.app.FragmentActivity */ // TODO(#1619): Remove file post-Gradle interface IntentFactoryShim { - - /** Returns [ProfileChooserActivity] intent for [AdministratorControlsAccountActionsViewModel]. */ - fun createProfileChooserActivityIntent(fragment: FragmentActivity): Intent - /** * Creates a [TopicActivity] intent for [PromotedStoryViewModel] and passes necessary string * data. diff --git a/app/src/main/java/org/oppia/android/app/shim/IntentFactoryShimImpl.kt b/app/src/main/java/org/oppia/android/app/shim/IntentFactoryShimImpl.kt index 1e3fbac7eaf..186863989c3 100644 --- a/app/src/main/java/org/oppia/android/app/shim/IntentFactoryShimImpl.kt +++ b/app/src/main/java/org/oppia/android/app/shim/IntentFactoryShimImpl.kt @@ -2,12 +2,10 @@ package org.oppia.android.app.shim import android.content.Context import android.content.Intent -import androidx.fragment.app.FragmentActivity -import org.oppia.android.app.drawer.NAVIGATION_PROFILE_ID_ARGUMENT_KEY -import org.oppia.android.app.home.recentlyplayed.RecentlyPlayedActivity -import org.oppia.android.app.profile.ProfileChooserActivity -import org.oppia.android.app.topic.TopicActivity import javax.inject.Inject +import org.oppia.android.app.activity.ActivityIntentFactories.RecentlyPlayedActivityIntentFactory +import org.oppia.android.app.activity.ActivityIntentFactories.TopicActivityIntentFactory +import org.oppia.android.app.model.ProfileId /** * Creates intents for ViewModels in order to avoid ViewModel files directly depending on Activites. @@ -18,60 +16,49 @@ import javax.inject.Inject * ViewModel once Gradle has been removed. */ // TODO(#1619): Remove file post-Gradle -class IntentFactoryShimImpl @Inject constructor() : IntentFactoryShim { - - private val TOPIC_ACTIVITY_TOPIC_ID_ARGUMENT_KEY = "TopicActivity.topic_id" - private val TOPIC_ACTIVITY_STORY_ID_ARGUMENT_KEY = "TopicActivity.story_id" - - /** Returns [ProfileChooserActivity] intent for [AdministratorControlsAccountActionsViewModel]. */ - override fun createProfileChooserActivityIntent(fragment: FragmentActivity): Intent { - return Intent(fragment, ProfileChooserActivity::class.java) - } +class IntentFactoryShimImpl @Inject constructor( + private val topicActivityIntentFactory: TopicActivityIntentFactory, + private val recentlyPlayedActivityIntentFactory: RecentlyPlayedActivityIntentFactory +) : IntentFactoryShim { /** - * Creates a [TopicActivity] intent for [PromotedStoryViewModel] and passes necessary string + * Creates a topic activity intent for [PromotedStoryViewModel] and passes necessary string * data. - * */ + */ override fun createTopicPlayStoryActivityIntent( context: Context, internalProfileId: Int, topicId: String, storyId: String ): Intent { - val intent = Intent(context, TopicActivity::class.java) - intent.putExtra(NAVIGATION_PROFILE_ID_ARGUMENT_KEY, internalProfileId) - intent.putExtra(TOPIC_ACTIVITY_TOPIC_ID_ARGUMENT_KEY, topicId) - intent.putExtra(TOPIC_ACTIVITY_STORY_ID_ARGUMENT_KEY, storyId) - return intent + return topicActivityIntentFactory.createIntent(ProfileId.newBuilder().apply { + internalId = internalProfileId + }.build(), topicId, storyId) } /** - * Creates a [TopicActivity] intent which opens info-tab. - * */ + * Creates a topic activity intent which opens info-tab. + */ override fun createTopicActivityIntent( context: Context, internalProfileId: Int, topicId: String ): Intent { - val intent = Intent(context, TopicActivity::class.java) - intent.putExtra(NAVIGATION_PROFILE_ID_ARGUMENT_KEY, internalProfileId) - intent.putExtra(TOPIC_ACTIVITY_TOPIC_ID_ARGUMENT_KEY, topicId) - return intent + return topicActivityIntentFactory.createIntent(ProfileId.newBuilder().apply { + internalId = internalProfileId + }.build(), topicId) } /** - * Creates a [RecentlyPlayedActivity] intent for [PromotedStoryListViewModel] and passes + * Creates a recently played activity intent for [PromotedStoryListViewModel] and passes * necessary string data. - * */ + */ override fun createRecentlyPlayedActivityIntent( context: Context, internalProfileId: Int ): Intent { - val intent = Intent(context, RecentlyPlayedActivity::class.java) - intent.putExtra( - RecentlyPlayedActivity.RECENTLY_PLAYED_ACTIVITY_INTERNAL_PROFILE_ID_KEY, - internalProfileId - ) - return intent + return recentlyPlayedActivityIntentFactory.createIntent(ProfileId.newBuilder().apply { + internalId = internalProfileId + }.build()) } } diff --git a/app/src/main/java/org/oppia/android/app/shim/ViewBindingShim.kt b/app/src/main/java/org/oppia/android/app/shim/ViewBindingShim.kt index 61b49a70cdc..4356120a55f 100644 --- a/app/src/main/java/org/oppia/android/app/shim/ViewBindingShim.kt +++ b/app/src/main/java/org/oppia/android/app/shim/ViewBindingShim.kt @@ -8,7 +8,6 @@ import android.widget.ImageButton import android.widget.LinearLayout import androidx.recyclerview.widget.RecyclerView import org.oppia.android.app.home.promotedlist.ComingSoonTopicsViewModel -import org.oppia.android.app.home.promotedlist.PromotedStoryListView import org.oppia.android.app.home.promotedlist.PromotedStoryViewModel import org.oppia.android.app.player.state.itemviewmodel.DragDropInteractionContentViewModel import org.oppia.android.app.player.state.itemviewmodel.SelectionInteractionContentViewModel @@ -89,14 +88,19 @@ interface ViewBindingShim { /** Returns [ClickableAreasImage]'s default region. */ fun getDefaultRegion(parentView: FrameLayout): View - /** Handles binding inflation for [PromotedStoryListView]. */ + /** + * Handles binding inflation for [org.oppia.android.app.home.promotedlist.PromotedStoryListView]. + */ fun providePromotedStoryCardInflatedView( inflater: LayoutInflater, parent: ViewGroup, attachToParent: Boolean ): View - /** Handles binding inflation for [PromotedStoryListView] and returns the view model. */ + /** + * Handles binding inflation for [org.oppia.android.app.home.promotedlist.PromotedStoryListView] + * and returns the view model. + */ fun providePromotedStoryViewModel( view: View, viewModel: PromotedStoryViewModel diff --git a/app/src/main/java/org/oppia/android/app/shim/ViewComponentFactory.kt b/app/src/main/java/org/oppia/android/app/shim/ViewComponentFactory.kt deleted file mode 100644 index bf1eb2adcc1..00000000000 --- a/app/src/main/java/org/oppia/android/app/shim/ViewComponentFactory.kt +++ /dev/null @@ -1,11 +0,0 @@ -package org.oppia.android.app.shim - -import android.view.View -import org.oppia.android.app.view.ViewComponent - -interface ViewComponentFactory { - /** - * Returns a new [ViewComponent] for the specified view. - */ - fun createViewComponent(view: View): ViewComponent -} diff --git a/app/src/main/java/org/oppia/android/app/splash/SplashActivity.kt b/app/src/main/java/org/oppia/android/app/splash/SplashActivity.kt index ebd8b0531e8..35c63c0b7ee 100644 --- a/app/src/main/java/org/oppia/android/app/splash/SplashActivity.kt +++ b/app/src/main/java/org/oppia/android/app/splash/SplashActivity.kt @@ -1,21 +1,43 @@ package org.oppia.android.app.splash import android.os.Bundle -import org.oppia.android.app.activity.InjectableAppCompatActivity +import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.Fragment import org.oppia.android.app.deprecation.DeprecationNoticeExitAppListener import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponent +import org.oppia.android.app.activity.ActivityComponentFactory +import org.oppia.android.app.activity.ActivityComponentImpl +import org.oppia.android.app.fragment.FragmentComponent +import org.oppia.android.app.fragment.FragmentComponentBuilderInjector +import org.oppia.android.app.fragment.FragmentComponentFactory -/** An activity that shows a temporary loading page until the app is fully loaded then navigates to [ProfileActivity]. */ -class SplashActivity : InjectableAppCompatActivity(), DeprecationNoticeExitAppListener { +/** + * An activity that shows a temporary loading page until the app is fully loaded then navigates to + * the profile selection screen. + * + * Note that this activity intentionally doesn't utilize the shared injectable activity base class + * since it's used to bootstrap the app's locale context (which is passed along to activities + * through their intents). + */ +class SplashActivity : AppCompatActivity(), FragmentComponentFactory, DeprecationNoticeExitAppListener { + private lateinit var activityComponent: ActivityComponent @Inject lateinit var splashActivityPresenter: SplashActivityPresenter override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + val componentFactory = applicationContext as ActivityComponentFactory + activityComponent = componentFactory.createActivityComponent(this) + (activityComponent as ActivityComponentImpl).inject(this) splashActivityPresenter.handleOnCreate() } override fun onCloseAppButtonClicked() = splashActivityPresenter.handleOnCloseAppButtonClicked() + + override fun createFragmentComponent(fragment: Fragment): FragmentComponent { + val builderInjector = activityComponent as FragmentComponentBuilderInjector + return builderInjector.getFragmentComponentBuilderProvider().get().setFragment(fragment).build() + } } diff --git a/app/src/main/java/org/oppia/android/app/splash/SplashActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/splash/SplashActivityPresenter.kt index d5a0c68a9ba..ae51532eecc 100644 --- a/app/src/main/java/org/oppia/android/app/splash/SplashActivityPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/splash/SplashActivityPresenter.kt @@ -3,8 +3,8 @@ package org.oppia.android.app.splash import android.view.WindowManager import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.LiveData -import androidx.lifecycle.Observer import androidx.lifecycle.Transformations +import javax.inject.Inject import org.oppia.android.R import org.oppia.android.app.activity.ActivityScope import org.oppia.android.app.deprecation.AutomaticAppDeprecationNoticeDialogFragment @@ -12,14 +12,20 @@ import org.oppia.android.app.model.AppStartupState import org.oppia.android.app.model.AppStartupState.StartupMode import org.oppia.android.app.onboarding.OnboardingActivity import org.oppia.android.app.profile.ProfileChooserActivity +import org.oppia.android.app.translation.AppLanguageLocaleHandler +import org.oppia.android.domain.locale.LocaleController +import org.oppia.android.domain.locale.OppiaLocale import org.oppia.android.domain.onboarding.AppStartupStateController import org.oppia.android.domain.oppialogger.OppiaLogger import org.oppia.android.domain.topic.PrimeTopicAssetsController +import org.oppia.android.domain.translation.TranslationController import org.oppia.android.util.data.AsyncResult +import org.oppia.android.util.data.DataProvider +import org.oppia.android.util.data.DataProviders.Companion.combineWith import org.oppia.android.util.data.DataProviders.Companion.toLiveData -import javax.inject.Inject private const val AUTO_DEPRECATION_NOTICE_DIALOG_FRAGMENT_TAG = "auto_deprecation_notice_dialog" +private const val SPLASH_INIT_STATE_DATA_PROVIDER_ID = "splash_init_state_data_provider" /** The presenter for [SplashActivity]. */ @ActivityScope @@ -27,7 +33,10 @@ class SplashActivityPresenter @Inject constructor( private val activity: AppCompatActivity, private val oppiaLogger: OppiaLogger, private val appStartupStateController: AppStartupStateController, - private val primeTopicAssetsController: PrimeTopicAssetsController + private val primeTopicAssetsController: PrimeTopicAssetsController, + private val translationController: TranslationController, + private val localeController: LocaleController, + private val appLanguageLocaleHandler: AppLanguageLocaleHandler ) { fun handleOnCreate() { @@ -48,10 +57,14 @@ class SplashActivityPresenter @Inject constructor( } private fun subscribeToOnboardingFlow() { - getOnboardingFlow().observe( + computeInitStateLiveData().observe( activity, - Observer { startupMode -> - when (startupMode) { + { initState -> + // First, initialize the app's initial locale. + appLanguageLocaleHandler.initializeLocale(initState.displayLocale) + + // Second, route the user to the correct desintation. + when (initState.startupMode) { StartupMode.USER_IS_ONBOARDED -> { activity.startActivity(ProfileChooserActivity.createProfileChooserActivity(activity)) activity.finish() @@ -76,25 +89,32 @@ class SplashActivityPresenter @Inject constructor( ) } - private fun getOnboardingFlow(): LiveData { - return Transformations.map( - appStartupStateController.getAppStartupState().toLiveData(), - ::processStartupState - ) + private fun computeInitStateDataProvider(): DataProvider { + val startupStateDataProvider = appStartupStateController.getAppStartupState() + val systemAppLanguageLocaleDataProvider = translationController.getSystemLanguageLocale() + return startupStateDataProvider.combineWith( + systemAppLanguageLocaleDataProvider, SPLASH_INIT_STATE_DATA_PROVIDER_ID + ) { startupState, systemAppLanguageLocale -> + SplashInitState(startupState.startupMode, systemAppLanguageLocale) + } } - private fun processStartupState( - startupStateResult: AsyncResult - ): StartupMode { - if (startupStateResult.isFailure()) { + private fun computeInitStateLiveData(): LiveData = + Transformations.map(computeInitStateDataProvider().toLiveData(), ::processInitState) + + private fun processInitState( + initStateResult: AsyncResult + ): SplashInitState { + if (initStateResult.isFailure()) { oppiaLogger.e( "SplashActivity", - "Failed to retrieve startup state", - startupStateResult.getErrorOrNull() + "Failed to compute initial state state", + initStateResult.getErrorOrNull() ) } + // If there's an error loading the data, assume the default. - return startupStateResult.getOrDefault(AppStartupState.getDefaultInstance()).startupMode + return initStateResult.getOrDefault(SplashInitState.computeDefault(localeController)) } private fun getDeprecationNoticeDialogFragment(): AutomaticAppDeprecationNoticeDialogFragment? { @@ -102,4 +122,20 @@ class SplashActivityPresenter @Inject constructor( AUTO_DEPRECATION_NOTICE_DIALOG_FRAGMENT_TAG ) as? AutomaticAppDeprecationNoticeDialogFragment } + + private data class SplashInitState( + val startupMode: StartupMode, + val displayLocale: OppiaLocale.DisplayLocale + ) { + companion object { + fun computeDefault(localeController: LocaleController): SplashInitState { + return SplashInitState( + startupMode = AppStartupState.getDefaultInstance().startupMode, + displayLocale = localeController.reconstituteDisplayLocale( + localeController.getLikelyDefaultAppStringLocaleContext() + ) + ) + } + } + } } diff --git a/app/src/main/java/org/oppia/android/app/story/StoryActivity.kt b/app/src/main/java/org/oppia/android/app/story/StoryActivity.kt index fe7a7db6adc..2ef86be5e60 100644 --- a/app/src/main/java/org/oppia/android/app/story/StoryActivity.kt +++ b/app/src/main/java/org/oppia/android/app/story/StoryActivity.kt @@ -10,6 +10,7 @@ import org.oppia.android.app.player.exploration.ExplorationActivity import org.oppia.android.app.resumelesson.ResumeLessonActivity import org.oppia.android.app.topic.RouteToResumeLessonListener import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl /** Activity for stories. */ class StoryActivity : @@ -24,7 +25,7 @@ class StoryActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) internalProfileId = intent.getIntExtra(STORY_ACTIVITY_INTENT_EXTRA_INTERNAL_PROFILE_ID, -1) topicId = checkNotNull(intent.getStringExtra(STORY_ACTIVITY_INTENT_EXTRA_TOPIC_ID)) { "Expected extra topic ID to be included for StoryActivity." diff --git a/app/src/main/java/org/oppia/android/app/story/StoryFragment.kt b/app/src/main/java/org/oppia/android/app/story/StoryFragment.kt index 368d7a03d68..8259149370a 100644 --- a/app/src/main/java/org/oppia/android/app/story/StoryFragment.kt +++ b/app/src/main/java/org/oppia/android/app/story/StoryFragment.kt @@ -8,6 +8,7 @@ import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableFragment import org.oppia.android.app.model.ExplorationCheckpoint import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl private const val INTERNAL_PROFILE_ID_ARGUMENT_KEY = "StoryFragment.internal_profile_id" private const val KEY_TOPIC_ID_ARGUMENT = "TOPIC_ID" @@ -33,7 +34,7 @@ class StoryFragment : InjectableFragment(), ExplorationSelectionListener, StoryF override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/testing/AudioFragmentTestActivity.kt b/app/src/main/java/org/oppia/android/app/testing/AudioFragmentTestActivity.kt index c1d8d45508f..2f7595c97fb 100644 --- a/app/src/main/java/org/oppia/android/app/testing/AudioFragmentTestActivity.kt +++ b/app/src/main/java/org/oppia/android/app/testing/AudioFragmentTestActivity.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.os.Bundle import org.oppia.android.app.activity.InjectableAppCompatActivity import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl const val AUDIO_FRAGMENT_TEST_PROFILE_ID_ARGUMENT_KEY = "AudioFragmentTestActivity.audio_fragment_test_profile_id" @@ -17,7 +18,7 @@ class AudioFragmentTestActivity : InjectableAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) val internalProfileId = intent.getIntExtra(AUDIO_FRAGMENT_TEST_PROFILE_ID_ARGUMENT_KEY, /* defaultValue= */ -1) audioFragmentTestActivityController.handleOnCreate(internalProfileId) diff --git a/app/src/main/java/org/oppia/android/app/testing/ConceptCardFragmentTestActivity.kt b/app/src/main/java/org/oppia/android/app/testing/ConceptCardFragmentTestActivity.kt index 7c9d5318bb9..4936e3881b6 100644 --- a/app/src/main/java/org/oppia/android/app/testing/ConceptCardFragmentTestActivity.kt +++ b/app/src/main/java/org/oppia/android/app/testing/ConceptCardFragmentTestActivity.kt @@ -5,6 +5,7 @@ import org.oppia.android.app.activity.InjectableAppCompatActivity import org.oppia.android.app.topic.conceptcard.ConceptCardFragment import org.oppia.android.app.topic.conceptcard.ConceptCardListener import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl /** Test Activity used for testing ConceptCardFragment */ class ConceptCardFragmentTestActivity : InjectableAppCompatActivity(), ConceptCardListener { @@ -14,7 +15,7 @@ class ConceptCardFragmentTestActivity : InjectableAppCompatActivity(), ConceptCa override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) conceptCardFragmentTestActivityController.handleOnCreate() } diff --git a/app/src/main/java/org/oppia/android/app/testing/DragDropTestActivity.kt b/app/src/main/java/org/oppia/android/app/testing/DragDropTestActivity.kt index b1abce0a1ee..b7a79fc9ad2 100644 --- a/app/src/main/java/org/oppia/android/app/testing/DragDropTestActivity.kt +++ b/app/src/main/java/org/oppia/android/app/testing/DragDropTestActivity.kt @@ -6,6 +6,7 @@ import org.oppia.android.app.activity.InjectableAppCompatActivity import org.oppia.android.app.recyclerview.OnDragEndedListener import org.oppia.android.app.recyclerview.OnItemDragListener import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl /** Test Activity used for testing [DragAndDropItemFacilitator] functionality */ class DragDropTestActivity : @@ -18,7 +19,7 @@ class DragDropTestActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) dragDropTestActivityPresenter.handleOnCreate() } diff --git a/app/src/main/java/org/oppia/android/app/testing/ExplorationInjectionActivity.kt b/app/src/main/java/org/oppia/android/app/testing/ExplorationInjectionActivity.kt index 62e3e3fe575..62b01254c89 100644 --- a/app/src/main/java/org/oppia/android/app/testing/ExplorationInjectionActivity.kt +++ b/app/src/main/java/org/oppia/android/app/testing/ExplorationInjectionActivity.kt @@ -5,6 +5,7 @@ import org.oppia.android.app.activity.InjectableAppCompatActivity import org.oppia.android.domain.exploration.ExplorationDataController import org.oppia.android.util.networking.NetworkConnectionUtil import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl /** Activity used in [ExplorationActivityTest] to get certain dependencies. */ class ExplorationInjectionActivity : InjectableAppCompatActivity() { @@ -16,6 +17,6 @@ class ExplorationInjectionActivity : InjectableAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) } } diff --git a/app/src/main/java/org/oppia/android/app/testing/ExplorationTestActivity.kt b/app/src/main/java/org/oppia/android/app/testing/ExplorationTestActivity.kt index 37caaf4f50a..af54256236c 100644 --- a/app/src/main/java/org/oppia/android/app/testing/ExplorationTestActivity.kt +++ b/app/src/main/java/org/oppia/android/app/testing/ExplorationTestActivity.kt @@ -6,6 +6,7 @@ import org.oppia.android.app.home.RouteToExplorationListener import org.oppia.android.app.player.exploration.ExplorationActivity import org.oppia.android.app.topic.TopicFragment import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl /** The activity for testing [TopicFragment]. */ class ExplorationTestActivity : InjectableAppCompatActivity(), RouteToExplorationListener { @@ -14,7 +15,7 @@ class ExplorationTestActivity : InjectableAppCompatActivity(), RouteToExploratio override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) explorationTestActivityPresenter.handleOnCreate() } diff --git a/app/src/main/java/org/oppia/android/app/testing/HomeFragmentTestActivity.kt b/app/src/main/java/org/oppia/android/app/testing/HomeFragmentTestActivity.kt index 8776b9ff57d..67fb208d85d 100644 --- a/app/src/main/java/org/oppia/android/app/testing/HomeFragmentTestActivity.kt +++ b/app/src/main/java/org/oppia/android/app/testing/HomeFragmentTestActivity.kt @@ -3,6 +3,7 @@ package org.oppia.android.app.testing import android.content.Context import android.content.Intent import android.os.Bundle +import org.oppia.android.app.activity.ActivityComponentImpl import org.oppia.android.app.activity.InjectableAppCompatActivity import org.oppia.android.app.home.HomeFragment import org.oppia.android.app.home.RouteToRecentlyPlayedListener @@ -21,7 +22,7 @@ class HomeFragmentTestActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) } companion object { diff --git a/app/src/main/java/org/oppia/android/app/testing/HomeTestActivity.kt b/app/src/main/java/org/oppia/android/app/testing/HomeTestActivity.kt index 8a3b4f0ef91..1d97b5ff5db 100644 --- a/app/src/main/java/org/oppia/android/app/testing/HomeTestActivity.kt +++ b/app/src/main/java/org/oppia/android/app/testing/HomeTestActivity.kt @@ -3,6 +3,7 @@ package org.oppia.android.app.testing import android.os.Bundle import org.oppia.android.app.activity.InjectableAppCompatActivity import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl /** The activity for testing [HomeFragment]. */ class HomeTestActivity : InjectableAppCompatActivity() { @@ -12,7 +13,7 @@ class HomeTestActivity : InjectableAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) homeTestActivityPresenter.handleOnCreate() } } diff --git a/app/src/main/java/org/oppia/android/app/testing/HtmlParserTestActivity.kt b/app/src/main/java/org/oppia/android/app/testing/HtmlParserTestActivity.kt index da2bd656bcf..1260de4557a 100644 --- a/app/src/main/java/org/oppia/android/app/testing/HtmlParserTestActivity.kt +++ b/app/src/main/java/org/oppia/android/app/testing/HtmlParserTestActivity.kt @@ -2,13 +2,14 @@ package org.oppia.android.app.testing import android.os.Bundle import org.oppia.android.R +import org.oppia.android.app.activity.ActivityComponentImpl import org.oppia.android.app.activity.InjectableAppCompatActivity /** This is a dummy activity to test Html parsing. */ class HtmlParserTestActivity : InjectableAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) setContentView(R.layout.test_html_parser_activity) } } diff --git a/app/src/main/java/org/oppia/android/app/testing/ImageRegionSelectionTestActivity.kt b/app/src/main/java/org/oppia/android/app/testing/ImageRegionSelectionTestActivity.kt index dc5187eaf5e..ba023d37896 100644 --- a/app/src/main/java/org/oppia/android/app/testing/ImageRegionSelectionTestActivity.kt +++ b/app/src/main/java/org/oppia/android/app/testing/ImageRegionSelectionTestActivity.kt @@ -2,6 +2,7 @@ package org.oppia.android.app.testing import android.os.Bundle import org.oppia.android.R +import org.oppia.android.app.activity.ActivityComponentImpl import org.oppia.android.app.activity.InjectableAppCompatActivity import org.oppia.android.app.utility.ClickableAreasImage @@ -10,7 +11,7 @@ class ImageRegionSelectionTestActivity : InjectableAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) setContentView(R.layout.test_activity) supportFragmentManager.beginTransaction() .add( diff --git a/app/src/main/java/org/oppia/android/app/testing/ImageRegionSelectionTestFragment.kt b/app/src/main/java/org/oppia/android/app/testing/ImageRegionSelectionTestFragment.kt index 6238ea4c04d..0347261b9e5 100644 --- a/app/src/main/java/org/oppia/android/app/testing/ImageRegionSelectionTestFragment.kt +++ b/app/src/main/java/org/oppia/android/app/testing/ImageRegionSelectionTestFragment.kt @@ -8,6 +8,7 @@ import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableFragment import org.oppia.android.app.utility.ClickableAreasImage import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl const val IMAGE_REGION_SELECTION_TEST_FRAGMENT_TAG = "image_region_selection_test_fragment" @@ -20,7 +21,7 @@ class ImageRegionSelectionTestFragment : InjectableFragment() { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/testing/MarginBindingAdaptersTestActivity.kt b/app/src/main/java/org/oppia/android/app/testing/MarginBindingAdaptersTestActivity.kt index a033cba76c1..b9cc72ce452 100644 --- a/app/src/main/java/org/oppia/android/app/testing/MarginBindingAdaptersTestActivity.kt +++ b/app/src/main/java/org/oppia/android/app/testing/MarginBindingAdaptersTestActivity.kt @@ -2,13 +2,14 @@ package org.oppia.android.app.testing import android.os.Bundle import org.oppia.android.R +import org.oppia.android.app.activity.ActivityComponentImpl import org.oppia.android.app.activity.InjectableAppCompatActivity /** Test activity for MarginBindableAdapters. */ class MarginBindingAdaptersTestActivity : InjectableAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) setContentView(R.layout.test_margin_bindable_adapter_activity) } } diff --git a/app/src/main/java/org/oppia/android/app/testing/NavigationDrawerTestActivity.kt b/app/src/main/java/org/oppia/android/app/testing/NavigationDrawerTestActivity.kt index c272bdcb0a8..2f3b05c90dc 100644 --- a/app/src/main/java/org/oppia/android/app/testing/NavigationDrawerTestActivity.kt +++ b/app/src/main/java/org/oppia/android/app/testing/NavigationDrawerTestActivity.kt @@ -13,6 +13,7 @@ import org.oppia.android.app.home.RouteToTopicPlayStoryListener import org.oppia.android.app.home.recentlyplayed.RecentlyPlayedActivity import org.oppia.android.app.topic.TopicActivity import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl class NavigationDrawerTestActivity : InjectableAppCompatActivity(), @@ -33,7 +34,7 @@ class NavigationDrawerTestActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) internalProfileId = intent?.getIntExtra(NAVIGATION_PROFILE_ID_ARGUMENT_KEY, -1)!! homeActivityPresenter.handleOnCreate() title = getString(R.string.menu_home) diff --git a/app/src/main/java/org/oppia/android/app/testing/ProfileChooserFragmentTestActivity.kt b/app/src/main/java/org/oppia/android/app/testing/ProfileChooserFragmentTestActivity.kt index fe862bdd7b3..27c1149908a 100644 --- a/app/src/main/java/org/oppia/android/app/testing/ProfileChooserFragmentTestActivity.kt +++ b/app/src/main/java/org/oppia/android/app/testing/ProfileChooserFragmentTestActivity.kt @@ -3,6 +3,7 @@ package org.oppia.android.app.testing import android.os.Bundle import org.oppia.android.app.activity.InjectableAppCompatActivity import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl /** Test Activity used for testing [ProfileChooserFragment] */ class ProfileChooserFragmentTestActivity : InjectableAppCompatActivity() { @@ -13,7 +14,7 @@ class ProfileChooserFragmentTestActivity : InjectableAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) profileChooserFragmentTestActivityPresenter.handleOnCreate() } diff --git a/app/src/main/java/org/oppia/android/app/testing/SplashTestActivity.kt b/app/src/main/java/org/oppia/android/app/testing/SplashTestActivity.kt index 441f4b3cbaf..e4c070a67f0 100644 --- a/app/src/main/java/org/oppia/android/app/testing/SplashTestActivity.kt +++ b/app/src/main/java/org/oppia/android/app/testing/SplashTestActivity.kt @@ -5,6 +5,7 @@ import org.oppia.android.app.activity.InjectableAppCompatActivity import org.oppia.android.app.splash.SplashActivity import org.oppia.android.util.platformparameter.PlatformParameterValue import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl /** * A test activity to verify the injection of [PlatformParameterValue] in the [SplashActivity]. @@ -17,7 +18,7 @@ class SplashTestActivity : InjectableAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) splashTestActivityPresenter.handleOnCreate() } diff --git a/app/src/main/java/org/oppia/android/app/testing/StateAssemblerMarginBindingAdaptersTestActivity.kt b/app/src/main/java/org/oppia/android/app/testing/StateAssemblerMarginBindingAdaptersTestActivity.kt index 7d3bf138e88..a5b2d70c2db 100644 --- a/app/src/main/java/org/oppia/android/app/testing/StateAssemblerMarginBindingAdaptersTestActivity.kt +++ b/app/src/main/java/org/oppia/android/app/testing/StateAssemblerMarginBindingAdaptersTestActivity.kt @@ -2,13 +2,14 @@ package org.oppia.android.app.testing import android.os.Bundle import org.oppia.android.R +import org.oppia.android.app.activity.ActivityComponentImpl import org.oppia.android.app.activity.InjectableAppCompatActivity /** Test activity for StateAssemblerMarginBindingAdapters. */ class StateAssemblerMarginBindingAdaptersTestActivity : InjectableAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) setContentView(R.layout.test_margin_bindable_adapter_activity) } } diff --git a/app/src/main/java/org/oppia/android/app/testing/StateAssemblerPaddingBindingAdaptersTestActivity.kt b/app/src/main/java/org/oppia/android/app/testing/StateAssemblerPaddingBindingAdaptersTestActivity.kt index 365892bcb4d..8d952d339d3 100644 --- a/app/src/main/java/org/oppia/android/app/testing/StateAssemblerPaddingBindingAdaptersTestActivity.kt +++ b/app/src/main/java/org/oppia/android/app/testing/StateAssemblerPaddingBindingAdaptersTestActivity.kt @@ -2,13 +2,14 @@ package org.oppia.android.app.testing import android.os.Bundle import org.oppia.android.R +import org.oppia.android.app.activity.ActivityComponentImpl import org.oppia.android.app.activity.InjectableAppCompatActivity /** Test activity for StateAssemblerPaddingBindingAdapters . */ class StateAssemblerPaddingBindingAdaptersTestActivity : InjectableAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) setContentView(R.layout.test_margin_bindable_adapter_activity) } } diff --git a/app/src/main/java/org/oppia/android/app/testing/TestFontScaleConfigurationUtilActivity.kt b/app/src/main/java/org/oppia/android/app/testing/TestFontScaleConfigurationUtilActivity.kt index e61c44369db..8a853d82932 100644 --- a/app/src/main/java/org/oppia/android/app/testing/TestFontScaleConfigurationUtilActivity.kt +++ b/app/src/main/java/org/oppia/android/app/testing/TestFontScaleConfigurationUtilActivity.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.os.Bundle import org.oppia.android.app.activity.InjectableAppCompatActivity import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl /** Test activity used for testing font scale. */ class TestFontScaleConfigurationUtilActivity : InjectableAppCompatActivity() { @@ -14,7 +15,7 @@ class TestFontScaleConfigurationUtilActivity : InjectableAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) val readingTextSize = intent.getStringExtra(FONT_SCALE_EXTRA_KEY) configUtilActivityPresenter.handleOnCreate(readingTextSize) } diff --git a/app/src/main/java/org/oppia/android/app/testing/TopicRevisionTestActivity.kt b/app/src/main/java/org/oppia/android/app/testing/TopicRevisionTestActivity.kt index 086fd77eb29..90c22fab782 100644 --- a/app/src/main/java/org/oppia/android/app/testing/TopicRevisionTestActivity.kt +++ b/app/src/main/java/org/oppia/android/app/testing/TopicRevisionTestActivity.kt @@ -6,6 +6,7 @@ import org.oppia.android.app.topic.RouteToRevisionCardListener import org.oppia.android.app.topic.revision.TopicRevisionFragment import org.oppia.android.app.topic.revisioncard.RevisionCardActivity import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl /** Test Activity used for testing [TopicRevisionFragment] */ class TopicRevisionTestActivity : InjectableAppCompatActivity(), RouteToRevisionCardListener { @@ -15,7 +16,7 @@ class TopicRevisionTestActivity : InjectableAppCompatActivity(), RouteToRevision override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) topicRevisionTestActivityPresenter.handleOnCreate() } diff --git a/app/src/main/java/org/oppia/android/app/testing/TopicTestActivity.kt b/app/src/main/java/org/oppia/android/app/testing/TopicTestActivity.kt index 1f0031f62c5..f61ec7d8381 100644 --- a/app/src/main/java/org/oppia/android/app/testing/TopicTestActivity.kt +++ b/app/src/main/java/org/oppia/android/app/testing/TopicTestActivity.kt @@ -14,6 +14,7 @@ import org.oppia.android.app.topic.questionplayer.QuestionPlayerActivity import org.oppia.android.app.topic.revisioncard.RevisionCardActivity import org.oppia.android.domain.topic.TEST_TOPIC_ID_0 import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl /** The activity for testing [TopicFragment]. */ class TopicTestActivity : @@ -28,7 +29,7 @@ class TopicTestActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) topicActivityPresenter.handleOnCreate( internalProfileId = 0, topicId = TEST_TOPIC_ID_0, diff --git a/app/src/main/java/org/oppia/android/app/testing/TopicTestActivityForStory.kt b/app/src/main/java/org/oppia/android/app/testing/TopicTestActivityForStory.kt index a05f4a6e8f8..dd8a75d3f2b 100644 --- a/app/src/main/java/org/oppia/android/app/testing/TopicTestActivityForStory.kt +++ b/app/src/main/java/org/oppia/android/app/testing/TopicTestActivityForStory.kt @@ -18,6 +18,7 @@ import org.oppia.android.app.topic.revisioncard.RevisionCardActivity import org.oppia.android.domain.topic.TEST_STORY_ID_0 import org.oppia.android.domain.topic.TEST_TOPIC_ID_0 import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl /** The test activity for [TopicFragment] to test displaying story by storyId. */ class TopicTestActivityForStory : @@ -33,7 +34,7 @@ class TopicTestActivityForStory : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) topicActivityPresenter.handleOnCreate( internalProfileId = 0, topicId = TEST_TOPIC_ID_0, diff --git a/app/src/main/java/org/oppia/android/app/testing/ViewBindingAdaptersTestActivity.kt b/app/src/main/java/org/oppia/android/app/testing/ViewBindingAdaptersTestActivity.kt index 7076fb036c0..1f7c9f4b993 100644 --- a/app/src/main/java/org/oppia/android/app/testing/ViewBindingAdaptersTestActivity.kt +++ b/app/src/main/java/org/oppia/android/app/testing/ViewBindingAdaptersTestActivity.kt @@ -2,13 +2,14 @@ package org.oppia.android.app.testing import android.os.Bundle import org.oppia.android.R +import org.oppia.android.app.activity.ActivityComponentImpl import org.oppia.android.app.activity.InjectableAppCompatActivity /** Test activity for ViewBindingAdapters. */ class ViewBindingAdaptersTestActivity : InjectableAppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) setContentView(R.layout.activity_view_binding_adapters_test) } } diff --git a/app/src/main/java/org/oppia/android/app/topic/TopicActivity.kt b/app/src/main/java/org/oppia/android/app/topic/TopicActivity.kt index 488727625a2..139cb1b8e75 100755 --- a/app/src/main/java/org/oppia/android/app/topic/TopicActivity.kt +++ b/app/src/main/java/org/oppia/android/app/topic/TopicActivity.kt @@ -3,6 +3,7 @@ package org.oppia.android.app.topic import android.content.Context import android.content.Intent import android.os.Bundle +import androidx.appcompat.app.AppCompatActivity import org.oppia.android.app.activity.InjectableAppCompatActivity import org.oppia.android.app.drawer.NAVIGATION_PROFILE_ID_ARGUMENT_KEY import org.oppia.android.app.home.RouteToExplorationListener @@ -13,6 +14,9 @@ import org.oppia.android.app.story.StoryActivity import org.oppia.android.app.topic.questionplayer.QuestionPlayerActivity import org.oppia.android.app.topic.revisioncard.RevisionCardActivity import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl +import org.oppia.android.app.activity.ActivityIntentFactories +import org.oppia.android.app.model.ProfileId private const val TOPIC_ACTIVITY_TOPIC_ID_ARGUMENT_KEY = "TopicActivity.topic_id" private const val TOPIC_ACTIVITY_STORY_ID_ARGUMENT_KEY = "TopicActivity.story_id" @@ -35,7 +39,7 @@ class TopicActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) internalProfileId = intent?.getIntExtra(NAVIGATION_PROFILE_ID_ARGUMENT_KEY, -1)!! topicId = checkNotNull(intent?.getStringExtra(TOPIC_ACTIVITY_TOPIC_ID_ARGUMENT_KEY)) { "Expected topic ID to be included in intent for TopicActivity." @@ -121,6 +125,16 @@ class TopicActivity : finish() } + class TopicActivityIntentFactoryImpl @Inject constructor( + private val activity: AppCompatActivity + ): ActivityIntentFactories.TopicActivityIntentFactory { + override fun createIntent(profileId: ProfileId, topicId: String): Intent = + createTopicActivityIntent(activity, profileId.internalId, topicId) + + override fun createIntent(profileId: ProfileId, topicId: String, storyId: String): Intent = + createTopicPlayStoryActivityIntent(activity, profileId.internalId, topicId, storyId) + } + companion object { fun getProfileIdKey(): String { @@ -141,10 +155,10 @@ class TopicActivity : internalProfileId: Int, topicId: String ): Intent { - val intent = Intent(context, TopicActivity::class.java) - intent.putExtra(NAVIGATION_PROFILE_ID_ARGUMENT_KEY, internalProfileId) - intent.putExtra(TOPIC_ACTIVITY_TOPIC_ID_ARGUMENT_KEY, topicId) - return intent + return Intent(context, TopicActivity::class.java).apply { + putExtra(NAVIGATION_PROFILE_ID_ARGUMENT_KEY, internalProfileId) + putExtra(TOPIC_ACTIVITY_TOPIC_ID_ARGUMENT_KEY, topicId) + } } /** Returns a new [Intent] to route to [TopicLessonsFragment] for a specified story ID. */ @@ -154,11 +168,9 @@ class TopicActivity : topicId: String, storyId: String ): Intent { - val intent = Intent(context, TopicActivity::class.java) - intent.putExtra(NAVIGATION_PROFILE_ID_ARGUMENT_KEY, internalProfileId) - intent.putExtra(TOPIC_ACTIVITY_TOPIC_ID_ARGUMENT_KEY, topicId) - intent.putExtra(TOPIC_ACTIVITY_STORY_ID_ARGUMENT_KEY, storyId) - return intent + return createTopicActivityIntent(context, internalProfileId, topicId).apply { + putExtra(TOPIC_ACTIVITY_STORY_ID_ARGUMENT_KEY, storyId) + } } } } diff --git a/app/src/main/java/org/oppia/android/app/topic/TopicFragment.kt b/app/src/main/java/org/oppia/android/app/topic/TopicFragment.kt index 5c7ac802a3a..e7f5a820bdb 100644 --- a/app/src/main/java/org/oppia/android/app/topic/TopicFragment.kt +++ b/app/src/main/java/org/oppia/android/app/topic/TopicFragment.kt @@ -8,6 +8,7 @@ import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableFragment import org.oppia.android.domain.topic.TEST_TOPIC_ID_0 import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl /** Fragment that contains tabs for Topic. */ class TopicFragment : InjectableFragment() { @@ -16,7 +17,7 @@ class TopicFragment : InjectableFragment() { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/topic/conceptcard/ConceptCardFragment.kt b/app/src/main/java/org/oppia/android/app/topic/conceptcard/ConceptCardFragment.kt index 8eb88e03351..d3c6d5dd377 100644 --- a/app/src/main/java/org/oppia/android/app/topic/conceptcard/ConceptCardFragment.kt +++ b/app/src/main/java/org/oppia/android/app/topic/conceptcard/ConceptCardFragment.kt @@ -8,6 +8,7 @@ import android.view.ViewGroup import org.oppia.android.R import org.oppia.android.app.fragment.InjectableDialogFragment import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl private const val SKILL_ID_ARGUMENT_KEY = "ConceptCardFragment.skill_id" @@ -37,7 +38,7 @@ class ConceptCardFragment : InjectableDialogFragment() { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreate(savedInstanceState: Bundle?) { diff --git a/app/src/main/java/org/oppia/android/app/topic/info/TopicInfoFragment.kt b/app/src/main/java/org/oppia/android/app/topic/info/TopicInfoFragment.kt index 4307b3aa9fb..7db7db05383 100644 --- a/app/src/main/java/org/oppia/android/app/topic/info/TopicInfoFragment.kt +++ b/app/src/main/java/org/oppia/android/app/topic/info/TopicInfoFragment.kt @@ -9,6 +9,7 @@ import org.oppia.android.app.fragment.InjectableFragment import org.oppia.android.app.topic.PROFILE_ID_ARGUMENT_KEY import org.oppia.android.app.topic.TOPIC_ID_ARGUMENT_KEY import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl /** Fragment that contains info of Topic. */ class TopicInfoFragment : InjectableFragment() { @@ -29,7 +30,7 @@ class TopicInfoFragment : InjectableFragment() { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/topic/lessons/TopicLessonsFragment.kt b/app/src/main/java/org/oppia/android/app/topic/lessons/TopicLessonsFragment.kt index 890eab17baf..33897f4c9bd 100644 --- a/app/src/main/java/org/oppia/android/app/topic/lessons/TopicLessonsFragment.kt +++ b/app/src/main/java/org/oppia/android/app/topic/lessons/TopicLessonsFragment.kt @@ -12,6 +12,7 @@ import org.oppia.android.app.topic.PROFILE_ID_ARGUMENT_KEY import org.oppia.android.app.topic.STORY_ID_ARGUMENT_KEY import org.oppia.android.app.topic.TOPIC_ID_ARGUMENT_KEY import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl private const val CURRENT_EXPANDED_LIST_INDEX_SAVED_KEY = "TopicLessonsFragment.current_expanded_list_index" @@ -50,7 +51,7 @@ class TopicLessonsFragment : override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/topic/practice/TopicPracticeFragment.kt b/app/src/main/java/org/oppia/android/app/topic/practice/TopicPracticeFragment.kt index a7d5f9d5b7d..b3425b8966a 100644 --- a/app/src/main/java/org/oppia/android/app/topic/practice/TopicPracticeFragment.kt +++ b/app/src/main/java/org/oppia/android/app/topic/practice/TopicPracticeFragment.kt @@ -9,6 +9,7 @@ import org.oppia.android.app.fragment.InjectableFragment import org.oppia.android.app.topic.PROFILE_ID_ARGUMENT_KEY import org.oppia.android.app.topic.TOPIC_ID_ARGUMENT_KEY import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl /** Fragment that displays skills for topic practice mode. */ class TopicPracticeFragment : InjectableFragment() { @@ -32,7 +33,7 @@ class TopicPracticeFragment : InjectableFragment() { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/topic/questionplayer/HintsAndSolutionQuestionManagerFragment.kt b/app/src/main/java/org/oppia/android/app/topic/questionplayer/HintsAndSolutionQuestionManagerFragment.kt index b7c14028c8c..da4fccafe54 100644 --- a/app/src/main/java/org/oppia/android/app/topic/questionplayer/HintsAndSolutionQuestionManagerFragment.kt +++ b/app/src/main/java/org/oppia/android/app/topic/questionplayer/HintsAndSolutionQuestionManagerFragment.kt @@ -7,6 +7,7 @@ import android.view.View import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableFragment import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl /** * ManagerFragment of [QuestionFragment] that observes data provider that retrieve Question State. @@ -18,7 +19,7 @@ class HintsAndSolutionQuestionManagerFragment : InjectableFragment() { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerActivity.kt b/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerActivity.kt index 8662b6b31ce..617219350da 100644 --- a/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerActivity.kt +++ b/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerActivity.kt @@ -18,6 +18,7 @@ import org.oppia.android.app.player.stopplaying.StopExplorationDialogFragment import org.oppia.android.app.player.stopplaying.StopStatePlayingSessionListener import org.oppia.android.app.topic.conceptcard.ConceptCardListener import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl const val QUESTION_PLAYER_ACTIVITY_SKILL_ID_LIST_ARGUMENT_KEY = "QuestionPlayerActivity.skill_id_list" @@ -42,7 +43,7 @@ class QuestionPlayerActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) questionPlayerActivityPresenter.handleOnCreate() } diff --git a/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerFragment.kt b/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerFragment.kt index 9196f19e102..7f229382897 100644 --- a/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerFragment.kt +++ b/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerFragment.kt @@ -18,6 +18,7 @@ import org.oppia.android.app.player.state.listener.ReturnToTopicNavigationButton import org.oppia.android.app.player.state.listener.ShowHintAvailabilityListener import org.oppia.android.app.player.state.listener.SubmitNavigationButtonListener import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl /** Fragment that contains all questions in Question Player. */ class QuestionPlayerFragment : @@ -37,7 +38,7 @@ class QuestionPlayerFragment : override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/topic/revision/TopicRevisionFragment.kt b/app/src/main/java/org/oppia/android/app/topic/revision/TopicRevisionFragment.kt index b589fead6b9..5310dab0de5 100755 --- a/app/src/main/java/org/oppia/android/app/topic/revision/TopicRevisionFragment.kt +++ b/app/src/main/java/org/oppia/android/app/topic/revision/TopicRevisionFragment.kt @@ -10,6 +10,7 @@ import org.oppia.android.app.model.Subtopic import org.oppia.android.app.topic.PROFILE_ID_ARGUMENT_KEY import org.oppia.android.app.topic.TOPIC_ID_ARGUMENT_KEY import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl /** Fragment that card for topic revision. */ class TopicRevisionFragment : InjectableFragment(), RevisionSubtopicSelector { @@ -32,7 +33,7 @@ class TopicRevisionFragment : InjectableFragment(), RevisionSubtopicSelector { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardActivity.kt b/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardActivity.kt index bc8822e8a6b..6a10a0e2f2a 100644 --- a/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardActivity.kt +++ b/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardActivity.kt @@ -9,6 +9,7 @@ import org.oppia.android.R import org.oppia.android.app.activity.InjectableAppCompatActivity import org.oppia.android.app.topic.conceptcard.ConceptCardListener import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl /** Activity for revision card. */ class RevisionCardActivity : @@ -19,7 +20,7 @@ class RevisionCardActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) intent?.let { intent -> val internalProfileId = intent.getIntExtra(INTERNAL_PROFILE_ID_EXTRA_KEY, -1) val topicId = checkNotNull(intent.getStringExtra(TOPIC_ID_EXTRA_KEY)) { diff --git a/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardFragment.kt b/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardFragment.kt index ec2e29c5407..8381b41a1fd 100755 --- a/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardFragment.kt +++ b/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardFragment.kt @@ -7,6 +7,7 @@ import android.view.View import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableDialogFragment import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl /* Fragment that displays revision card */ class RevisionCardFragment : InjectableDialogFragment() { @@ -30,7 +31,7 @@ class RevisionCardFragment : InjectableDialogFragment() { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/translation/AppLanguageActivityInjector.kt b/app/src/main/java/org/oppia/android/app/translation/AppLanguageActivityInjector.kt new file mode 100644 index 00000000000..9cba41ef4fe --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/translation/AppLanguageActivityInjector.kt @@ -0,0 +1,5 @@ +package org.oppia.android.app.translation + +interface AppLanguageActivityInjector { + fun getAppLanguageWatcherMixin(): AppLanguageWatcherMixin +} diff --git a/app/src/main/java/org/oppia/android/app/translation/AppLanguageApplicationInjector.kt b/app/src/main/java/org/oppia/android/app/translation/AppLanguageApplicationInjector.kt new file mode 100644 index 00000000000..52b4646184c --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/translation/AppLanguageApplicationInjector.kt @@ -0,0 +1,5 @@ +package org.oppia.android.app.translation + +interface AppLanguageApplicationInjector { + fun getAppLanguageHandler(): AppLanguageLocaleHandler +} diff --git a/app/src/main/java/org/oppia/android/app/translation/AppLanguageApplicationInjectorProvider.kt b/app/src/main/java/org/oppia/android/app/translation/AppLanguageApplicationInjectorProvider.kt new file mode 100644 index 00000000000..88d6f4d535a --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/translation/AppLanguageApplicationInjectorProvider.kt @@ -0,0 +1,5 @@ +package org.oppia.android.app.translation + +interface AppLanguageApplicationInjectorProvider { + fun getAppLanguageApplicationInjector(): AppLanguageApplicationInjector +} diff --git a/app/src/main/java/org/oppia/android/app/translation/AppLanguageLocaleHandler.kt b/app/src/main/java/org/oppia/android/app/translation/AppLanguageLocaleHandler.kt new file mode 100644 index 00000000000..0fe879543e0 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/translation/AppLanguageLocaleHandler.kt @@ -0,0 +1,43 @@ +package org.oppia.android.app.translation + +import android.content.res.Configuration +import javax.inject.Inject +import javax.inject.Singleton +import org.oppia.android.domain.locale.LocaleController +import org.oppia.android.domain.locale.OppiaLocale + +@Singleton +class AppLanguageLocaleHandler @Inject constructor( + private val localeController: LocaleController +) { + private lateinit var displayLocale: OppiaLocale.DisplayLocale + + // TODO: document that this should only be called by bootstrapping activities (like splash). + fun initializeLocale(locale: OppiaLocale.DisplayLocale) { + // TODO: think about this check more. It won't work correctly for intent-based entrypoints in + // the future. + check(!::displayLocale.isInitialized) { "Expected to initialize the locale for the first time" } + displayLocale = locale + } + + fun notifyPotentialLocaleChange() { + localeController.notifyPotentialLocaleChange() + } + + fun initializeLocaleForActivity(newConfiguration: Configuration) { + localeController.setAsDefault(displayLocale, newConfiguration) + } + + // TODO: document that this returns whether the locale has changed. + fun updateLocale(newLocale: OppiaLocale.DisplayLocale): Boolean { + check(::displayLocale.isInitialized) { + "Expected locale to already be initialized before being updated" + } + return displayLocale.let { oldLocale -> + displayLocale = newLocale + return@let oldLocale != newLocale + } + } + + fun getDisplayLocale(): OppiaLocale.DisplayLocale = displayLocale +} diff --git a/app/src/main/java/org/oppia/android/app/translation/AppLanguageResourceHandler.kt b/app/src/main/java/org/oppia/android/app/translation/AppLanguageResourceHandler.kt new file mode 100644 index 00000000000..23bbaf834bb --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/translation/AppLanguageResourceHandler.kt @@ -0,0 +1,32 @@ +package org.oppia.android.app.translation + +import androidx.annotation.ArrayRes +import androidx.annotation.StringRes +import androidx.appcompat.app.AppCompatActivity +import javax.inject.Inject +import org.oppia.android.domain.locale.OppiaLocale + +class AppLanguageResourceHandler @Inject constructor( + private val activity: AppCompatActivity, + private val appLanguageLocaleHandler: AppLanguageLocaleHandler +) { + private val resources by lazy { activity.resources } + + fun getStringInLocale(@StringRes id: Int): String { +// ensureLocaleIsInitialized() + return getDisplayLocale().run { resources.getStringInLocale(id) } + } + + fun getStringInLocale(@StringRes id: Int, vararg formatArgs: Any?): String { +// ensureLocaleIsInitialized() + return getDisplayLocale().run { resources.getStringInLocale(id, *formatArgs) } + } + + fun getStringArrayInLocale(@ArrayRes id: Int): List { +// ensureLocaleIsInitialized() + return getDisplayLocale().run { resources.getStringArrayInLocale(id) } + } + + private fun getDisplayLocale(): OppiaLocale.DisplayLocale = + appLanguageLocaleHandler.getDisplayLocale() +} diff --git a/app/src/main/java/org/oppia/android/app/translation/AppLanguageWatcherMixin.kt b/app/src/main/java/org/oppia/android/app/translation/AppLanguageWatcherMixin.kt new file mode 100644 index 00000000000..64d87deaf5e --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/translation/AppLanguageWatcherMixin.kt @@ -0,0 +1,44 @@ +package org.oppia.android.app.translation + +import androidx.appcompat.app.AppCompatActivity +import javax.inject.Inject +import org.oppia.android.app.model.ProfileId +import org.oppia.android.domain.oppialogger.OppiaLogger +import org.oppia.android.domain.translation.TranslationController +import org.oppia.android.util.data.DataProviders.Companion.toLiveData + +class AppLanguageWatcherMixin @Inject constructor( + private val activity: AppCompatActivity, + private val translationController: TranslationController, + private val appLanguageLocaleHandler: AppLanguageLocaleHandler, + private val oppiaLogger: OppiaLogger +) { + fun initialize() { + // TODO(#52): Hook this up properly to profiles, and handle the non-profile activity cases. + val profileId = ProfileId.getDefaultInstance() + val appLanguageLocaleDataProvider = translationController.getAppLanguageLocale(profileId) + appLanguageLocaleDataProvider.toLiveData().observe(activity, { localeResult -> + if (localeResult.isSuccess()) { + // Only recreate the activity if the locale actually changed (to avoid an infinite + // recreation loop). + if (appLanguageLocaleHandler.updateLocale(localeResult.getOrThrow())) { + // Recreate the activity to apply the latest locale state. Note that in some cases this + // may result in 2 recreations for the user: one to notify that there's a new system + // locale, and a second to actually apply that locale. This is due to a limitation in the + // infrastructure where the app can't know which system locale it can use without a + // LiveData trigger (this class). While this isn't an ideal user experience, the + // expectation is that the recreation should happen fairly quickly. If, in practice, + // that's not the case, the team will need to look into ways of synchronizing the UI-kept + // locale faster (maybe by short-circuiting some of the system locale selection code since + // the underlying I/O state is technically fixed and doesn't need a DataProvider past the + // splash screen). + activity.recreate() + } + } else if (localeResult.isFailure()) { + oppiaLogger.e( + "AppLanguageWatcherMixin", "Failed to retrieve app string locale for activity: $activity" + ) + } + }) + } +} diff --git a/app/src/main/java/org/oppia/android/app/translation/BUILD.bazel b/app/src/main/java/org/oppia/android/app/translation/BUILD.bazel new file mode 100644 index 00000000000..9dcba626821 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/translation/BUILD.bazel @@ -0,0 +1,86 @@ +""" +UI utilities for 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 = "app_language_locale_handler", + srcs = [ + "AppLanguageLocaleHandler.kt", + ], + # visibility = ["//app:app_visibility"], + deps = [ + ":dagger", + "//domain/src/main/java/org/oppia/android/domain/locale:locale_controller", + ], +) + +kt_android_library( + name = "app_language_resource_handler", + srcs = [ + "AppLanguageResourceHandler.kt", + ], + visibility = ["//app:app_visibility"], + deps = [ + ":app_language_locale_handler", + ":dagger", + "//domain/src/main/java/org/oppia/android/domain/locale:oppia_locale", + "//third_party:androidx_appcompat_appcompat", + ], +) + +kt_android_library( + name = "app_language_watcher_mixin", + srcs = [ + "AppLanguageWatcherMixin.kt", + ], + visibility = ["//app:app_visibility"], + deps = [ + ":app_language_locale_handler", + ":dagger", + "//domain/src/main/java/org/oppia/android/domain/translation:translation_controller", + ], +) + +kt_android_library( + name = "app_language_activity_injector", + srcs = [ + "AppLanguageActivityInjector.kt", + ], + visibility = [ + "//app/src/main/java/org/oppia/android/app/activity:__pkg__", + ], + deps = [ + ":app_language_watcher_mixin", + ], +) + +kt_android_library( + name = "app_language_application_injector", + srcs = [ + "AppLanguageApplicationInjector.kt", + ], + visibility = [ + "//app/src/main/java/org/oppia/android/app/activity:__pkg__", + ], + deps = [ + ":app_language_locale_handler", + ], +) + +kt_android_library( + name = "app_language_application_injector_provider", + srcs = [ + "AppLanguageApplicationInjectorProvider.kt", + ], + visibility = [ + "//app/src/main/java/org/oppia/android/app/activity:__pkg__", + ], + deps = [ + ":app_language_application_injector", + ], +) + +dagger_rules() diff --git a/app/src/main/java/org/oppia/android/app/view/BUILD.bazel b/app/src/main/java/org/oppia/android/app/view/BUILD.bazel new file mode 100644 index 00000000000..b81f8639a09 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/view/BUILD.bazel @@ -0,0 +1,57 @@ +""" +Constructs for setting up views for injection in the Dagger graph. +""" + +load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_android_library") + +# TODO(#59): Define ViewComponentImpl as a library separate from views. +exports_files([ + "ViewComponentBuilderModule.kt", + "ViewComponentImpl.kt", +]) + +kt_android_library( + name = "view_scope", + srcs = [ + "ViewScope.kt", + ], + visibility = ["//app:app_visibility"], + deps = [ + "//third_party:javax_inject_javax_inject", + ], +) + +kt_android_library( + name = "view_component", + srcs = [ + "ViewComponent.kt", + ], + visibility = [ + "//app/src/main/java/org/oppia/android/app/fragment:__pkg__", + ], +) + +kt_android_library( + name = "view_component_factory", + srcs = [ + "ViewComponentFactory.kt", + ], + visibility = ["//app:app_visibility"], + deps = [ + ":view_component", + ], +) + +kt_android_library( + name = "view_component_builder_injector", + srcs = [ + "ViewComponentBuilderInjector.kt", + ], + visibility = [ + "//app/src/main/java/org/oppia/android/app/fragment:__pkg__", + ], + deps = [ + ":view_component", + "//third_party:javax_inject_javax_inject", + ], +) diff --git a/app/src/main/java/org/oppia/android/app/view/ViewComponent.kt b/app/src/main/java/org/oppia/android/app/view/ViewComponent.kt index 42675a693b3..ae840a1f15e 100644 --- a/app/src/main/java/org/oppia/android/app/view/ViewComponent.kt +++ b/app/src/main/java/org/oppia/android/app/view/ViewComponent.kt @@ -1,31 +1,11 @@ package org.oppia.android.app.view import android.view.View -import dagger.BindsInstance -import dagger.Subcomponent -import org.oppia.android.app.customview.LessonThumbnailImageView -import org.oppia.android.app.home.promotedlist.ComingSoonTopicsListView -import org.oppia.android.app.home.promotedlist.PromotedStoryListView -import org.oppia.android.app.player.state.DragDropSortInteractionView -import org.oppia.android.app.player.state.ImageRegionSelectionInteractionView -import org.oppia.android.app.player.state.SelectionInteractionView -/** Root subcomponent for custom views. */ -@Subcomponent -@ViewScope interface ViewComponent { - @Subcomponent.Builder interface Builder { - @BindsInstance fun setView(view: View): Builder fun build(): ViewComponent } - - fun inject(comingSoonTopicsListView: ComingSoonTopicsListView) - fun inject(selectionInteractionView: SelectionInteractionView) - fun inject(dragDropSortInteractionView: DragDropSortInteractionView) - fun inject(imageRegionSelectionInteractionView: ImageRegionSelectionInteractionView) - fun inject(lessonThumbnailImageView: LessonThumbnailImageView) - fun inject(promotedStoryListView: PromotedStoryListView) } diff --git a/app/src/main/java/org/oppia/android/app/view/ViewComponentBuilderInjector.kt b/app/src/main/java/org/oppia/android/app/view/ViewComponentBuilderInjector.kt new file mode 100644 index 00000000000..22f34695678 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/view/ViewComponentBuilderInjector.kt @@ -0,0 +1,7 @@ +package org.oppia.android.app.view + +import javax.inject.Provider + +interface ViewComponentBuilderInjector { + fun getViewComponentBuilderProvider(): Provider +} diff --git a/app/src/main/java/org/oppia/android/app/view/ViewComponentBuilderModule.kt b/app/src/main/java/org/oppia/android/app/view/ViewComponentBuilderModule.kt new file mode 100644 index 00000000000..9885082af34 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/view/ViewComponentBuilderModule.kt @@ -0,0 +1,10 @@ +package org.oppia.android.app.view + +import dagger.Binds +import dagger.Module + +@Module +interface ViewComponentBuilderModule { + @Binds + fun bindViewComponentBuilder(impl: ViewComponentImpl.Builder): ViewComponent.Builder +} diff --git a/app/src/main/java/org/oppia/android/app/view/ViewComponentFactory.kt b/app/src/main/java/org/oppia/android/app/view/ViewComponentFactory.kt new file mode 100644 index 00000000000..bf0433f2fe8 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/view/ViewComponentFactory.kt @@ -0,0 +1,8 @@ +package org.oppia.android.app.view + +import android.view.View + +interface ViewComponentFactory { + /** Returns a new [ViewComponent] for the specified view. */ + fun createViewComponent(view: View): ViewComponent +} diff --git a/app/src/main/java/org/oppia/android/app/view/ViewComponentImpl.kt b/app/src/main/java/org/oppia/android/app/view/ViewComponentImpl.kt new file mode 100644 index 00000000000..06a928361fd --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/view/ViewComponentImpl.kt @@ -0,0 +1,33 @@ +package org.oppia.android.app.view + +import android.view.View +import dagger.BindsInstance +import dagger.Subcomponent +import org.oppia.android.app.customview.LessonThumbnailImageView +import org.oppia.android.app.home.promotedlist.ComingSoonTopicsListView +import org.oppia.android.app.home.promotedlist.PromotedStoryListView +import org.oppia.android.app.player.state.DragDropSortInteractionView +import org.oppia.android.app.player.state.ImageRegionSelectionInteractionView +import org.oppia.android.app.player.state.SelectionInteractionView + +// TODO(#59): Restrict access to this implementation by introducing injectors in each view. + +/** Root subcomponent for custom views. */ +@Subcomponent +@ViewScope +interface ViewComponentImpl: ViewComponent { + @Subcomponent.Builder + interface Builder: ViewComponent.Builder { + @BindsInstance + override fun setView(view: View): Builder + + override fun build(): ViewComponentImpl + } + + fun inject(comingSoonTopicsListView: ComingSoonTopicsListView) + fun inject(selectionInteractionView: SelectionInteractionView) + fun inject(dragDropSortInteractionView: DragDropSortInteractionView) + fun inject(imageRegionSelectionInteractionView: ImageRegionSelectionInteractionView) + fun inject(lessonThumbnailImageView: LessonThumbnailImageView) + fun inject(promotedStoryListView: PromotedStoryListView) +} diff --git a/app/src/main/java/org/oppia/android/app/walkthrough/WalkthroughActivity.kt b/app/src/main/java/org/oppia/android/app/walkthrough/WalkthroughActivity.kt index 8e62c6f3974..7314e058047 100644 --- a/app/src/main/java/org/oppia/android/app/walkthrough/WalkthroughActivity.kt +++ b/app/src/main/java/org/oppia/android/app/walkthrough/WalkthroughActivity.kt @@ -5,6 +5,7 @@ import android.content.Intent import android.os.Bundle import org.oppia.android.app.activity.InjectableAppCompatActivity import javax.inject.Inject +import org.oppia.android.app.activity.ActivityComponentImpl /** Activity that contains the walkthrough flow for users. */ class WalkthroughActivity : InjectableAppCompatActivity(), WalkthroughFragmentChangeListener { @@ -13,7 +14,7 @@ class WalkthroughActivity : InjectableAppCompatActivity(), WalkthroughFragmentCh override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - activityComponent.inject(this) + (activityComponent as ActivityComponentImpl).inject(this) walkthroughActivityPresenter.handleOnCreate() } diff --git a/app/src/main/java/org/oppia/android/app/walkthrough/end/WalkthroughFinalFragment.kt b/app/src/main/java/org/oppia/android/app/walkthrough/end/WalkthroughFinalFragment.kt index b95cd0ba9b7..25e5d070721 100644 --- a/app/src/main/java/org/oppia/android/app/walkthrough/end/WalkthroughFinalFragment.kt +++ b/app/src/main/java/org/oppia/android/app/walkthrough/end/WalkthroughFinalFragment.kt @@ -7,6 +7,7 @@ import android.view.View import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableFragment import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl private const val KEY_TOPIC_ID_ARGUMENT = "TOPIC_ID" @@ -28,7 +29,7 @@ class WalkthroughFinalFragment : InjectableFragment() { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/walkthrough/topiclist/WalkthroughTopicListFragment.kt b/app/src/main/java/org/oppia/android/app/walkthrough/topiclist/WalkthroughTopicListFragment.kt index e85fbf16fbf..c1ae9be2ebc 100644 --- a/app/src/main/java/org/oppia/android/app/walkthrough/topiclist/WalkthroughTopicListFragment.kt +++ b/app/src/main/java/org/oppia/android/app/walkthrough/topiclist/WalkthroughTopicListFragment.kt @@ -9,6 +9,7 @@ import org.oppia.android.app.fragment.InjectableFragment import org.oppia.android.app.home.topiclist.TopicSummaryClickListener import org.oppia.android.app.model.TopicSummary import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl /** The second slide for [WalkthroughActivity]. */ class WalkthroughTopicListFragment : InjectableFragment(), TopicSummaryClickListener { @@ -17,7 +18,7 @@ class WalkthroughTopicListFragment : InjectableFragment(), TopicSummaryClickList override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( diff --git a/app/src/main/java/org/oppia/android/app/walkthrough/welcome/WalkthroughWelcomeFragment.kt b/app/src/main/java/org/oppia/android/app/walkthrough/welcome/WalkthroughWelcomeFragment.kt index d5c604c51e2..ea4552994e0 100644 --- a/app/src/main/java/org/oppia/android/app/walkthrough/welcome/WalkthroughWelcomeFragment.kt +++ b/app/src/main/java/org/oppia/android/app/walkthrough/welcome/WalkthroughWelcomeFragment.kt @@ -7,6 +7,7 @@ import android.view.View import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableFragment import javax.inject.Inject +import org.oppia.android.app.fragment.FragmentComponentImpl /** The first slide for [WalkthroughActivity]. */ class WalkthroughWelcomeFragment : InjectableFragment() { @@ -15,7 +16,7 @@ class WalkthroughWelcomeFragment : InjectableFragment() { override fun onAttach(context: Context) { super.onAttach(context) - fragmentComponent.inject(this) + (fragmentComponent as FragmentComponentImpl).inject(this) } override fun onCreateView( From 6b8298da780f06f71b9889b41a1f2995197f0c3e Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Wed, 8 Sep 2021 15:49:55 -0700 Subject: [PATCH 21/93] Domain changes needed per downstream UI changes. --- .../java/org/oppia/android/config/BUILD.bazel | 3 + .../oppia/android/domain/locale/BUILD.bazel | 1 + .../domain/locale/DisplayLocaleImpl.kt | 58 +++++++---- .../android/domain/locale/LocaleController.kt | 98 ++++++++++++++++--- .../domain/locale/MachineLocaleImpl.kt | 10 ++ .../android/domain/locale/OppiaLocale.kt | 16 ++- .../android/domain/translation/BUILD.bazel | 2 +- .../android/util/caching/AssetRepository.kt | 7 +- 8 files changed, 153 insertions(+), 42 deletions(-) diff --git a/config/src/java/org/oppia/android/config/BUILD.bazel b/config/src/java/org/oppia/android/config/BUILD.bazel index ba2b60beadb..36079416378 100644 --- a/config/src/java/org/oppia/android/config/BUILD.bazel +++ b/config/src/java/org/oppia/android/config/BUILD.bazel @@ -32,4 +32,7 @@ android_library( 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/domain/src/main/java/org/oppia/android/domain/locale/BUILD.bazel b/domain/src/main/java/org/oppia/android/domain/locale/BUILD.bazel index bd585da9c7f..8c0c52a35b4 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 @@ -56,6 +56,7 @@ kt_android_library( ], 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:assets", ], 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 index ab6875e7846..a45f2bc4005 100644 --- a/domain/src/main/java/org/oppia/android/domain/locale/DisplayLocaleImpl.kt +++ b/domain/src/main/java/org/oppia/android/domain/locale/DisplayLocaleImpl.kt @@ -1,11 +1,15 @@ package org.oppia.android.domain.locale +import android.content.res.Configuration import android.content.res.Resources import android.os.Build +import android.text.BidiFormatter import androidx.annotation.ArrayRes import androidx.annotation.StringRes import java.text.DateFormat import java.util.Locale +import java.util.Objects +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 @@ -17,7 +21,8 @@ class DisplayLocaleImpl( localeContext: OppiaLocaleContext, private val machineLocale: MachineLocale ): OppiaLocale.DisplayLocale(localeContext) { - private val formattingLocale by lazy { computeLocale() } + // TODO(#3766): Restrict to be 'internal'. + val formattingLocale: Locale by lazy { computeLocale() } private val dateFormat by lazy { DateFormat.getDateInstance(DATE_FORMAT_LENGTH, formattingLocale) } @@ -27,6 +32,12 @@ class DisplayLocaleImpl( private val dateTimeFormat by lazy { DateFormat.getDateTimeInstance(DATE_FORMAT_LENGTH, TIME_FORMAT_LENGTH, formattingLocale) } + private val bidiFormatter by lazy { BidiFormatter.getInstance(formattingLocale) } + + // TODO(#3766): Restrict to be 'internal'. + fun setAsDefault(configuration: Configuration) { + configuration.setLocale(formattingLocale) + } override fun getCurrentDateString(): String = dateFormat.format(oppiaClock.getCurrentDate()) @@ -35,7 +46,10 @@ class DisplayLocaleImpl( override fun getCurrentDateTimeString(): String = dateTimeFormat.format(oppiaClock.getCurrentDate()) - override fun String.formatInLocale(vararg args: Any?): String = format(formattingLocale, *args) + override fun String.formatInLocale(vararg args: Any?): String = + format(formattingLocale, *args.map { arg -> + if (arg is CharSequence) bidiFormatter.unicodeWrap(arg) else arg + }.toTypedArray()) override fun Resources.getStringInLocale(@StringRes id: Int): String = getString(id) @@ -45,6 +59,16 @@ class DisplayLocaleImpl( override fun Resources.getStringArrayInLocale(@ArrayRes id: Int): List = getStringArray(id).toList() + 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 fun computeLocale(): Locale { // 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 @@ -65,25 +89,21 @@ class DisplayLocaleImpl( return Locale(selectedProfile.languageCode, selectedProfile.regionCode) } - private fun computePotentialLanguageProfiles(): List { - return if (localeContext.languageDefinition.minAndroidSdkVersion <= Build.VERSION.SDK_INT) { - listOf( - getLanguageId().computeLocaleProfileFromAndroidId(), - getLanguageId().computeLocaleProfileFromIetfDefinitions(localeContext.regionDefinition), - getLanguageId().computeLocaleProfileFromMacaronicLanguage() - ) - } else listOf() - } + private fun computePotentialLanguageProfiles(): List = + computeLanguageProfiles(localeContext.languageDefinition, getLanguageId()) + + private fun computePotentialFallbackLanguageProfiles(): List = + computeLanguageProfiles(localeContext.fallbackLanguageDefinition, getFallbackLanguageId()) - private fun computePotentialFallbackLanguageProfiles(): List { - val fallbackLanguageMinSdk = localeContext.fallbackLanguageDefinition.minAndroidSdkVersion - return if (fallbackLanguageMinSdk <= Build.VERSION.SDK_INT) { + private fun computeLanguageProfiles( + definition: LanguageSupportDefinition, + languageId: LanguageId + ): List { + return if (definition.minAndroidSdkVersion <= Build.VERSION.SDK_INT) { listOf( - getFallbackLanguageId().computeLocaleProfileFromAndroidId(), - getFallbackLanguageId().computeLocaleProfileFromIetfDefinitions( - localeContext.regionDefinition - ), - getFallbackLanguageId().computeLocaleProfileFromMacaronicLanguage() + languageId.computeLocaleProfileFromAndroidId(), + languageId.computeLocaleProfileFromIetfDefinitions(localeContext.regionDefinition), + languageId.computeLocaleProfileFromMacaronicLanguage() ) } else listOf() } diff --git a/domain/src/main/java/org/oppia/android/domain/locale/LocaleController.kt b/domain/src/main/java/org/oppia/android/domain/locale/LocaleController.kt index 81910ee0cc4..8bbc0aae8a3 100644 --- a/domain/src/main/java/org/oppia/android/domain/locale/LocaleController.kt +++ b/domain/src/main/java/org/oppia/android/domain/locale/LocaleController.kt @@ -1,5 +1,7 @@ package org.oppia.android.domain.locale +import android.content.Context +import android.content.res.Configuration import org.oppia.android.app.model.LanguageSupportDefinition import org.oppia.android.app.model.OppiaLanguage import org.oppia.android.app.model.OppiaLocaleContext @@ -30,7 +32,7 @@ import org.oppia.android.util.data.DataProviders.Companion.transformAsync import org.oppia.android.util.system.OppiaClock // TODO: document how notifications work (everything is rooted from changing Locale). -private const val ANDROID_LOCALE_DATA_PROVIDER_ID = "android_locale" +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." @@ -38,6 +40,7 @@ private const val SYSTEM_LANGUAGE_DATA_PROVIDER_ID = "system_language" @Singleton class LocaleController @Inject constructor( + private val applicationContext: Context, private val dataProviders: DataProviders, private val languageConfigRetriever: LanguageConfigRetriever, private val oppiaLogger: OppiaLogger, @@ -50,30 +53,63 @@ class LocaleController @Inject constructor( private val machineLocaleImpl: MachineLocale by lazy { MachineLocaleImpl(oppiaClock) } - // TODO: this won't work in very specific cases (restore from bundle for new process). Tie to - // tracking TODO. + // TODO: explain what this is & how/when to use it. + 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: this should also work in cases when the process dies. fun reconstituteDisplayLocale(oppiaLocaleContext: OppiaLocaleContext): DisplayLocale { return DisplayLocaleImpl(oppiaClock, oppiaLocaleContext, machineLocaleImpl) } + // TODO: document + 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) + } + // TODO: document that retrieving country is a fixed thing. Explain usage mode. fun retrieveAppStringDisplayLocale(language: OppiaLanguage): DataProvider { val providerId = "$APP_STRING_LOCALE_DATA_BASE_PROVIDER_ID.${language.name}" - return getAndroidLocale().transformAsync(providerId) { systemLocaleProfile -> + return getSystemLocaleProfile().transformAsync(providerId) { systemLocaleProfile -> computeLocaleResult(language, systemLocaleProfile, APP_STRINGS) } } fun retrieveWrittenTranslationsLocale(language: OppiaLanguage): DataProvider { val providerId = "$WRITTEN_TRANSLATION_LOCALE_BASE_DATA_PROVIDER_ID.${language.name}" - return getAndroidLocale().transformAsync(providerId) { systemLocaleProfile -> + return getSystemLocaleProfile().transformAsync(providerId) { systemLocaleProfile -> computeLocaleResult(language, systemLocaleProfile, CONTENT_STRINGS) } } fun retrieveAudioTranslationsLocale(language: OppiaLanguage): DataProvider { val providerId = "$AUDIO_TRANSLATIONS_LOCALE_BASE_DATA_PROVIDER_ID.${language.name}" - return getAndroidLocale().transformAsync(providerId) { systemLocaleProfile -> + return getSystemLocaleProfile().transformAsync(providerId) { systemLocaleProfile -> computeLocaleResult(language, systemLocaleProfile, AUDIO_TRANSLATIONS) } } @@ -83,7 +119,7 @@ class LocaleController @Inject constructor( // TODO: document only matches to app language definitions. fun retrieveSystemLanguage(): DataProvider { val providerId = SYSTEM_LANGUAGE_DATA_PROVIDER_ID - return getAndroidLocale().transformAsync(providerId) { systemLocaleProfile -> + return getSystemLocaleProfile().transformAsync(providerId) { systemLocaleProfile -> // TODO: fix failover AsyncResult.success( retrieveLanguageDefinitionFromSystemCode(systemLocaleProfile.languageCode)?.language @@ -92,20 +128,54 @@ class LocaleController @Inject constructor( } } + // TODO: document that this can't be called due to Locale being prohibited broadly in the // codebase. Might be nice to find a more private signal mechanism. - fun updateDefaultLocale(newLocale: Locale) { - // TODO: add regex prohibiting this - Locale.setDefault(newLocale) - asyncDataSubscriptionManager.notifyChangeAsync(ANDROID_LOCALE_DATA_PROVIDER_ID) + 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. + // TODO: add regex prohibiting this + Locale.setDefault(locale.formattingLocale) + notifyPotentialLocaleChange() + } ?: error("Invalid display locale type passed in: $displayLocale") } - private fun getAndroidLocale(): DataProvider { - return dataProviders.createInMemoryDataProvider(ANDROID_LOCALE_DATA_PROVIDER_ID) { - AndroidLocaleProfile.createFrom(Locale.getDefault()) + 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. + */ + private fun getSystemLocale(): Locale { + val locales = applicationContext.resources.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." + ) + locales[0] + } else Locale.getDefault() + } + private suspend fun computeLocaleResult( language: OppiaLanguage, systemLocaleProfile: AndroidLocaleProfile, usageMode: LanguageUsageMode ): AsyncResult { diff --git a/domain/src/main/java/org/oppia/android/domain/locale/MachineLocaleImpl.kt b/domain/src/main/java/org/oppia/android/domain/locale/MachineLocaleImpl.kt index 8f05530f14e..fbc8f84dcff 100644 --- a/domain/src/main/java/org/oppia/android/domain/locale/MachineLocaleImpl.kt +++ b/domain/src/main/java/org/oppia/android/domain/locale/MachineLocaleImpl.kt @@ -51,6 +51,16 @@ class MachineLocaleImpl( return parsedDate?.let { OppiaDateImpl(it, oppiaClock.getCurrentDate()) } } + 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) } diff --git a/domain/src/main/java/org/oppia/android/domain/locale/OppiaLocale.kt b/domain/src/main/java/org/oppia/android/domain/locale/OppiaLocale.kt index 85adf063509..ecddf56cd42 100644 --- a/domain/src/main/java/org/oppia/android/domain/locale/OppiaLocale.kt +++ b/domain/src/main/java/org/oppia/android/domain/locale/OppiaLocale.kt @@ -1,8 +1,11 @@ package org.oppia.android.domain.locale +import android.content.Context +import android.content.res.Configuration import android.content.res.Resources import androidx.annotation.ArrayRes import androidx.annotation.StringRes +import java.util.Locale import org.oppia.android.app.model.LanguageSupportDefinition.LanguageId import org.oppia.android.app.model.OppiaLanguage import org.oppia.android.app.model.OppiaLocaleContext @@ -13,7 +16,10 @@ import org.oppia.android.app.model.OppiaLocaleContext.LanguageUsageMode.UNRECOGN import org.oppia.android.app.model.OppiaLocaleContext.LanguageUsageMode.USAGE_MODE_UNSPECIFIED import org.oppia.android.app.model.OppiaRegion -sealed class OppiaLocale(val localeContext: OppiaLocaleContext) { +// TOOD: document that equals, tostring, and hashcode are all properly implemented for subclasses? +sealed class OppiaLocale { + protected abstract val localeContext: OppiaLocaleContext + // TODO: verify exclusivity of regions/languages table in tests. fun getCurrentLanguage(): OppiaLanguage = localeContext.languageDefinition.language @@ -35,7 +41,7 @@ sealed class OppiaLocale(val localeContext: OppiaLocaleContext) { fun getCurrentRegion(): OppiaRegion = localeContext.regionDefinition.region // TODO: documentation (https://developer.android.com/reference/java/util/Locale). - abstract class MachineLocale(localeContext: OppiaLocaleContext): OppiaLocale(localeContext) { + abstract class MachineLocale(override val localeContext: OppiaLocaleContext): OppiaLocale() { abstract fun String.formatForMachines(vararg args: Any?): String abstract fun String.toMachineLowerCase(): String @@ -67,14 +73,14 @@ sealed class OppiaLocale(val localeContext: OppiaLocaleContext) { } } - abstract class DisplayLocale(localeContext: OppiaLocaleContext): OppiaLocale(localeContext) { + abstract class DisplayLocale(override val localeContext: OppiaLocaleContext): OppiaLocale() { abstract fun getCurrentDateString(): String abstract fun getCurrentTimeString(): String abstract fun getCurrentDateTimeString(): String - // TODO: mention bidi wrapping & machine readable args + // TODO: mention bidi wrapping (only applied to strings) & machine readable args // TODO: document that receiver is the format (unlike String.format()). abstract fun String.formatInLocale(vararg args: Any?): String @@ -87,5 +93,5 @@ sealed class OppiaLocale(val localeContext: OppiaLocaleContext) { abstract fun Resources.getStringArrayInLocale(@ArrayRes id: Int): List } - class ContentLocale(localeContext: OppiaLocaleContext): OppiaLocale(localeContext) + data class ContentLocale(override val localeContext: OppiaLocaleContext): OppiaLocale() } 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 index fc023d498ca..b719b6ec57f 100644 --- a/domain/src/main/java/org/oppia/android/domain/translation/BUILD.bazel +++ b/domain/src/main/java/org/oppia/android/domain/translation/BUILD.bazel @@ -6,7 +6,7 @@ load("@dagger//:workspace_defs.bzl", "dagger_rules") load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_android_library") kt_android_library( - name = "oppia_locale", + name = "translation_controller", srcs = [ "TranslationController.kt", ], 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 68d2de1d488..1ce36f626f9 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 @@ -97,9 +97,10 @@ class AssetRepository @Inject constructor( private fun primeProtoBlobFromLocalAssets(assetName: String) { repositoryLock.withLock { if (assetName !in protoFileAssets) { - val files = context.assets.list(/* path= */ ".")?.toList() ?: listOf() - protoFileAssets[assetName] = if (assetName in files) { - context.assets.open("$assetName.pb").use { it.readBytes() } + 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 } } From 014ac5438665352a68ab6a03e91afddb32afb1ef Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Thu, 9 Sep 2021 22:03:43 -0700 Subject: [PATCH 22/93] Add patterns & fixes. This involves MANY broad changes to ensure consistent string retrieval (for arrays and plurals), formatting, and string transformations throughout the codebase. Some extra patterns to added to fix things that were needed, and a few issues were fixed along the way. --- app/BUILD.bazel | 47 ++++---- .../app/activity/ActivityComponentImpl.kt | 2 + .../oppia/android/app/activity/BUILD.bazel | 1 + .../activity/InjectableAppCompatActivity.kt | 6 +- .../AdministratorControlsActivity.kt | 17 ++- .../LogoutDialogFragment.kt | 10 +- .../appversion/AppVersionViewModel.kt | 26 ++--- .../app/application/ApplicationComponent.kt | 3 +- .../app/application/ApplicationInjector.kt | 4 +- .../ApplicationInjectorProvider.kt | 6 +- .../customview/LessonThumbnailImageView.kt | 19 ++-- .../databinding/TextViewBindingAdapters.java | 103 ++++++++++++------ .../devoptions/DeveloperOptionsActivity.kt | 6 +- .../ForceNetworkTypeActivity.kt | 6 +- .../ForceNetworkTypeViewModel.kt | 11 +- .../MarkChaptersCompletedActivity.kt | 7 +- .../MarkStoriesCompletedActivity.kt | 7 +- .../MarkTopicsCompletedActivity.kt | 7 +- .../vieweventlogs/EventLogItemViewModel.kt | 32 +++--- .../vieweventlogs/ViewEventLogsActivity.kt | 6 +- .../vieweventlogs/ViewEventLogsViewModel.kt | 8 +- .../app/drawer/ExitProfileDialogFragment.kt | 9 +- .../NavigationDrawerFragmentPresenter.kt | 10 +- .../drawer/NavigationDrawerHeaderViewModel.kt | 39 ++++++- .../app/fragment/FragmentComponentImpl.kt | 22 +++- .../oppia/android/app/help/HelpActivity.kt | 13 ++- .../android/app/help/HelpActivityPresenter.kt | 24 ++-- .../android/app/help/HelpItemViewModel.kt | 8 +- .../android/app/help/HelpListViewModel.kt | 15 ++- .../app/help/faq/FAQListActivityPresenter.kt | 6 +- .../android/app/help/faq/FAQListViewModel.kt | 14 ++- .../faqsingle/FAQSingleActivityPresenter.kt | 6 +- .../LicenseListActivityPresenter.kt | 7 +- .../LicenseListFragmentPresenter.kt | 6 +- .../help/thirdparty/LicenseListViewModel.kt | 8 +- .../help/thirdparty/LicenseTextViewModel.kt | 9 +- .../LicenseTextViewerActivityPresenter.kt | 6 +- .../LicenseTextViewerFragmentPresenter.kt | 4 +- ...irdPartyDependencyListActivityPresenter.kt | 6 +- .../ThirdPartyDependencyListViewModel.kt | 16 +-- .../HintsAndSolutionDialogFragment.kt | 3 +- ...HintsAndSolutionDialogFragmentPresenter.kt | 13 ++- .../app/hintsandsolution/HintsViewModel.kt | 19 +++- .../RevealSolutionDialogFragment.kt | 18 ++- .../app/hintsandsolution/SolutionViewModel.kt | 1 + .../oppia/android/app/home/HomeActivity.kt | 7 +- .../android/app/home/HomeFragmentPresenter.kt | 11 +- .../oppia/android/app/home/HomeViewModel.kt | 16 ++- .../android/app/home/WelcomeViewModel.kt | 19 ++-- .../PromotedStoryListViewModel.kt | 12 +- .../recentlyplayed/OngoingStoryViewModel.kt | 10 +- .../RecentlyPlayedFragmentPresenter.kt | 24 ++-- .../home/topiclist/TopicSummaryViewModel.kt | 11 +- .../MyDownloadsFragmentPresenter.kt | 10 +- .../app/onboarding/OnboadingSlideViewModel.kt | 27 +++-- .../onboarding/OnboardingFragmentPresenter.kt | 16 ++- .../app/onboarding/OnboardingViewModel.kt | 21 +++- .../OngoingTopicItemViewModel.kt | 15 ++- .../OngoingTopicListViewModel.kt | 8 +- .../app/options/AppLanguageFragment.kt | 5 +- .../app/options/AudioLanguageFragment.kt | 5 +- .../android/app/options/OptionsActivity.kt | 22 +++- .../android/app/options/OptionsFragment.kt | 3 +- .../ReadingTextSizeFragmentPresenter.kt | 6 +- .../ReadingTextSizeSelectionViewModel.kt | 16 ++- .../app/options/TextSizeItemViewModel.kt | 17 ++- .../app/parser/StringToFractionParser.kt | 29 ++--- .../app/parser/StringToNumberParser.kt | 31 +++--- .../android/app/parser/StringToRatioParser.kt | 26 +++-- .../player/audio/AudioFragmentPresenter.kt | 12 +- .../app/player/audio/AudioViewModel.kt | 9 +- .../audio/CellularAudioDialogFragment.kt | 9 +- .../player/audio/LanguageDialogFragment.kt | 10 +- .../player/exploration/ExplorationFragment.kt | 9 +- .../ImageRegionSelectionInteractionView.kt | 12 +- .../android/app/player/state/StateFragment.kt | 8 +- .../state/StatePlayerRecyclerViewAssembler.kt | 39 ++++--- .../DragAndDropSortInteractionViewModel.kt | 15 ++- .../DragDropInteractionContentViewModel.kt | 25 ++++- .../FractionInteractionViewModel.kt | 21 ++-- ...mageRegionSelectionInteractionViewModel.kt | 5 +- .../InteractionViewModelModule.kt | 57 ++++++---- .../itemviewmodel/NumericInputViewModel.kt | 10 +- .../PreviousResponsesHeaderViewModel.kt | 13 ++- ...atioExpressionInputInteractionViewModel.kt | 18 ++- .../itemviewmodel/SubmittedAnswerViewModel.kt | 53 ++++++++- .../ProgressDatabaseFullDialogFragment.kt | 22 +++- .../StopExplorationDialogFragment.kt | 9 +- .../UnsavedExplorationDialogFragment.kt | 9 +- .../profile/AddProfileActivityPresenter.kt | 16 +-- .../app/profile/AdminAuthActivityPresenter.kt | 28 +++-- .../app/profile/AdminPinActivityPresenter.kt | 8 +- .../profile/AdminSettingsDialogFragment.kt | 3 +- .../AdminSettingsDialogFragmentPresenter.kt | 8 +- .../profile/PinPasswordActivityPresenter.kt | 8 +- .../app/profile/PinPasswordViewModel.kt | 11 +- .../app/profile/ProfileChooserViewModel.kt | 7 +- .../app/profile/ResetPinDialogFragment.kt | 3 +- .../ResetPinDialogFragmentPresenter.kt | 8 +- .../android/app/profile/ResetPinViewModel.kt | 19 +++- .../ProfilePictureEditDialogFragment.kt | 9 +- .../ProfileProgressViewModel.kt | 6 +- .../RecentlyPlayedStorySummaryViewModel.kt | 11 +- .../app/resumelesson/ResumeLessonFragment.kt | 7 +- .../ProfileEditDeletionDialogFragment.kt | 9 +- .../profile/ProfileListActivityPresenter.kt | 6 +- .../settings/profile/ProfileListViewModel.kt | 7 +- .../profile/ProfileRenameActivityPresenter.kt | 10 +- .../ProfileResetPinActivityPresenter.kt | 10 +- .../app/splash/SplashActivityPresenter.kt | 2 +- .../oppia/android/app/story/StoryFragment.kt | 5 +- .../app/story/StoryFragmentPresenter.kt | 6 +- .../oppia/android/app/story/StoryViewModel.kt | 9 +- .../StoryChapterSummaryViewModel.kt | 11 +- .../StoryHeaderViewModel.kt | 16 ++- .../InputInteractionViewTestActivity.kt | 30 +++-- .../testing/NavigationDrawerTestActivity.kt | 7 +- .../oppia/android/app/topic/TopicFragment.kt | 5 +- .../app/topic/TopicFragmentPresenter.kt | 6 +- .../oppia/android/app/topic/TopicViewModel.kt | 13 ++- .../topic/conceptcard/ConceptCardFragment.kt | 3 +- .../app/topic/info/TopicInfoFragment.kt | 3 +- .../topic/info/TopicInfoFragmentPresenter.kt | 5 +- .../app/topic/info/TopicInfoViewModel.kt | 46 ++++++-- .../topic/lessons/ChapterSummaryViewModel.kt | 19 +++- .../topic/lessons/StorySummaryViewModel.kt | 50 ++++++++- .../app/topic/lessons/TopicLessonViewModel.kt | 7 +- .../app/topic/lessons/TopicLessonsFragment.kt | 5 +- .../lessons/TopicLessonsFragmentPresenter.kt | 2 +- .../topic/practice/TopicPracticeFragment.kt | 3 +- .../questionplayer/QuestionPlayerViewModel.kt | 16 ++- .../topic/revision/TopicRevisionFragment.kt | 3 +- .../revisioncard/RevisionCardFragment.kt | 3 +- .../AppLanguageActivityInjector.kt | 2 + .../AppLanguageActivityInjectorProvider.kt | 5 + .../translation/AppLanguageLocaleHandler.kt | 2 +- .../translation/AppLanguageResourceHandler.kt | 30 ++++- .../oppia/android/app/translation/BUILD.bazel | 18 ++- .../android/app/utility/RatioExtensions.kt | 6 +- .../android/app/utility/datetime/BUILD.bazel | 2 + .../app/utility/datetime/DateTimeUtil.kt | 26 ++--- .../end/WalkthroughFinalFragment.kt | 3 +- .../end/WalkthroughFinalFragmentPresenter.kt | 6 +- .../topiclist/WalkthroughTopicViewModel.kt | 7 +- .../WalkthroughTopicSummaryViewModel.kt | 14 ++- .../WalkthroughWelcomeFragmentPresenter.kt | 8 +- .../main/res/layout-land/hints_summary.xml | 2 +- .../res/layout-land/lessons_chapter_view.xml | 4 +- .../res/layout-land/onboarding_fragment.xml | 2 +- .../res/layout-land/ongoing_story_card.xml | 2 +- .../res/layout-land/ongoing_topic_item.xml | 2 +- .../res/layout-land/pin_password_activity.xml | 2 +- ...le_progress_recently_played_story_card.xml | 2 +- .../layout-land/question_player_fragment.xml | 2 +- .../main/res/layout-land/solution_summary.xml | 2 +- .../res/layout-land/story_chapter_view.xml | 2 +- .../res/layout-land/story_header_view.xml | 2 +- .../res/layout-land/topic_info_fragment.xml | 4 +- .../topic_lessons_story_summary.xml | 18 ++- .../res/layout-land/topic_summary_view.xml | 2 +- .../res/layout-sw600dp-land/hints_summary.xml | 2 +- .../onboarding_fragment.xml | 2 +- .../ongoing_story_card.xml | 2 +- .../ongoing_topic_item.xml | 2 +- .../pin_password_activity.xml | 2 +- ...le_progress_recently_played_story_card.xml | 2 +- .../question_player_fragment.xml | 2 +- .../layout-sw600dp-land/solution_summary.xml | 2 +- .../layout-sw600dp-land/topic_fragment.xml | 2 +- .../topic_info_fragment.xml | 4 +- .../topic_lessons_story_summary.xml | 18 ++- .../topic_summary_view.xml | 2 +- .../res/layout-sw600dp-port/hints_summary.xml | 2 +- .../onboarding_fragment.xml | 2 +- .../ongoing_story_card.xml | 2 +- .../ongoing_topic_item.xml | 2 +- .../pin_password_activity.xml | 2 +- ...le_progress_recently_played_story_card.xml | 2 +- .../question_player_fragment.xml | 2 +- .../layout-sw600dp-port/solution_summary.xml | 2 +- .../layout-sw600dp-port/topic_fragment.xml | 2 +- .../topic_info_fragment.xml | 4 +- .../topic_lessons_story_summary.xml | 18 ++- .../topic_summary_view.xml | 2 +- .../res/layout-sw600dp/story_chapter_view.xml | 2 +- .../res/layout-sw600dp/story_header_view.xml | 2 +- .../main/res/layout/app_version_fragment.xml | 4 +- .../layout/drag_drop_interaction_items.xml | 8 +- app/src/main/res/layout/hints_summary.xml | 2 +- .../main/res/layout/lessons_chapter_view.xml | 4 +- .../layout/nav_header_navigation_drawer.xml | 2 +- .../main/res/layout/onboarding_fragment.xml | 2 +- .../main/res/layout/ongoing_story_card.xml | 2 +- .../main/res/layout/ongoing_topic_item.xml | 2 +- .../main/res/layout/pin_password_activity.xml | 2 +- .../layout/previous_responses_header_item.xml | 2 +- ...le_progress_recently_played_story_card.xml | 2 +- .../res/layout/question_player_fragment.xml | 2 +- app/src/main/res/layout/reset_pin_dialog.xml | 2 +- app/src/main/res/layout/solution_summary.xml | 2 +- .../main/res/layout/story_chapter_view.xml | 2 +- app/src/main/res/layout/story_header_view.xml | 2 +- .../main/res/layout/submitted_answer_item.xml | 12 +- app/src/main/res/layout/topic_fragment.xml | 2 +- .../main/res/layout/topic_info_fragment.xml | 4 +- .../layout/topic_lessons_story_summary.xml | 18 ++- .../main/res/layout/topic_summary_view.xml | 2 +- .../layout/walkthrough_topic_summary_view.xml | 2 +- app/src/main/res/layout/welcome.xml | 4 +- .../android/app/home/HomeActivityTest.kt | 10 +- .../app/recyclerview/RecyclerViewMatcher.kt | 5 +- domain/BUILD.bazel | 1 + ...TextInputContainsRuleClassifierProvider.kt | 9 +- .../TextInputEqualsRuleClassifierProvider.kt | 6 +- ...tInputFuzzyEqualsRuleClassifierProvider.kt | 8 +- ...xtInputStartsWithRuleClassifierProvider.kt | 9 +- .../exploration/ExplorationRetriever.kt | 11 +- .../onboarding/AppStartupStateController.kt | 3 +- .../loguploader/LogUploadWorker.kt | 3 +- .../syncup/PlatformParameterSyncUpWorker.kt | 3 +- .../profile/ProfileManagementController.kt | 9 +- .../domain/question/QuestionRetriever.kt | 6 +- .../domain/topic/ConceptCardRetriever.kt | 13 ++- .../topic/PrimeTopicAssetsControllerImpl.kt | 7 +- .../domain/topic/RevisionCardRetriever.kt | 7 +- .../android/domain/topic/TopicController.kt | 11 +- .../domain/topic/TopicListController.kt | 5 +- .../org/oppia/android/domain/util/BUILD.bazel | 5 + .../android/domain/util/JsonAssetRetriever.kt | 2 +- .../android/domain/util/JsonExtensions.kt | 8 ++ .../android/domain/util/StateRetriever.kt | 48 ++++---- .../android/domain/util/WorkDataExtensions.kt | 5 + .../file_content_validation_checks.textproto | 70 +++++++++++- .../file_content_validation_checks.proto | 3 + .../regex/RegexPatternValidationCheck.kt | 7 +- .../environment/TestEnvironmentConfig.kt | 8 +- utility/BUILD.bazel | 2 +- .../util/extensions/BundleExtensions.kt | 2 + .../util/parser/image/UrlImageParser.kt | 16 ++- .../org/oppia/android/util/system/BUILD.bazel | 17 ++- .../android/util/system/OppiaClockInjector.kt | 5 + .../util/system/OppiaClockInjectorProvider.kt | 5 + .../util/system/OppiaDateTimeFormatter.kt | 46 -------- .../html/CustomHtmlContentHandlerTest.kt | 3 +- .../util/system/OppiaDateTimeFormatterTest.kt | 2 + 245 files changed, 1712 insertions(+), 836 deletions(-) create mode 100644 app/src/main/java/org/oppia/android/app/translation/AppLanguageActivityInjectorProvider.kt create mode 100644 domain/src/main/java/org/oppia/android/domain/util/JsonExtensions.kt create mode 100644 domain/src/main/java/org/oppia/android/domain/util/WorkDataExtensions.kt create mode 100644 utility/src/main/java/org/oppia/android/util/system/OppiaClockInjector.kt create mode 100644 utility/src/main/java/org/oppia/android/util/system/OppiaClockInjectorProvider.kt delete mode 100644 utility/src/main/java/org/oppia/android/util/system/OppiaDateTimeFormatter.kt diff --git a/app/BUILD.bazel b/app/BUILD.bazel index 6c6c260d61b..eed4086d6d9 100644 --- a/app/BUILD.bazel +++ b/app/BUILD.bazel @@ -152,7 +152,9 @@ DATABINDING_LAYOUTS = ["src/main/res/layout*/**"] # keep sorted VIEW_MODELS_WITH_RESOURCE_IMPORTS = [ + "src/main/java/org/oppia/android/app/administratorcontrols/appversion/AppVersionViewModel.kt", "src/main/java/org/oppia/android/app/devoptions/forcenetworktype/ForceNetworkTypeViewModel.kt", + "src/main/java/org/oppia/android/app/drawer/NavigationDrawerHeaderViewModel.kt", "src/main/java/org/oppia/android/app/help/HelpItemViewModel.kt", "src/main/java/org/oppia/android/app/help/HelpListViewModel.kt", "src/main/java/org/oppia/android/app/help/HelpViewModel.kt", @@ -160,7 +162,9 @@ VIEW_MODELS_WITH_RESOURCE_IMPORTS = [ "src/main/java/org/oppia/android/app/help/thirdparty/LicenseListViewModel.kt", "src/main/java/org/oppia/android/app/help/thirdparty/LicenseTextViewModel.kt", "src/main/java/org/oppia/android/app/help/thirdparty/ThirdPartyDependencyListViewModel.kt", + "src/main/java/org/oppia/android/app/hintsandsolution/HintsViewModel.kt", "src/main/java/org/oppia/android/app/home/HomeViewModel.kt", + "src/main/java/org/oppia/android/app/home/WelcomeViewModel.kt", "src/main/java/org/oppia/android/app/home/promotedlist/ComingSoonTopicsViewModel.kt", "src/main/java/org/oppia/android/app/home/promotedlist/PromotedStoryListViewModel.kt", "src/main/java/org/oppia/android/app/home/promotedlist/PromotedStoryViewModel.kt", @@ -168,16 +172,30 @@ VIEW_MODELS_WITH_RESOURCE_IMPORTS = [ "src/main/java/org/oppia/android/app/home/topiclist/TopicSummaryViewModel.kt", "src/main/java/org/oppia/android/app/onboarding/OnboadingSlideViewModel.kt", "src/main/java/org/oppia/android/app/onboarding/OnboardingViewModel.kt", + "src/main/java/org/oppia/android/app/ongoingtopiclist/OngoingTopicItemViewModel.kt", "src/main/java/org/oppia/android/app/options/TextSizeItemViewModel.kt", "src/main/java/org/oppia/android/app/parser/StringToFractionParser.kt", "src/main/java/org/oppia/android/app/parser/StringToNumberParser.kt", "src/main/java/org/oppia/android/app/parser/StringToRatioParser.kt", + "src/main/java/org/oppia/android/app/player/state/itemviewmodel/DragDropInteractionContentViewModel.kt", "src/main/java/org/oppia/android/app/player/state/itemviewmodel/FractionInteractionViewModel.kt", "src/main/java/org/oppia/android/app/player/state/itemviewmodel/ImageRegionSelectionInteractionViewModel.kt", + "src/main/java/org/oppia/android/app/player/state/itemviewmodel/PreviousResponsesHeaderViewModel.kt", "src/main/java/org/oppia/android/app/player/state/itemviewmodel/RatioExpressionInputInteractionViewModel.kt", + "src/main/java/org/oppia/android/app/player/state/itemviewmodel/SubmittedAnswerViewModel.kt", + "src/main/java/org/oppia/android/app/profile/PinPasswordViewModel.kt", + "src/main/java/org/oppia/android/app/profile/ResetPinViewModel.kt", "src/main/java/org/oppia/android/app/profileprogress/ProfileProgressViewModel.kt", + "src/main/java/org/oppia/android/app/profileprogress/RecentlyPlayedStorySummaryViewModel.kt", + "src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryChapterSummaryViewModel.kt", + "src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryHeaderViewModel.kt", + "src/main/java/org/oppia/android/app/topic/TopicViewModel.kt", "src/main/java/org/oppia/android/app/topic/info/TopicInfoViewModel.kt", + "src/main/java/org/oppia/android/app/topic/lessons/ChapterSummaryViewModel.kt", + "src/main/java/org/oppia/android/app/topic/lessons/StorySummaryViewModel.kt", + "src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerViewModel.kt", "src/main/java/org/oppia/android/app/utility/RatioExtensions.kt", + "src/main/java/org/oppia/android/app/walkthrough/topiclist/topiclistviewmodel/WalkthroughTopicSummaryViewModel.kt", ] # Source files for the view_models library that DO NOT import resources. @@ -194,7 +212,7 @@ VIEW_MODELS = [ "src/main/java/org/oppia/android/app/administratorcontrols/administratorcontrolsitemviewmodel/AdministratorControlsItemViewModel.kt", "src/main/java/org/oppia/android/app/administratorcontrols/administratorcontrolsitemviewmodel/AdministratorControlsProfileViewModel.kt", "src/main/java/org/oppia/android/app/administratorcontrols/AdministratorControlsViewModel.kt", - "src/main/java/org/oppia/android/app/administratorcontrols/appversion/AppVersionViewModel.kt", + "src/main/java/org/oppia/android/app/devoptions/marktopicscompleted/TopicViewModel.kt", "src/main/java/org/oppia/android/app/completedstorylist/CompletedStoryItemViewModel.kt", "src/main/java/org/oppia/android/app/completedstorylist/CompletedStoryListViewModel.kt", "src/main/java/org/oppia/android/app/devoptions/devoptionsitemviewmodel/DeveloperOptionsItemViewModel.kt", @@ -210,11 +228,9 @@ VIEW_MODELS = [ "src/main/java/org/oppia/android/app/devoptions/markstoriescompleted/MarkStoriesCompletedViewModel.kt", "src/main/java/org/oppia/android/app/devoptions/markstoriescompleted/StorySummaryViewModel.kt", "src/main/java/org/oppia/android/app/devoptions/marktopicscompleted/MarkTopicsCompletedViewModel.kt", - "src/main/java/org/oppia/android/app/devoptions/marktopicscompleted/TopicViewModel.kt", "src/main/java/org/oppia/android/app/devoptions/vieweventlogs/EventLogItemViewModel.kt", "src/main/java/org/oppia/android/app/devoptions/vieweventlogs/ViewEventLogsViewModel.kt", "src/main/java/org/oppia/android/app/drawer/NavigationDrawerFooterViewModel.kt", - "src/main/java/org/oppia/android/app/drawer/NavigationDrawerHeaderViewModel.kt", "src/main/java/org/oppia/android/app/help/faq/faqItemViewModel/FAQContentViewModel.kt", "src/main/java/org/oppia/android/app/help/faq/faqItemViewModel/FAQHeaderViewModel.kt", "src/main/java/org/oppia/android/app/help/faq/faqItemViewModel/FAQItemViewModel.kt", @@ -223,7 +239,6 @@ VIEW_MODELS = [ "src/main/java/org/oppia/android/app/help/thirdparty/ThirdPartyDependencyItemViewModel.kt", "src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionItemViewModel.kt", "src/main/java/org/oppia/android/app/hintsandsolution/HintsDividerViewModel.kt", - "src/main/java/org/oppia/android/app/hintsandsolution/HintsViewModel.kt", "src/main/java/org/oppia/android/app/hintsandsolution/SolutionViewModel.kt", "src/main/java/org/oppia/android/app/home/HomeItemViewModel.kt", "src/main/java/org/oppia/android/app/home/promotedlist/ComingSoonTopicListViewModel.kt", @@ -231,11 +246,9 @@ VIEW_MODELS = [ "src/main/java/org/oppia/android/app/home/recentlyplayed/SectionTitleViewModel.kt", "src/main/java/org/oppia/android/app/home/topiclist/AllTopicsViewModel.kt", "src/main/java/org/oppia/android/app/home/UserAppHistoryViewModel.kt", - "src/main/java/org/oppia/android/app/home/WelcomeViewModel.kt", "src/main/java/org/oppia/android/app/onboarding/OnboardingSlideFinalViewModel.kt", "src/main/java/org/oppia/android/app/onboarding/OnboardingViewPagerViewModel.kt", "src/main/java/org/oppia/android/app/onboarding/ViewPagerSlide.kt", - "src/main/java/org/oppia/android/app/ongoingtopiclist/OngoingTopicItemViewModel.kt", "src/main/java/org/oppia/android/app/ongoingtopiclist/OngoingTopicListViewModel.kt", "src/main/java/org/oppia/android/app/options/LanguageItemViewModel.kt", "src/main/java/org/oppia/android/app/options/LanguageSelectionViewModel.kt", @@ -251,19 +264,16 @@ VIEW_MODELS = [ "src/main/java/org/oppia/android/app/player/state/itemviewmodel/ContinueInteractionViewModel.kt", "src/main/java/org/oppia/android/app/player/state/itemviewmodel/ContinueNavigationButtonViewModel.kt", "src/main/java/org/oppia/android/app/player/state/itemviewmodel/DragAndDropSortInteractionViewModel.kt", - "src/main/java/org/oppia/android/app/player/state/itemviewmodel/DragDropInteractionContentViewModel.kt", "src/main/java/org/oppia/android/app/player/state/itemviewmodel/FeedbackViewModel.kt", "src/main/java/org/oppia/android/app/player/state/itemviewmodel/NextButtonViewModel.kt", "src/main/java/org/oppia/android/app/player/state/itemviewmodel/NumericInputViewModel.kt", "src/main/java/org/oppia/android/app/player/state/itemviewmodel/PreviousButtonViewModel.kt", - "src/main/java/org/oppia/android/app/player/state/itemviewmodel/PreviousResponsesHeaderViewModel.kt", "src/main/java/org/oppia/android/app/player/state/itemviewmodel/ReplayButtonViewModel.kt", "src/main/java/org/oppia/android/app/player/state/itemviewmodel/ReturnToTopicButtonViewModel.kt", "src/main/java/org/oppia/android/app/player/state/itemviewmodel/SelectionInteractionContentViewModel.kt", "src/main/java/org/oppia/android/app/player/state/itemviewmodel/SelectionInteractionViewModel.kt", "src/main/java/org/oppia/android/app/player/state/itemviewmodel/StateItemViewModel.kt", "src/main/java/org/oppia/android/app/player/state/itemviewmodel/SubmitButtonViewModel.kt", - "src/main/java/org/oppia/android/app/player/state/itemviewmodel/SubmittedAnswerViewModel.kt", "src/main/java/org/oppia/android/app/player/state/itemviewmodel/TextInputViewModel.kt", "src/main/java/org/oppia/android/app/player/state/StateViewModel.kt", "src/main/java/org/oppia/android/app/player/state/testing/StateFragmentTestViewModel.kt", @@ -271,13 +281,10 @@ VIEW_MODELS = [ "src/main/java/org/oppia/android/app/profile/AdminAuthViewModel.kt", "src/main/java/org/oppia/android/app/profile/AdminPinViewModel.kt", "src/main/java/org/oppia/android/app/profile/AdminSettingsViewModel.kt", - "src/main/java/org/oppia/android/app/profile/PinPasswordViewModel.kt", "src/main/java/org/oppia/android/app/profile/ProfileChooserViewModel.kt", - "src/main/java/org/oppia/android/app/profile/ResetPinViewModel.kt", "src/main/java/org/oppia/android/app/profileprogress/ProfilePictureActivityViewModel.kt", "src/main/java/org/oppia/android/app/profileprogress/ProfileProgressHeaderViewModel.kt", "src/main/java/org/oppia/android/app/profileprogress/ProfileProgressItemViewModel.kt", - "src/main/java/org/oppia/android/app/profileprogress/RecentlyPlayedStorySummaryViewModel.kt", "src/main/java/org/oppia/android/app/recyclerview/BindableAdapter.kt", "src/main/java/org/oppia/android/app/recyclerview/DividerItemDecorator.kt", "src/main/java/org/oppia/android/app/recyclerview/DragAndDropItemFacilitator.kt", @@ -288,15 +295,11 @@ VIEW_MODELS = [ "src/main/java/org/oppia/android/app/settings/profile/ProfileResetPinViewModel.kt", "src/main/java/org/oppia/android/app/story/ExplorationSelectionListener.kt", "src/main/java/org/oppia/android/app/story/StoryFragmentScroller.kt", - "src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryChapterSummaryViewModel.kt", - "src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryHeaderViewModel.kt", "src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryItemViewModel.kt", "src/main/java/org/oppia/android/app/story/StoryViewModel.kt", "src/main/java/org/oppia/android/app/testing/BindableAdapterTestDataModel.kt", "src/main/java/org/oppia/android/app/testing/BindableAdapterTestViewModel.kt", "src/main/java/org/oppia/android/app/topic/conceptcard/ConceptCardViewModel.kt", - "src/main/java/org/oppia/android/app/topic/lessons/StorySummaryViewModel.kt", - "src/main/java/org/oppia/android/app/topic/lessons/ChapterSummaryViewModel.kt", "src/main/java/org/oppia/android/app/topic/lessons/TopicLessonViewModel.kt", "src/main/java/org/oppia/android/app/topic/lessons/TopicLessonsItemViewModel.kt", "src/main/java/org/oppia/android/app/topic/lessons/TopicLessonsTitleViewModel.kt", @@ -305,14 +308,11 @@ VIEW_MODELS = [ "src/main/java/org/oppia/android/app/topic/practice/practiceitemviewmodel/TopicPracticeItemViewModel.kt", "src/main/java/org/oppia/android/app/topic/practice/practiceitemviewmodel/TopicPracticeSubtopicViewModel.kt", "src/main/java/org/oppia/android/app/topic/practice/TopicPracticeViewModel.kt", - "src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerViewModel.kt", "src/main/java/org/oppia/android/app/topic/revision/revisionitemviewmodel/TopicRevisionItemViewModel.kt", "src/main/java/org/oppia/android/app/topic/revision/TopicRevisionViewModel.kt", "src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardViewModel.kt", - "src/main/java/org/oppia/android/app/topic/TopicViewModel.kt", "src/main/java/org/oppia/android/app/walkthrough/end/WalkthroughFinalViewModel.kt", "src/main/java/org/oppia/android/app/walkthrough/topiclist/topiclistviewmodel/WalkthroughTopicHeaderViewModel.kt", - "src/main/java/org/oppia/android/app/walkthrough/topiclist/topiclistviewmodel/WalkthroughTopicSummaryViewModel.kt", "src/main/java/org/oppia/android/app/walkthrough/topiclist/WalkthroughTopicItemViewModel.kt", "src/main/java/org/oppia/android/app/walkthrough/topiclist/WalkthroughTopicViewModel.kt", "src/main/java/org/oppia/android/app/walkthrough/WalkthroughViewModel.kt", @@ -530,6 +530,8 @@ android_library( ":resources", ":view_models", ":views", + "//app/src/main/java/org/oppia/android/app/translation:app_language_activity_injector_provider", + "//app/src/main/java/org/oppia/android/app/translation:app_language_resource_handler", "//model:interaction_object_java_proto_lite", "//model:thumbnail_java_proto_lite", "//third_party:androidx_annotation_annotation", @@ -550,6 +552,8 @@ android_library( "//third_party:io_github_chaosleung_pinview", "//third_party:javax_annotation_javax_annotation-api_jar", "//third_party:nl_dionsegijn_konfetti", + "//utility/src/main/java/org/oppia/android/util/system:oppia_clock", + "//utility/src/main/java/org/oppia/android/util/system:oppia_clock_injector_provider", ], ) @@ -658,6 +662,8 @@ android_library( ":dagger", ":resources", ":view_models", + "//app/src/main/java/org/oppia/android/app/translation:app_language_activity_injector_provider", + "//app/src/main/java/org/oppia/android/app/translation:app_language_resource_handler", "//model:thumbnail_java_proto_lite", "//third_party:androidx_annotation_annotation", "//third_party:androidx_constraintlayout_constraintlayout", @@ -667,6 +673,8 @@ android_library( "//third_party:com_google_android_material_material", "//third_party:io_github_chaosleung_pinview", "//utility", + "//utility/src/main/java/org/oppia/android/util/system:oppia_clock", + "//utility/src/main/java/org/oppia/android/util/system:oppia_clock_injector_provider", ], ) @@ -717,6 +725,7 @@ kt_android_library( "//utility", "//utility/src/main/java/org/oppia/android/util/accessibility:prod_module", "//utility/src/main/java/org/oppia/android/util/extensions:bundle_extensions", + "//utility/src/main/java/org/oppia/android/util/locale:prod_module", # 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", "//utility/src/main/java/org/oppia/android/util/statusbar:status_bar_color", diff --git a/app/src/main/java/org/oppia/android/app/activity/ActivityComponentImpl.kt b/app/src/main/java/org/oppia/android/app/activity/ActivityComponentImpl.kt index 05316f3efd8..07cc2147499 100644 --- a/app/src/main/java/org/oppia/android/app/activity/ActivityComponentImpl.kt +++ b/app/src/main/java/org/oppia/android/app/activity/ActivityComponentImpl.kt @@ -77,6 +77,7 @@ import org.oppia.android.app.walkthrough.WalkthroughActivity import javax.inject.Provider import org.oppia.android.app.fragment.FragmentComponentBuilderInjector import org.oppia.android.app.fragment.FragmentComponentBuilderModule +import org.oppia.android.app.testing.InputInteractionViewTestActivity // TODO(#59): Restrict access to this implementation by introducing injectors in each activity. @@ -120,6 +121,7 @@ interface ActivityComponentImpl: ActivityComponent, FragmentComponentBuilderInje fun inject(homeTestActivity: HomeTestActivity) fun inject(htmlParserTestActivity: HtmlParserTestActivity) fun inject(imageRegionSelectionTestActivity: ImageRegionSelectionTestActivity) + fun inject(inputInteractionViewTestActivity: InputInteractionViewTestActivity) fun inject(licenseListActivity: LicenseListActivity) fun inject(licenseTextViewerActivity: LicenseTextViewerActivity) fun inject(markChaptersCompletedActivity: MarkChaptersCompletedActivity) diff --git a/app/src/main/java/org/oppia/android/app/activity/BUILD.bazel b/app/src/main/java/org/oppia/android/app/activity/BUILD.bazel index ad899603c26..bd36217bb6b 100644 --- a/app/src/main/java/org/oppia/android/app/activity/BUILD.bazel +++ b/app/src/main/java/org/oppia/android/app/activity/BUILD.bazel @@ -35,6 +35,7 @@ kt_android_library( "//app/src/main/java/org/oppia/android/app/fragment:fragment_component_builder_injector", "//app/src/main/java/org/oppia/android/app/fragment:fragment_component_factory", "//app/src/main/java/org/oppia/android/app/translation:app_language_activity_injector", + "//app/src/main/java/org/oppia/android/app/translation:app_language_activity_injector_provider", "//app/src/main/java/org/oppia/android/app/translation:app_language_application_injector", "//app/src/main/java/org/oppia/android/app/translation:app_language_application_injector_provider", ], diff --git a/app/src/main/java/org/oppia/android/app/activity/InjectableAppCompatActivity.kt b/app/src/main/java/org/oppia/android/app/activity/InjectableAppCompatActivity.kt index 94eff37c7a8..d72abef2e75 100644 --- a/app/src/main/java/org/oppia/android/app/activity/InjectableAppCompatActivity.kt +++ b/app/src/main/java/org/oppia/android/app/activity/InjectableAppCompatActivity.kt @@ -10,13 +10,15 @@ import org.oppia.android.app.fragment.FragmentComponent import org.oppia.android.app.fragment.FragmentComponentBuilderInjector import org.oppia.android.app.fragment.FragmentComponentFactory import org.oppia.android.app.translation.AppLanguageActivityInjector +import org.oppia.android.app.translation.AppLanguageActivityInjectorProvider import org.oppia.android.app.translation.AppLanguageApplicationInjectorProvider /** * An [AppCompatActivity] that facilitates field injection to child activities and constituent * fragments that extend [org.oppia.android.app.fragment.InjectableFragment]. */ -abstract class InjectableAppCompatActivity : AppCompatActivity(), FragmentComponentFactory { +abstract class InjectableAppCompatActivity : + AppCompatActivity(), FragmentComponentFactory, AppLanguageActivityInjectorProvider { /** * The [ActivityComponent] corresponding to this activity. This cannot be used before * [attachBaseContext] is called, and can be used to inject lateinit fields in child activities @@ -67,6 +69,8 @@ abstract class InjectableAppCompatActivity : AppCompatActivity(), FragmentCompon return builderInjector.getFragmentComponentBuilderProvider().get().setFragment(fragment).build() } + override fun getAppLanguageActivityInjector(): AppLanguageActivityInjector = activityComponent + private fun initializeActivityComponent(applicationContext: Context) { val componentFactory = applicationContext as ActivityComponentFactory activityComponent = componentFactory.createActivityComponent(this) diff --git a/app/src/main/java/org/oppia/android/app/administratorcontrols/AdministratorControlsActivity.kt b/app/src/main/java/org/oppia/android/app/administratorcontrols/AdministratorControlsActivity.kt index 8a0fb73f5f2..32cfa52ecbe 100644 --- a/app/src/main/java/org/oppia/android/app/administratorcontrols/AdministratorControlsActivity.kt +++ b/app/src/main/java/org/oppia/android/app/administratorcontrols/AdministratorControlsActivity.kt @@ -10,6 +10,8 @@ import org.oppia.android.app.drawer.NAVIGATION_PROFILE_ID_ARGUMENT_KEY import org.oppia.android.app.settings.profile.ProfileListActivity import javax.inject.Inject import org.oppia.android.app.activity.ActivityComponentImpl +import org.oppia.android.app.translation.AppLanguageResourceHandler +import org.oppia.android.util.extensions.getStringFromBundle const val SELECTED_CONTROLS_TITLE_SAVED_KEY = "AdministratorControlsActivity.selected_controls_title" @@ -27,12 +29,15 @@ class AdministratorControlsActivity : ShowLogoutDialogListener { @Inject lateinit var administratorControlsActivityPresenter: AdministratorControlsActivityPresenter + @Inject + lateinit var resourceHandler: AppLanguageResourceHandler private lateinit var lastLoadedFragment: String override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) (activityComponent as ActivityComponentImpl).inject(this) - val extraControlsTitle = savedInstanceState?.getString(SELECTED_CONTROLS_TITLE_SAVED_KEY) + val extraControlsTitle = + savedInstanceState?.getStringFromBundle(SELECTED_CONTROLS_TITLE_SAVED_KEY) lastLoadedFragment = if (savedInstanceState != null) { savedInstanceState.get(LAST_LOADED_FRAGMENT_EXTRA_KEY) as String } else { @@ -40,7 +45,7 @@ class AdministratorControlsActivity : PROFILE_LIST_FRAGMENT } administratorControlsActivityPresenter.handleOnCreate(extraControlsTitle, lastLoadedFragment) - title = getString(R.string.administrator_controls) + title = resourceHandler.getStringInLocale(R.string.administrator_controls) } override fun routeToAppVersion() { @@ -66,14 +71,18 @@ class AdministratorControlsActivity : override fun loadProfileList() { lastLoadedFragment = PROFILE_LIST_FRAGMENT administratorControlsActivityPresenter - .setExtraControlsTitle(getString(R.string.administrator_controls_edit_profiles)) + .setExtraControlsTitle( + resourceHandler.getStringInLocale(R.string.administrator_controls_edit_profiles) + ) administratorControlsActivityPresenter.loadProfileList() } override fun loadAppVersion() { lastLoadedFragment = APP_VERSION_FRAGMENT administratorControlsActivityPresenter - .setExtraControlsTitle(getString(R.string.administrator_controls_app_version)) + .setExtraControlsTitle( + resourceHandler.getStringInLocale(R.string.administrator_controls_app_version) + ) administratorControlsActivityPresenter.loadAppVersion() } diff --git a/app/src/main/java/org/oppia/android/app/administratorcontrols/LogoutDialogFragment.kt b/app/src/main/java/org/oppia/android/app/administratorcontrols/LogoutDialogFragment.kt index 868cafc3ed9..ae8af638605 100644 --- a/app/src/main/java/org/oppia/android/app/administratorcontrols/LogoutDialogFragment.kt +++ b/app/src/main/java/org/oppia/android/app/administratorcontrols/LogoutDialogFragment.kt @@ -1,14 +1,17 @@ package org.oppia.android.app.administratorcontrols import android.app.Dialog +import android.content.Context import android.os.Bundle import androidx.appcompat.app.AlertDialog import androidx.fragment.app.DialogFragment import org.oppia.android.R +import org.oppia.android.app.fragment.FragmentComponentImpl +import org.oppia.android.app.fragment.InjectableDialogFragment import org.oppia.android.app.profile.ProfileChooserActivity /** [DialogFragment] that gives option to either cancel or log out from current profile. */ -class LogoutDialogFragment : DialogFragment() { +class LogoutDialogFragment : InjectableDialogFragment() { companion object { const val TAG_LOGOUT_DIALOG_FRAGMENT = "TAG_LOGOUT_DIALOG_FRAGMENT" @@ -18,6 +21,11 @@ class LogoutDialogFragment : DialogFragment() { } } + override fun onAttach(context: Context) { + super.onAttach(context) + (fragmentComponent as FragmentComponentImpl).inject(this) + } + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { return AlertDialog.Builder(requireContext(), R.style.AlertDialogTheme) .setMessage(R.string.log_out_dialog_message) diff --git a/app/src/main/java/org/oppia/android/app/administratorcontrols/appversion/AppVersionViewModel.kt b/app/src/main/java/org/oppia/android/app/administratorcontrols/appversion/AppVersionViewModel.kt index 44bcba6f634..2f457119efb 100644 --- a/app/src/main/java/org/oppia/android/app/administratorcontrols/appversion/AppVersionViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/administratorcontrols/appversion/AppVersionViewModel.kt @@ -1,33 +1,33 @@ package org.oppia.android.app.administratorcontrols.appversion import android.content.Context -import androidx.databinding.ObservableField import androidx.lifecycle.ViewModel +import org.oppia.android.R import org.oppia.android.app.fragment.FragmentScope import org.oppia.android.app.utility.getLastUpdateTime import org.oppia.android.app.utility.getVersionName import org.oppia.android.app.viewmodel.ObservableViewModel -import org.oppia.android.util.system.OppiaDateTimeFormatter -import java.util.Locale import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler /** [ViewModel] for [AppVersionFragment]*/ @FragmentScope class AppVersionViewModel @Inject constructor( - private val oppiaDateTimeFormatter: OppiaDateTimeFormatter, + private val resourceHandler: AppLanguageResourceHandler, context: Context ) : ObservableViewModel() { - val versionName: String = context.getVersionName() - + private val versionName: String = context.getVersionName() private val lastUpdateDateTime = context.getLastUpdateTime() - val lastUpdateDate = ObservableField(getDateTime(lastUpdateDateTime)) - private fun getDateTime(lastUpdateTime: Long): String? { - return oppiaDateTimeFormatter.formatDateFromDateString( - OppiaDateTimeFormatter.DD_MMM_YYYY, - lastUpdateTime, - Locale.US + fun computeVersionNameText(): String = + resourceHandler.getStringInLocale(R.string.app_version_name, versionName) + + fun computeLastUpdatedDateText(): String = + resourceHandler.getStringInLocale( + R.string.app_last_update_date, getDateTime(lastUpdateDateTime) ) - } + + private fun getDateTime(lastUpdateTime: Long): String = + resourceHandler.computeDateString(lastUpdateTime) } 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 783b778251c..517775bf886 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 @@ -51,6 +51,7 @@ import org.oppia.android.util.system.OppiaClockModule import org.oppia.android.util.threading.DispatcherModule import javax.inject.Provider import javax.inject.Singleton +import org.oppia.android.util.locale.MachineLocaleModule /** * Root Dagger component for the application. All application-scoped modules should be included in @@ -87,7 +88,7 @@ import javax.inject.Singleton PlatformParameterModule::class, ExplorationStorageModule::class, DeveloperOptionsStarterModule::class, DeveloperOptionsModule::class, PlatformParameterSyncUpWorkerModule::class, NetworkConnectionUtilDebugModule::class, - NetworkConfigProdModule::class, + NetworkConfigProdModule::class, MachineLocaleModule::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/main/java/org/oppia/android/app/application/ApplicationInjector.kt b/app/src/main/java/org/oppia/android/app/application/ApplicationInjector.kt index 67e46e2f3a5..0229b81d29f 100644 --- a/app/src/main/java/org/oppia/android/app/application/ApplicationInjector.kt +++ b/app/src/main/java/org/oppia/android/app/application/ApplicationInjector.kt @@ -2,6 +2,8 @@ package org.oppia.android.app.application import org.oppia.android.app.translation.AppLanguageApplicationInjector import org.oppia.android.util.data.DataProvidersInjector +import org.oppia.android.util.system.OppiaClockInjector /** Injector for application-level dependencies that can't be directly injected where needed. */ -interface ApplicationInjector : DataProvidersInjector, AppLanguageApplicationInjector +interface ApplicationInjector : + DataProvidersInjector, AppLanguageApplicationInjector, OppiaClockInjector diff --git a/app/src/main/java/org/oppia/android/app/application/ApplicationInjectorProvider.kt b/app/src/main/java/org/oppia/android/app/application/ApplicationInjectorProvider.kt index 99832db2056..47bb0da6dd3 100644 --- a/app/src/main/java/org/oppia/android/app/application/ApplicationInjectorProvider.kt +++ b/app/src/main/java/org/oppia/android/app/application/ApplicationInjectorProvider.kt @@ -4,14 +4,18 @@ import org.oppia.android.app.translation.AppLanguageApplicationInjector import org.oppia.android.app.translation.AppLanguageApplicationInjectorProvider import org.oppia.android.util.data.DataProvidersInjector import org.oppia.android.util.data.DataProvidersInjectorProvider +import org.oppia.android.util.system.OppiaClockInjector +import org.oppia.android.util.system.OppiaClockInjectorProvider /** Provider for [ApplicationInjector]. The application context will implement this interface. */ interface ApplicationInjectorProvider : DataProvidersInjectorProvider, - AppLanguageApplicationInjectorProvider { + AppLanguageApplicationInjectorProvider, OppiaClockInjectorProvider { fun getApplicationInjector(): ApplicationInjector override fun getDataProvidersInjector(): DataProvidersInjector = getApplicationInjector() override fun getAppLanguageApplicationInjector(): AppLanguageApplicationInjector = getApplicationInjector() + + override fun getOppiaClockInjector(): OppiaClockInjector = getApplicationInjector() } diff --git a/app/src/main/java/org/oppia/android/app/customview/LessonThumbnailImageView.kt b/app/src/main/java/org/oppia/android/app/customview/LessonThumbnailImageView.kt index 0f86008ea52..13b0c10c979 100644 --- a/app/src/main/java/org/oppia/android/app/customview/LessonThumbnailImageView.kt +++ b/app/src/main/java/org/oppia/android/app/customview/LessonThumbnailImageView.kt @@ -18,6 +18,7 @@ import org.oppia.android.util.parser.image.ImageViewTarget import org.oppia.android.util.parser.image.ThumbnailDownloadUrlTemplate import javax.inject.Inject import org.oppia.android.app.view.ViewComponentImpl +import org.oppia.android.util.locale.OppiaLocale /** A custom [AppCompatImageView] used to show lesson thumbnails. */ class LessonThumbnailImageView @JvmOverloads constructor( @@ -52,6 +53,9 @@ class LessonThumbnailImageView @JvmOverloads constructor( @Inject lateinit var oppiaLogger: OppiaLogger + @Inject + lateinit var machineLocale: OppiaLocale.MachineLocale + fun setEntityId(entityId: String) { this.entityId = entityId checkIfLoadingIsPossible() @@ -108,14 +112,15 @@ class LessonThumbnailImageView @JvmOverloads constructor( /** Loads an image using Glide from [filename]. */ private fun loadImage(filename: String, transformations: List) { - val imageName = String.format( - thumbnailDownloadUrlTemplate, - entityType, - entityId, - filename - ) + val imageName = machineLocale.run { + thumbnailDownloadUrlTemplate.formatForMachines( + entityType, + entityId, + filename + ) + } val imageUrl = "$gcsPrefix/$resourceBucketName/$imageName" - if (imageUrl.endsWith("svg", ignoreCase = true)) { + if (machineLocale.run { imageUrl.endsWithIgnoreCase("svg") }) { imageLoader.loadBlockSvg(imageUrl, ImageViewTarget(this), transformations) } else { imageLoader.loadBitmap(imageUrl, ImageViewTarget(this), transformations) diff --git a/app/src/main/java/org/oppia/android/app/databinding/TextViewBindingAdapters.java b/app/src/main/java/org/oppia/android/app/databinding/TextViewBindingAdapters.java index 23f4a1cc206..0452b68d83f 100644 --- a/app/src/main/java/org/oppia/android/app/databinding/TextViewBindingAdapters.java +++ b/app/src/main/java/org/oppia/android/app/databinding/TextViewBindingAdapters.java @@ -1,15 +1,19 @@ package org.oppia.android.app.databinding; +import android.app.Activity; import android.content.Context; -import android.content.res.Resources; +import android.content.ContextWrapper; +import android.view.View; import android.widget.TextView; import androidx.annotation.NonNull; import androidx.annotation.PluralsRes; import androidx.databinding.BindingAdapter; -import java.util.Locale; import java.util.concurrent.TimeUnit; import org.oppia.android.R; -import org.oppia.android.util.system.OppiaDateTimeFormatter; +import org.oppia.android.app.translation.AppLanguageActivityInjectorProvider; +import org.oppia.android.app.translation.AppLanguageResourceHandler; +import org.oppia.android.util.system.OppiaClock; +import org.oppia.android.util.system.OppiaClockInjectorProvider; /** Holds all custom binding adapters that bind to [TextView]. */ public final class TextViewBindingAdapters { @@ -17,13 +21,9 @@ public final class TextViewBindingAdapters { /** Binds date text with relative time. */ @BindingAdapter("profile:created") public static void setProfileDataText(@NonNull TextView textView, long timestamp) { - OppiaDateTimeFormatter oppiaDateTimeFormatter = new OppiaDateTimeFormatter(); - String time = oppiaDateTimeFormatter.formatDateFromDateString( - OppiaDateTimeFormatter.DD_MMM_YYYY, - timestamp, - Locale.getDefault() - ); - textView.setText(textView.getContext().getString( + AppLanguageResourceHandler resourceHandler = getResourceHandler(textView); + String time = resourceHandler.computeDateString(timestamp); + textView.setText(resourceHandler.getStringInLocale( R.string.profile_edit_created, time )); @@ -32,12 +32,10 @@ public static void setProfileDataText(@NonNull TextView textView, long timestamp /** Binds last used with relative timestamp. */ @BindingAdapter("profile:lastVisited") public static void setProfileLastVisitedText(@NonNull TextView textView, long timestamp) { - String profileLastUsed = textView.getContext().getString(R.string.profile_last_used); - String timeAgoTimeStamp = getTimeAgo( - timestamp, - textView.getContext() - ); - String profileLastVisited = textView.getContext().getString( + AppLanguageResourceHandler resourceHandler = getResourceHandler(textView); + String profileLastUsed = resourceHandler.getStringInLocale(R.string.profile_last_used); + String timeAgoTimeStamp = getTimeAgo(textView, timestamp); + String profileLastVisited = resourceHandler.getStringInLocale( R.string.profile_last_visited, profileLastUsed, timeAgoTimeStamp @@ -45,56 +43,91 @@ public static void setProfileLastVisitedText(@NonNull TextView textView, long ti textView.setText(profileLastVisited); } - private static String getTimeAgo(long lastVisitedTimeStamp, Context context) { - OppiaDateTimeFormatter oppiaDateTimeFormatter = new OppiaDateTimeFormatter(); - long timeStampMillis = - oppiaDateTimeFormatter.checkAndConvertTimestampToMilliseconds(lastVisitedTimeStamp); - long currentTimeMillis = oppiaDateTimeFormatter.currentDate().getTime(); + private static String getTimeAgo(View view, long lastVisitedTimestamp) { + long timeStampMillis = ensureTimestampIsInMilliseconds(lastVisitedTimestamp); + long currentTimeMillis = getOppiaClock(view).getCurrentTimeMs(); if (timeStampMillis > currentTimeMillis || timeStampMillis <= 0) { return ""; } - Resources res = context.getResources(); long timeDifferenceMillis = currentTimeMillis - timeStampMillis; + AppLanguageResourceHandler resourceHandler = getResourceHandler(view); if (timeDifferenceMillis < (int) TimeUnit.MINUTES.toMillis(1)) { - return context.getString(R.string.just_now); + return resourceHandler.getStringInLocale(R.string.just_now); } else if (timeDifferenceMillis < TimeUnit.MINUTES.toMillis(50)) { return getPluralString( - context, + resourceHandler, R.plurals.minutes, (int) TimeUnit.MILLISECONDS.toMinutes(timeDifferenceMillis) ); } else if (timeDifferenceMillis < TimeUnit.DAYS.toMillis(1)) { return getPluralString( - context, + resourceHandler, R.plurals.hours, (int) TimeUnit.MILLISECONDS.toHours(timeDifferenceMillis) ); } else if (timeDifferenceMillis < TimeUnit.DAYS.toMillis(2)) { - return context.getString(R.string.yesterday); + return resourceHandler.getStringInLocale(R.string.yesterday); } return getPluralString( - context, + resourceHandler, R.plurals.days, (int) TimeUnit.MILLISECONDS.toDays(timeDifferenceMillis) ); } private static String getPluralString( - @NonNull Context context, + AppLanguageResourceHandler resourceHandler, @PluralsRes int pluralsResId, int count ) { - Resources resources = context.getResources(); - return context.getString( + // TODO: file an issue to combine these strings together. + return resourceHandler.getStringInLocale( R.string.time_ago, - resources.getQuantityString( - pluralsResId, - count, - count - ) + resourceHandler.getQuantityStringInLocale(pluralsResId, count, count) ); } + + private static long ensureTimestampIsInMilliseconds(long lastVisitedTimestamp) { + // TODO: file issue to investigate & remove this method. + if (lastVisitedTimestamp < 1000000000000L) { + // If timestamp is given in seconds, convert that to milliseconds. + return TimeUnit.SECONDS.toMillis(lastVisitedTimestamp); + } + return lastVisitedTimestamp; + } + + private static AppLanguageResourceHandler getResourceHandler(View view) { + AppLanguageActivityInjectorProvider provider = + (AppLanguageActivityInjectorProvider) getAttachedActivity(view); + return provider.getAppLanguageActivityInjector().getAppLanguageResourceHandler(); + } + + private static Activity getAttachedActivity(View view) { + Context context = view.getContext(); + while (context != null && !(context instanceof Activity)) { + if (!(context instanceof ContextWrapper)) { + throw new IllegalStateException( + "Encountered context in view (" + view + ") that doesn't wrap a parent context: " + + context + ); + } + context = ((ContextWrapper) context).getBaseContext(); + } + if (context == null) { + throw new IllegalStateException("Failed to find base Activity for view: " + view); + } + return (Activity) context; + } + + private static OppiaClock getOppiaClock(View view) { + OppiaClockInjectorProvider provider = (OppiaClockInjectorProvider) getApplication(view); + return provider.getOppiaClockInjector().getOppiaClock(); + } + + private static Context getApplication(View view) { + return view.getContext().getApplicationContext(); + } } diff --git a/app/src/main/java/org/oppia/android/app/devoptions/DeveloperOptionsActivity.kt b/app/src/main/java/org/oppia/android/app/devoptions/DeveloperOptionsActivity.kt index ac7ce90cf55..eaf4f4f2002 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/DeveloperOptionsActivity.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/DeveloperOptionsActivity.kt @@ -13,6 +13,7 @@ import org.oppia.android.app.devoptions.vieweventlogs.ViewEventLogsActivity import org.oppia.android.app.drawer.NAVIGATION_PROFILE_ID_ARGUMENT_KEY import javax.inject.Inject import org.oppia.android.app.activity.ActivityComponentImpl +import org.oppia.android.app.translation.AppLanguageResourceHandler /** Activity for Developer Options. */ class DeveloperOptionsActivity : @@ -27,6 +28,9 @@ class DeveloperOptionsActivity : @Inject lateinit var developerOptionsActivityPresenter: DeveloperOptionsActivityPresenter + @Inject + lateinit var resourceHandler: AppLanguageResourceHandler + private var internalProfileId = -1 override fun onCreate(savedInstanceState: Bundle?) { @@ -34,7 +38,7 @@ class DeveloperOptionsActivity : (activityComponent as ActivityComponentImpl).inject(this) internalProfileId = intent.getIntExtra(NAVIGATION_PROFILE_ID_ARGUMENT_KEY, -1) developerOptionsActivityPresenter.handleOnCreate() - title = getString(R.string.developer_options_activity_title) + title = resourceHandler.getStringInLocale(R.string.developer_options_activity_title) } override fun routeToMarkChaptersCompleted() { diff --git a/app/src/main/java/org/oppia/android/app/devoptions/forcenetworktype/ForceNetworkTypeActivity.kt b/app/src/main/java/org/oppia/android/app/devoptions/forcenetworktype/ForceNetworkTypeActivity.kt index 3ba8e41ae82..bd7a2de421b 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/forcenetworktype/ForceNetworkTypeActivity.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/forcenetworktype/ForceNetworkTypeActivity.kt @@ -7,17 +7,21 @@ import org.oppia.android.R import org.oppia.android.app.activity.InjectableAppCompatActivity import javax.inject.Inject import org.oppia.android.app.activity.ActivityComponentImpl +import org.oppia.android.app.translation.AppLanguageResourceHandler /** Activity for forcing the network mode for the app. */ class ForceNetworkTypeActivity : InjectableAppCompatActivity() { @Inject lateinit var forceNetworkTypeActivityPresenter: ForceNetworkTypeActivityPresenter + @Inject + lateinit var resourceHandler: AppLanguageResourceHandler + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) (activityComponent as ActivityComponentImpl).inject(this) forceNetworkTypeActivityPresenter.handleOnCreate() - title = getString(R.string.force_network_type_activity_title) + title = resourceHandler.getStringInLocale(R.string.force_network_type_activity_title) } companion object { diff --git a/app/src/main/java/org/oppia/android/app/devoptions/forcenetworktype/ForceNetworkTypeViewModel.kt b/app/src/main/java/org/oppia/android/app/devoptions/forcenetworktype/ForceNetworkTypeViewModel.kt index 1e7d01c0c1b..4bad8396bec 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/forcenetworktype/ForceNetworkTypeViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/forcenetworktype/ForceNetworkTypeViewModel.kt @@ -7,6 +7,7 @@ import org.oppia.android.app.viewmodel.ObservableViewModel import org.oppia.android.util.networking.NetworkConnectionDebugUtil import org.oppia.android.util.networking.NetworkConnectionUtil import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler /** * [ViewModel] for [ForceNetworkTypeFragment]. It populates the recycler view with a list of @@ -14,7 +15,7 @@ import javax.inject.Inject */ @FragmentScope class ForceNetworkTypeViewModel @Inject constructor( - private val activity: AppCompatActivity + private val resourceHandler: AppLanguageResourceHandler ) : ObservableViewModel() { /** @@ -29,19 +30,19 @@ class ForceNetworkTypeViewModel @Inject constructor( return listOf( NetworkTypeItemViewModel( NetworkConnectionDebugUtil.DebugConnectionStatus.DEFAULT, - activity.getString(R.string.force_network_type_default_network) + resourceHandler.getStringInLocale(R.string.force_network_type_default_network) ), NetworkTypeItemViewModel( NetworkConnectionUtil.ProdConnectionStatus.LOCAL, - activity.getString(R.string.force_network_type_wifi_network) + resourceHandler.getStringInLocale(R.string.force_network_type_wifi_network) ), NetworkTypeItemViewModel( NetworkConnectionUtil.ProdConnectionStatus.CELLULAR, - activity.getString(R.string.force_network_type_cellular_network) + resourceHandler.getStringInLocale(R.string.force_network_type_cellular_network) ), NetworkTypeItemViewModel( NetworkConnectionUtil.ProdConnectionStatus.NONE, - activity.getString(R.string.force_network_type_no_network) + resourceHandler.getStringInLocale(R.string.force_network_type_no_network) ) ) } diff --git a/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedActivity.kt b/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedActivity.kt index 43460a6cd7f..96028c0bb7d 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedActivity.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/markchapterscompleted/MarkChaptersCompletedActivity.kt @@ -8,12 +8,17 @@ import org.oppia.android.R import org.oppia.android.app.activity.InjectableAppCompatActivity import javax.inject.Inject import org.oppia.android.app.activity.ActivityComponentImpl +import org.oppia.android.app.translation.AppLanguageResourceHandler /** Activity for Mark Chapters Completed. */ class MarkChaptersCompletedActivity : InjectableAppCompatActivity() { @Inject lateinit var markChaptersCompletedActivityPresenter: MarkChaptersCompletedActivityPresenter + + @Inject + lateinit var resourceHandler: AppLanguageResourceHandler + private var internalProfileId = -1 override fun onCreate(savedInstanceState: Bundle?) { @@ -21,7 +26,7 @@ class MarkChaptersCompletedActivity : InjectableAppCompatActivity() { (activityComponent as ActivityComponentImpl).inject(this) internalProfileId = intent.getIntExtra(MARK_CHAPTERS_COMPLETED_ACTIVITY_PROFILE_ID_KEY, -1) markChaptersCompletedActivityPresenter.handleOnCreate(internalProfileId) - title = getString(R.string.mark_chapters_completed_activity_title) + title = resourceHandler.getStringInLocale(R.string.mark_chapters_completed_activity_title) } override fun onOptionsItemSelected(item: MenuItem): Boolean { diff --git a/app/src/main/java/org/oppia/android/app/devoptions/markstoriescompleted/MarkStoriesCompletedActivity.kt b/app/src/main/java/org/oppia/android/app/devoptions/markstoriescompleted/MarkStoriesCompletedActivity.kt index 10c24979c80..c63f036078f 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/markstoriescompleted/MarkStoriesCompletedActivity.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/markstoriescompleted/MarkStoriesCompletedActivity.kt @@ -8,12 +8,17 @@ import org.oppia.android.R import org.oppia.android.app.activity.InjectableAppCompatActivity import javax.inject.Inject import org.oppia.android.app.activity.ActivityComponentImpl +import org.oppia.android.app.translation.AppLanguageResourceHandler /** Activity for Mark Stories Completed. */ class MarkStoriesCompletedActivity : InjectableAppCompatActivity() { @Inject lateinit var markStoriesCompletedActivityPresenter: MarkStoriesCompletedActivityPresenter + + @Inject + lateinit var resourceHandler: AppLanguageResourceHandler + private var internalProfileId = -1 override fun onCreate(savedInstanceState: Bundle?) { @@ -21,7 +26,7 @@ class MarkStoriesCompletedActivity : InjectableAppCompatActivity() { (activityComponent as ActivityComponentImpl).inject(this) internalProfileId = intent.getIntExtra(MARK_STORIES_COMPLETED_ACTIVITY_PROFILE_ID_KEY, -1) markStoriesCompletedActivityPresenter.handleOnCreate(internalProfileId) - title = getString(R.string.mark_stories_completed_activity_title) + title = resourceHandler.getStringInLocale(R.string.mark_stories_completed_activity_title) } override fun onOptionsItemSelected(item: MenuItem): Boolean { diff --git a/app/src/main/java/org/oppia/android/app/devoptions/marktopicscompleted/MarkTopicsCompletedActivity.kt b/app/src/main/java/org/oppia/android/app/devoptions/marktopicscompleted/MarkTopicsCompletedActivity.kt index 5a83495f611..84ca1f4f786 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/marktopicscompleted/MarkTopicsCompletedActivity.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/marktopicscompleted/MarkTopicsCompletedActivity.kt @@ -8,12 +8,17 @@ import org.oppia.android.R import org.oppia.android.app.activity.InjectableAppCompatActivity import javax.inject.Inject import org.oppia.android.app.activity.ActivityComponentImpl +import org.oppia.android.app.translation.AppLanguageResourceHandler /** Activity for Mark Topics Completed. */ class MarkTopicsCompletedActivity : InjectableAppCompatActivity() { @Inject lateinit var markTopicsCompletedActivityPresenter: MarkTopicsCompletedActivityPresenter + + @Inject + lateinit var resourceHandler: AppLanguageResourceHandler + private var internalProfileId = -1 override fun onCreate(savedInstanceState: Bundle?) { @@ -21,7 +26,7 @@ class MarkTopicsCompletedActivity : InjectableAppCompatActivity() { (activityComponent as ActivityComponentImpl).inject(this) internalProfileId = intent.getIntExtra(MARK_TOPICS_COMPLETED_ACTIVITY_PROFILE_ID_KEY, -1) markTopicsCompletedActivityPresenter.handleOnCreate(internalProfileId) - title = getString(R.string.mark_topics_completed_activity_title) + title = resourceHandler.getStringInLocale(R.string.mark_topics_completed_activity_title) } override fun onOptionsItemSelected(item: MenuItem): Boolean { diff --git a/app/src/main/java/org/oppia/android/app/devoptions/vieweventlogs/EventLogItemViewModel.kt b/app/src/main/java/org/oppia/android/app/devoptions/vieweventlogs/EventLogItemViewModel.kt index a4b53e3855c..be9f961db3b 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/vieweventlogs/EventLogItemViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/vieweventlogs/EventLogItemViewModel.kt @@ -2,33 +2,39 @@ package org.oppia.android.app.devoptions.vieweventlogs import org.oppia.android.app.model.EventLog import org.oppia.android.app.viewmodel.ObservableViewModel -import org.oppia.android.util.system.OppiaDateTimeFormatter import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler +import org.oppia.android.util.locale.OppiaLocale /** [ViewModel] for displaying a event log. */ -class EventLogItemViewModel @Inject constructor( +class EventLogItemViewModel( val eventLog: EventLog, - private val oppiaDateTimeFormatter: OppiaDateTimeFormatter + private val machineLocale: OppiaLocale.MachineLocale, + private val resourceHandler: AppLanguageResourceHandler ) : ObservableViewModel() { /** Returns the event log timestamp in a human readable format. */ - fun processDateAndTime(): String { - return oppiaDateTimeFormatter.formatDateFromDateString( - OppiaDateTimeFormatter.DD_MMM_hh_mm_aa, - eventLog.timestamp - ) - } + fun processDateAndTime(): String = resourceHandler.computeDateTimeString(eventLog.timestamp) /** Returns the event log priority in a human readable format. */ - fun formatPriorityString(): String? = eventLog.priority.name.toLowerCase().capitalize() + fun formatPriorityString(): String = machineLocale.run { + // Use the machine locale for capitalization/case changes since this string is only used by + // developers. + eventLog.priority.name.toMachineLowerCase().capitalizeForMachines() + } /** Returns the event log context in a human readable format. */ - fun formatContextString(): String? = + fun formatContextString(): String = eventLog.context.activityContextCase.name.capitalizeWords().substringBeforeLast(" ") /** Returns the event log action name in a human readable format. */ fun formatActionNameString(): String = eventLog.actionName.name.capitalizeWords() - private fun String.capitalizeWords(): String = - toLowerCase().split("_").joinToString(" ") { it.capitalize() } + private fun String.capitalizeWords(): String = machineLocale.run { + // Use the machine locale for capitalization/case changes since this string is only used by + // developers. + this@capitalizeWords.toMachineLowerCase().split("_").joinToString(" ") { + it.capitalizeForMachines() + } + } } diff --git a/app/src/main/java/org/oppia/android/app/devoptions/vieweventlogs/ViewEventLogsActivity.kt b/app/src/main/java/org/oppia/android/app/devoptions/vieweventlogs/ViewEventLogsActivity.kt index 4784e487005..77c2442db76 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/vieweventlogs/ViewEventLogsActivity.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/vieweventlogs/ViewEventLogsActivity.kt @@ -7,17 +7,21 @@ import org.oppia.android.R import org.oppia.android.app.activity.InjectableAppCompatActivity import javax.inject.Inject import org.oppia.android.app.activity.ActivityComponentImpl +import org.oppia.android.app.translation.AppLanguageResourceHandler /** Activity for View Event Logs. */ class ViewEventLogsActivity : InjectableAppCompatActivity() { @Inject lateinit var viewEventLogsActivityPresenter: ViewEventLogsActivityPresenter + @Inject + lateinit var resourceHandler: AppLanguageResourceHandler + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) (activityComponent as ActivityComponentImpl).inject(this) viewEventLogsActivityPresenter.handleOnCreate() - title = getString(R.string.view_event_logs_activity_title) + title = resourceHandler.getStringInLocale(R.string.view_event_logs_activity_title) } companion object { diff --git a/app/src/main/java/org/oppia/android/app/devoptions/vieweventlogs/ViewEventLogsViewModel.kt b/app/src/main/java/org/oppia/android/app/devoptions/vieweventlogs/ViewEventLogsViewModel.kt index 91d0fe6d145..f7e20461936 100644 --- a/app/src/main/java/org/oppia/android/app/devoptions/vieweventlogs/ViewEventLogsViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/devoptions/vieweventlogs/ViewEventLogsViewModel.kt @@ -3,8 +3,9 @@ package org.oppia.android.app.devoptions.vieweventlogs import org.oppia.android.app.fragment.FragmentScope import org.oppia.android.app.viewmodel.ObservableViewModel import org.oppia.android.util.logging.firebase.DebugEventLogger -import org.oppia.android.util.system.OppiaDateTimeFormatter import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler +import org.oppia.android.util.locale.OppiaLocale /** * [ViewModel] for [ViewEventLogsFragment]. It populates the recyclerview with a list of @@ -13,7 +14,8 @@ import javax.inject.Inject @FragmentScope class ViewEventLogsViewModel @Inject constructor( debugEventLogger: DebugEventLogger, - private val oppiaDateTimeFormatter: OppiaDateTimeFormatter + private val machineLocale: OppiaLocale.MachineLocale, + private val resourceHandler: AppLanguageResourceHandler ) : ObservableViewModel() { private val eventList = debugEventLogger.getEventList() @@ -28,7 +30,7 @@ class ViewEventLogsViewModel @Inject constructor( private fun processEventLogsList(): List { return eventList.map { - EventLogItemViewModel(it, oppiaDateTimeFormatter) + EventLogItemViewModel(it, machineLocale, resourceHandler) }.reversed() } } diff --git a/app/src/main/java/org/oppia/android/app/drawer/ExitProfileDialogFragment.kt b/app/src/main/java/org/oppia/android/app/drawer/ExitProfileDialogFragment.kt index eabce32cd94..3d6b176b989 100644 --- a/app/src/main/java/org/oppia/android/app/drawer/ExitProfileDialogFragment.kt +++ b/app/src/main/java/org/oppia/android/app/drawer/ExitProfileDialogFragment.kt @@ -9,6 +9,8 @@ import androidx.appcompat.app.AlertDialog import androidx.appcompat.view.ContextThemeWrapper import androidx.fragment.app.DialogFragment import org.oppia.android.R +import org.oppia.android.app.fragment.FragmentComponentImpl +import org.oppia.android.app.fragment.InjectableDialogFragment import org.oppia.android.app.model.ExitProfileDialogArguments import org.oppia.android.app.model.HighlightItem import org.oppia.android.app.profile.ProfileChooserActivity @@ -16,7 +18,7 @@ import org.oppia.android.util.extensions.getProto import org.oppia.android.util.extensions.putProto /** [DialogFragment] that gives option to either cancel or exit current profile. */ -class ExitProfileDialogFragment : DialogFragment() { +class ExitProfileDialogFragment : InjectableDialogFragment() { companion object { // TODO(#1655): Re-restrict access to fields in tests post-Gradle. @@ -40,6 +42,11 @@ class ExitProfileDialogFragment : DialogFragment() { lateinit var exitProfileDialogInterface: ExitProfileDialogInterface + override fun onAttach(context: Context) { + super.onAttach(context) + (fragmentComponent as FragmentComponentImpl).inject(this) + } + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val args = checkNotNull(arguments) { "Expected arguments to be pass to ExitProfileDialogFragment" } diff --git a/app/src/main/java/org/oppia/android/app/drawer/NavigationDrawerFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/drawer/NavigationDrawerFragmentPresenter.kt index 0ca42200155..2a128a04e39 100644 --- a/app/src/main/java/org/oppia/android/app/drawer/NavigationDrawerFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/drawer/NavigationDrawerFragmentPresenter.kt @@ -187,9 +187,8 @@ class NavigationDrawerFragmentPresenter @Inject constructor( private fun subscribeToCompletedStoryListLiveData() { getCompletedStoryListCount().observe( - fragment, - Observer { - getHeaderViewModel().completedStoryCount.set(it.completedStoryCount) + fragment, { + getHeaderViewModel().setCompletedStoryProgress(it.completedStoryCount) } ) } @@ -216,9 +215,8 @@ class NavigationDrawerFragmentPresenter @Inject constructor( private fun subscribeToOngoingTopicListLiveData() { getOngoingTopicListCount().observe( - fragment, - Observer { - getHeaderViewModel().ongoingTopicCount.set(it.topicCount) + fragment, { + getHeaderViewModel().setOngoingTopicProgress(it.topicCount) } ) } diff --git a/app/src/main/java/org/oppia/android/app/drawer/NavigationDrawerHeaderViewModel.kt b/app/src/main/java/org/oppia/android/app/drawer/NavigationDrawerHeaderViewModel.kt index 071af222366..2dc98192e10 100644 --- a/app/src/main/java/org/oppia/android/app/drawer/NavigationDrawerHeaderViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/drawer/NavigationDrawerHeaderViewModel.kt @@ -3,21 +3,52 @@ package org.oppia.android.app.drawer import androidx.databinding.ObservableField import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModel +import org.oppia.android.R import org.oppia.android.app.model.Profile import org.oppia.android.app.viewmodel.ObservableViewModel import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler + +private const val DEFAULT_ONGOING_TOPIC_COUNT = 0 +private const val DEFAULT_COMPLETED_STORY_COUNT = 0 /** [ViewModel] for displaying User profile details in navigation header. */ class NavigationDrawerHeaderViewModel @Inject constructor( - fragment: Fragment + fragment: Fragment, + private val resourceHandler: AppLanguageResourceHandler ) : ObservableViewModel() { private var routeToProfileProgressListener = fragment as RouteToProfileProgressListener - val profile = ObservableField(Profile.getDefaultInstance()) - val ongoingTopicCount = ObservableField(0) - val completedStoryCount = ObservableField(0) + val profile = ObservableField(Profile.getDefaultInstance()) + private var ongoingTopicCount = DEFAULT_ONGOING_TOPIC_COUNT + private var completedStoryCount = DEFAULT_COMPLETED_STORY_COUNT + val profileProgressText: ObservableField = ObservableField(computeProfileProgressText()) fun onHeaderClicked() { routeToProfileProgressListener.routeToProfileProgress(profile.get()!!.id.internalId) } + + fun setOngoingTopicProgress(ongoingTopicCount: Int) { + this.ongoingTopicCount = ongoingTopicCount + profileProgressText.set(computeProfileProgressText()) + } + + fun setCompletedStoryProgress(completedStoryCount: Int) { + this.completedStoryCount = completedStoryCount + profileProgressText.set(computeProfileProgressText()) + } + + private fun computeProfileProgressText(): String { + // TODO: file an issue to fix this (should be a single string so that translators can properly configure ordering). + val completedStoryCountText = + resourceHandler.getQuantityStringInLocale( + R.plurals.completed_story_count, completedStoryCount, completedStoryCount + ) + val ongoingTopicCountText = + resourceHandler.getQuantityStringInLocale( + R.plurals.ongoing_topic_count, ongoingTopicCount, ongoingTopicCount + ) + val barSeparator = resourceHandler.getStringInLocale(R.string.bar_separator) + return "$completedStoryCountText$barSeparator$ongoingTopicCountText" + } } diff --git a/app/src/main/java/org/oppia/android/app/fragment/FragmentComponentImpl.kt b/app/src/main/java/org/oppia/android/app/fragment/FragmentComponentImpl.kt index 4bcd2dbe8ec..2e8f0ac745e 100644 --- a/app/src/main/java/org/oppia/android/app/fragment/FragmentComponentImpl.kt +++ b/app/src/main/java/org/oppia/android/app/fragment/FragmentComponentImpl.kt @@ -4,6 +4,7 @@ import androidx.fragment.app.Fragment import dagger.BindsInstance import dagger.Subcomponent import org.oppia.android.app.administratorcontrols.AdministratorControlsFragment +import org.oppia.android.app.administratorcontrols.LogoutDialogFragment import org.oppia.android.app.administratorcontrols.appversion.AppVersionFragment import org.oppia.android.app.completedstorylist.CompletedStoryListFragment import org.oppia.android.app.deprecation.AutomaticAppDeprecationNoticeDialogFragment @@ -13,6 +14,7 @@ import org.oppia.android.app.devoptions.markchapterscompleted.MarkChaptersComple import org.oppia.android.app.devoptions.markstoriescompleted.MarkStoriesCompletedFragment import org.oppia.android.app.devoptions.marktopicscompleted.MarkTopicsCompletedFragment import org.oppia.android.app.devoptions.vieweventlogs.ViewEventLogsFragment +import org.oppia.android.app.drawer.ExitProfileDialogFragment import org.oppia.android.app.drawer.NavigationDrawerFragment import org.oppia.android.app.help.HelpFragment import org.oppia.android.app.help.faq.FAQListFragment @@ -20,6 +22,7 @@ import org.oppia.android.app.help.thirdparty.LicenseListFragment import org.oppia.android.app.help.thirdparty.LicenseTextViewerFragment import org.oppia.android.app.help.thirdparty.ThirdPartyDependencyListFragment import org.oppia.android.app.hintsandsolution.HintsAndSolutionDialogFragment +import org.oppia.android.app.hintsandsolution.RevealSolutionDialogFragment import org.oppia.android.app.home.HomeFragment import org.oppia.android.app.home.recentlyplayed.RecentlyPlayedFragment import org.oppia.android.app.mydownloads.DownloadsTabFragment @@ -32,16 +35,23 @@ import org.oppia.android.app.options.AudioLanguageFragment import org.oppia.android.app.options.OptionsFragment import org.oppia.android.app.options.ReadingTextSizeFragment import org.oppia.android.app.player.audio.AudioFragment +import org.oppia.android.app.player.audio.CellularAudioDialogFragment +import org.oppia.android.app.player.audio.LanguageDialogFragment import org.oppia.android.app.player.exploration.ExplorationFragment import org.oppia.android.app.player.exploration.ExplorationManagerFragment import org.oppia.android.app.player.exploration.HintsAndSolutionExplorationManagerFragment import org.oppia.android.app.player.state.StateFragment import org.oppia.android.app.player.state.itemviewmodel.InteractionViewModelModule +import org.oppia.android.app.player.stopplaying.ProgressDatabaseFullDialogFragment +import org.oppia.android.app.player.stopplaying.StopExplorationDialogFragment +import org.oppia.android.app.player.stopplaying.UnsavedExplorationDialogFragment import org.oppia.android.app.profile.AdminSettingsDialogFragment import org.oppia.android.app.profile.ProfileChooserFragment import org.oppia.android.app.profile.ResetPinDialogFragment +import org.oppia.android.app.profileprogress.ProfilePictureEditDialogFragment import org.oppia.android.app.profileprogress.ProfileProgressFragment import org.oppia.android.app.resumelesson.ResumeLessonFragment +import org.oppia.android.app.settings.profile.ProfileEditDeletionDialogFragment import org.oppia.android.app.settings.profile.ProfileEditFragment import org.oppia.android.app.settings.profile.ProfileListFragment import org.oppia.android.app.shim.IntentFactoryShimModule @@ -75,7 +85,7 @@ import org.oppia.android.app.view.ViewComponentBuilderModule @FragmentScope interface FragmentComponentImpl: FragmentComponent, ViewComponentBuilderInjector { @Subcomponent.Builder - interface Builder: FragmentComponent.Builder { + interface Builder : FragmentComponent.Builder { @BindsInstance override fun setFragment(fragment: Fragment): Builder @@ -89,10 +99,12 @@ interface FragmentComponentImpl: FragmentComponent, ViewComponentBuilderInjector fun inject(audioFragment: AudioFragment) fun inject(audioLanguageFragment: AudioLanguageFragment) fun inject(autoAppDeprecationNoticeDialogFragment: AutomaticAppDeprecationNoticeDialogFragment) + fun inject(cellularAudioDialogFragment: CellularAudioDialogFragment) fun inject(completedStoryListFragment: CompletedStoryListFragment) fun inject(conceptCardFragment: ConceptCardFragment) fun inject(developerOptionsFragment: DeveloperOptionsFragment) fun inject(downloadsTabFragment: DownloadsTabFragment) + fun inject(exitProfileDialogFragment: ExitProfileDialogFragment) fun inject(explorationFragment: ExplorationFragment) fun inject(explorationManagerFragment: ExplorationManagerFragment) fun inject(faqListFragment: FAQListFragment) @@ -103,8 +115,10 @@ interface FragmentComponentImpl: FragmentComponent, ViewComponentBuilderInjector fun inject(hintsAndSolutionQuestionManagerFragment: HintsAndSolutionQuestionManagerFragment) fun inject(homeFragment: HomeFragment) fun inject(imageRegionSelectionTestFragment: ImageRegionSelectionTestFragment) + fun inject(languageDialogFragment: LanguageDialogFragment) fun inject(licenseListFragment: LicenseListFragment) fun inject(licenseTextViewerFragment: LicenseTextViewerFragment) + fun inject(logoutDialogFragment: LogoutDialogFragment) fun inject(markChapterCompletedFragment: MarkChaptersCompletedFragment) fun inject(markStoriesCompletedFragment: MarkStoriesCompletedFragment) fun inject(markTopicsCompletedFragment: MarkTopicsCompletedFragment) @@ -114,16 +128,21 @@ interface FragmentComponentImpl: FragmentComponent, ViewComponentBuilderInjector fun inject(ongoingTopicListFragment: OngoingTopicListFragment) fun inject(optionFragment: OptionsFragment) fun inject(profileChooserFragment: ProfileChooserFragment) + fun inject(profileEditDeletionDialogFragment: ProfileEditDeletionDialogFragment) fun inject(profileEditFragment: ProfileEditFragment) fun inject(profileListFragment: ProfileListFragment) + fun inject(profilePictureEditDialogFragment: ProfilePictureEditDialogFragment) fun inject(profileProgressFragment: ProfileProgressFragment) + fun inject(progressDatabaseFullDialogFragment: ProgressDatabaseFullDialogFragment) fun inject(questionPlayerFragment: QuestionPlayerFragment) fun inject(readingTextSizeFragment: ReadingTextSizeFragment) fun inject(recentlyPlayedFragment: RecentlyPlayedFragment) fun inject(resetPinDialogFragment: ResetPinDialogFragment) fun inject(resumeLessonFragment: ResumeLessonFragment) + fun inject(revealSolutionDialogFragment: RevealSolutionDialogFragment) fun inject(revisionCardFragment: RevisionCardFragment) fun inject(stateFragment: StateFragment) + fun inject(stopExplorationDialogFragment: StopExplorationDialogFragment) fun inject(storyFragment: StoryFragment) fun inject(thirdPartyDependencyListFragment: ThirdPartyDependencyListFragment) fun inject(topicFragment: TopicFragment) @@ -131,6 +150,7 @@ interface FragmentComponentImpl: FragmentComponent, ViewComponentBuilderInjector fun inject(topicLessonsFragment: TopicLessonsFragment) fun inject(topicPracticeFragment: TopicPracticeFragment) fun inject(topicReviewFragment: TopicRevisionFragment) + fun inject(unsavedExplorationDialogFragment: UnsavedExplorationDialogFragment) fun inject(updatesTabFragment: UpdatesTabFragment) fun inject(viewEventLogsFragment: ViewEventLogsFragment) fun inject(walkthroughFinalFragment: WalkthroughFinalFragment) diff --git a/app/src/main/java/org/oppia/android/app/help/HelpActivity.kt b/app/src/main/java/org/oppia/android/app/help/HelpActivity.kt index 02bd384446f..809bb496445 100644 --- a/app/src/main/java/org/oppia/android/app/help/HelpActivity.kt +++ b/app/src/main/java/org/oppia/android/app/help/HelpActivity.kt @@ -13,6 +13,8 @@ import org.oppia.android.app.help.thirdparty.ThirdPartyDependencyListActivity import javax.inject.Inject import kotlin.properties.Delegates import org.oppia.android.app.activity.ActivityComponentImpl +import org.oppia.android.app.translation.AppLanguageResourceHandler +import org.oppia.android.util.extensions.getStringFromBundle const val HELP_OPTIONS_TITLE_SAVED_KEY = "HelpActivity.help_options_title" const val SELECTED_FRAGMENT_SAVED_KEY = "HelpActivity.selected_fragment" @@ -38,6 +40,9 @@ class HelpActivity : @Inject lateinit var helpActivityPresenter: HelpActivityPresenter + @Inject + lateinit var resourceHandler: AppLanguageResourceHandler + private lateinit var selectedFragment: String private lateinit var selectedHelpOptionsTitle: String private var selectedDependencyIndex by Delegates.notNull() @@ -51,12 +56,12 @@ class HelpActivity : /* defaultValue= */ false ) selectedFragment = - savedInstanceState?.getString(SELECTED_FRAGMENT_SAVED_KEY) ?: FAQ_LIST_FRAGMENT_TAG + savedInstanceState?.getStringFromBundle(SELECTED_FRAGMENT_SAVED_KEY) ?: FAQ_LIST_FRAGMENT_TAG selectedDependencyIndex = savedInstanceState?.getInt(THIRD_PARTY_DEPENDENCY_INDEX_SAVED_KEY) ?: 0 selectedLicenseIndex = savedInstanceState?.getInt(LICENSE_INDEX_SAVED_KEY) ?: 0 - selectedHelpOptionsTitle = savedInstanceState?.getString(HELP_OPTIONS_TITLE_SAVED_KEY) - ?: getString(R.string.faq_activity_title) + selectedHelpOptionsTitle = savedInstanceState?.getStringFromBundle(HELP_OPTIONS_TITLE_SAVED_KEY) + ?: resourceHandler.getStringInLocale(R.string.faq_activity_title) helpActivityPresenter.handleOnCreate( selectedHelpOptionsTitle, isFromNavigationDrawer, @@ -64,7 +69,7 @@ class HelpActivity : selectedDependencyIndex, selectedLicenseIndex ) - title = getString(R.string.menu_help) + title = resourceHandler.getStringInLocale(R.string.menu_help) } companion object { diff --git a/app/src/main/java/org/oppia/android/app/help/HelpActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/help/HelpActivityPresenter.kt index 56d7e55c079..85a8a366446 100644 --- a/app/src/main/java/org/oppia/android/app/help/HelpActivityPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/help/HelpActivityPresenter.kt @@ -18,10 +18,14 @@ import org.oppia.android.app.help.thirdparty.LicenseTextViewerFragment import org.oppia.android.app.help.thirdparty.ThirdPartyDependencyListFragment import javax.inject.Inject import kotlin.properties.Delegates +import org.oppia.android.app.translation.AppLanguageResourceHandler /** The presenter for [HelpActivity]. */ @ActivityScope -class HelpActivityPresenter @Inject constructor(private val activity: AppCompatActivity) { +class HelpActivityPresenter @Inject constructor( + private val activity: AppCompatActivity, + private val resourceHandler: AppLanguageResourceHandler +) { private lateinit var navigationDrawerFragment: NavigationDrawerFragment private lateinit var toolbar: Toolbar @@ -195,7 +199,7 @@ class HelpActivityPresenter @Inject constructor(private val activity: AppCompatA } private fun selectFAQListFragment() { - setMultipaneContainerTitle(activity.getString(R.string.faq_activity_title)) + setMultipaneContainerTitle(resourceHandler.getStringInLocale(R.string.faq_activity_title)) setMultipaneBackButtonVisibility(View.GONE) selectedFragmentTag = FAQ_LIST_FRAGMENT_TAG selectedHelpOptionTitle = getMultipaneContainerTitle() @@ -203,7 +207,7 @@ class HelpActivityPresenter @Inject constructor(private val activity: AppCompatA private fun selectThirdPartyDependencyListFragment() { setMultipaneContainerTitle( - activity.getString(R.string.third_party_dependency_list_activity_title) + resourceHandler.getStringInLocale(R.string.third_party_dependency_list_activity_title) ) setMultipaneBackButtonVisibility(View.GONE) selectedFragmentTag = THIRD_PARTY_DEPENDENCY_LIST_FRAGMENT_TAG @@ -211,7 +215,9 @@ class HelpActivityPresenter @Inject constructor(private val activity: AppCompatA } private fun selectLicenseListFragment(dependencyIndex: Int) { - setMultipaneContainerTitle(activity.getString(R.string.license_list_activity_title)) + setMultipaneContainerTitle( + resourceHandler.getStringInLocale(R.string.license_list_activity_title) + ) setMultipaneBackButtonVisibility(View.VISIBLE) setHelpBackButtonContentDescription(LICENSE_LIST_FRAGMENT_TAG) selectedFragmentTag = LICENSE_LIST_FRAGMENT_TAG @@ -237,7 +243,7 @@ class HelpActivityPresenter @Inject constructor(private val activity: AppCompatA dependencyIndex, /* defValue= */ 0 ) - val licenseNamesArray = activity.resources.getStringArray(licenseNamesArrayId) + val licenseNamesArray = resourceHandler.getStringArrayInLocale(licenseNamesArrayId) thirdPartyDependencyLicenseNamesArray.recycle() return licenseNamesArray[licenseIndex] } @@ -251,21 +257,21 @@ class HelpActivityPresenter @Inject constructor(private val activity: AppCompatA private fun setHelpBackButtonContentDescription(fragmentTag: String) { when (fragmentTag) { LICENSE_LIST_FRAGMENT_TAG -> { - val thirdPartyDependenciesList = activity.getString( + val thirdPartyDependenciesList = resourceHandler.getStringInLocale( R.string.help_activity_third_party_dependencies_list ) activity.findViewById(R.id.help_multipane_options_back_button) - .contentDescription = activity.getString( + .contentDescription = resourceHandler.getStringInLocale( R.string.help_activity_back_arrow_description, thirdPartyDependenciesList ) } LICENSE_TEXT_FRAGMENT_TAG -> { - val copyrightLicensesList = activity.getString( + val copyrightLicensesList = resourceHandler.getStringInLocale( R.string.help_activity_copyright_licenses_list ) activity.findViewById(R.id.help_multipane_options_back_button) - .contentDescription = activity.getString( + .contentDescription = resourceHandler.getStringInLocale( R.string.help_activity_back_arrow_description, copyrightLicensesList ) diff --git a/app/src/main/java/org/oppia/android/app/help/HelpItemViewModel.kt b/app/src/main/java/org/oppia/android/app/help/HelpItemViewModel.kt index e999203b6ae..71b84a96068 100644 --- a/app/src/main/java/org/oppia/android/app/help/HelpItemViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/help/HelpItemViewModel.kt @@ -2,17 +2,19 @@ package org.oppia.android.app.help import androidx.appcompat.app.AppCompatActivity import org.oppia.android.R +import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.app.viewmodel.ObservableViewModel /** [ObservableViewModel] for the recycler view of HelpActivity. */ class HelpItemViewModel( val activity: AppCompatActivity, val title: String, - val isMultipane: Boolean + val isMultipane: Boolean, + private val resourceHandler: AppLanguageResourceHandler ) : ObservableViewModel() { fun onClick(title: String) { when (title) { - activity.getString(R.string.frequently_asked_questions_FAQ) -> { + resourceHandler.getStringInLocale(R.string.frequently_asked_questions_FAQ) -> { if (isMultipane) { val loadFaqListFragmentListener = activity as LoadFaqListFragmentListener loadFaqListFragmentListener.loadFaqListFragment() @@ -21,7 +23,7 @@ class HelpItemViewModel( routeToFAQListener.onRouteToFAQList() } } - activity.getString(R.string.third_party_dependency_list_activity_title) -> { + resourceHandler.getStringInLocale(R.string.third_party_dependency_list_activity_title) -> { if (isMultipane) { val loadThirdPartyDependencyListFragmentListener = activity as LoadThirdPartyDependencyListFragmentListener diff --git a/app/src/main/java/org/oppia/android/app/help/HelpListViewModel.kt b/app/src/main/java/org/oppia/android/app/help/HelpListViewModel.kt index 5e6561c8940..1b00c177b17 100644 --- a/app/src/main/java/org/oppia/android/app/help/HelpListViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/help/HelpListViewModel.kt @@ -3,10 +3,12 @@ package org.oppia.android.app.help import androidx.appcompat.app.AppCompatActivity import org.oppia.android.R import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler /** View model in [HelpFragment]. */ class HelpListViewModel @Inject constructor( - val activity: AppCompatActivity + val activity: AppCompatActivity, + private val resourceHandler: AppLanguageResourceHandler ) : HelpViewModel() { private val arrayList = ArrayList() @@ -20,12 +22,15 @@ class HelpListViewModel @Inject constructor( val helpItemViewModel: HelpItemViewModel when (item) { HelpItems.FAQ -> { - category = activity.getString(R.string.frequently_asked_questions_FAQ) - helpItemViewModel = HelpItemViewModel(activity, category, isMultipane.get()!!) + category = resourceHandler.getStringInLocale(R.string.frequently_asked_questions_FAQ) + helpItemViewModel = + HelpItemViewModel(activity, category, isMultipane.get()!!, resourceHandler) } HelpItems.THIRD_PARTY -> { - category = activity.getString(R.string.third_party_dependency_list_activity_title) - helpItemViewModel = HelpItemViewModel(activity, category, isMultipane.get()!!) + category = + resourceHandler.getStringInLocale(R.string.third_party_dependency_list_activity_title) + helpItemViewModel = + HelpItemViewModel(activity, category, isMultipane.get()!!, resourceHandler) } } arrayList.add(helpItemViewModel) diff --git a/app/src/main/java/org/oppia/android/app/help/faq/FAQListActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/help/faq/FAQListActivityPresenter.kt index 98e850e4f5e..720b56a0fa4 100644 --- a/app/src/main/java/org/oppia/android/app/help/faq/FAQListActivityPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/help/faq/FAQListActivityPresenter.kt @@ -7,11 +7,13 @@ import org.oppia.android.R import org.oppia.android.app.activity.ActivityScope import org.oppia.android.databinding.FaqListActivityBinding import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler /** The presenter for [FAQListActivity]. */ @ActivityScope class FAQListActivityPresenter @Inject constructor( - private val activity: AppCompatActivity + private val activity: AppCompatActivity, + private val resourceHandler: AppLanguageResourceHandler ) { private lateinit var faqListActivityToolbar: Toolbar @@ -24,7 +26,7 @@ class FAQListActivityPresenter @Inject constructor( faqListActivityToolbar = binding.faqListActivityToolbar activity.setSupportActionBar(faqListActivityToolbar) - activity.supportActionBar!!.title = activity.getString(R.string.FAQs) + activity.supportActionBar!!.title = resourceHandler.getStringInLocale(R.string.FAQs) activity.supportActionBar!!.setDisplayShowHomeEnabled(true) activity.supportActionBar!!.setDisplayHomeAsUpEnabled(true) diff --git a/app/src/main/java/org/oppia/android/app/help/faq/FAQListViewModel.kt b/app/src/main/java/org/oppia/android/app/help/faq/FAQListViewModel.kt index 49247adea02..f753d852260 100644 --- a/app/src/main/java/org/oppia/android/app/help/faq/FAQListViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/help/faq/FAQListViewModel.kt @@ -8,10 +8,12 @@ import org.oppia.android.app.help.faq.faqItemViewModel.FAQItemViewModel import org.oppia.android.app.viewmodel.ObservableViewModel import java.util.Locale import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler /** View model in [FAQListFragment]. */ class FAQListViewModel @Inject constructor( - val activity: AppCompatActivity + val activity: AppCompatActivity, + private val resourceHandler: AppLanguageResourceHandler ) : ObservableViewModel() { val faqItemList: List by lazy { computeFaqViewModelList() @@ -25,20 +27,20 @@ class FAQListViewModel @Inject constructor( return listOf(FAQHeaderViewModel()) + faqs } - private fun retrieveQuestionsOrAnswers(questionsOrAnswers: Array): List { - val appName = activity.resources.getString(R.string.app_name) + private fun retrieveQuestionsOrAnswers(questionsOrAnswers: List): List { + val appName = resourceHandler.getStringInLocale(R.string.app_name) return questionsOrAnswers.mapIndexed { index, questionOrAnswer -> if (index == QUESTION_INDEX_WITH_OPPIA_REFERENCE) { - String.format(Locale.getDefault(), questionOrAnswer, appName) + resourceHandler.formatInLocale(questionOrAnswer, appName) } else questionOrAnswer } } private fun retrieveQuestions(): List = - retrieveQuestionsOrAnswers(activity.resources.getStringArray(R.array.faq_questions)) + retrieveQuestionsOrAnswers(resourceHandler.getStringArrayInLocale(R.array.faq_questions)) private fun retrieveAnswers(): List = - retrieveQuestionsOrAnswers(activity.resources.getStringArray(R.array.faq_answers)) + retrieveQuestionsOrAnswers(resourceHandler.getStringArrayInLocale(R.array.faq_answers)) private companion object { private const val QUESTION_INDEX_WITH_OPPIA_REFERENCE = 3 diff --git a/app/src/main/java/org/oppia/android/app/help/faq/faqsingle/FAQSingleActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/help/faq/faqsingle/FAQSingleActivityPresenter.kt index 2ecc2dc59a1..3d4d0862764 100644 --- a/app/src/main/java/org/oppia/android/app/help/faq/faqsingle/FAQSingleActivityPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/help/faq/faqsingle/FAQSingleActivityPresenter.kt @@ -10,13 +10,15 @@ import org.oppia.android.databinding.FaqSingleActivityBinding import org.oppia.android.util.gcsresource.DefaultResourceBucketName import org.oppia.android.util.parser.html.HtmlParser import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler /** The presenter for [FAQSingleActivity]. */ @ActivityScope class FAQSingleActivityPresenter @Inject constructor( private val activity: AppCompatActivity, private val htmlParserFactory: HtmlParser.Factory, - @DefaultResourceBucketName private val resourceBucketName: String + @DefaultResourceBucketName private val resourceBucketName: String, + private val resourceHandler: AppLanguageResourceHandler ) { private lateinit var faqSingleActivityToolbar: Toolbar @@ -32,7 +34,7 @@ class FAQSingleActivityPresenter @Inject constructor( faqSingleActivityToolbar = binding.faqSingleActivityToolbar activity.setSupportActionBar(faqSingleActivityToolbar) - activity.supportActionBar!!.title = activity.resources.getString(R.string.FAQs) + activity.supportActionBar!!.title = resourceHandler.getStringInLocale(R.string.FAQs) activity.supportActionBar!!.setDisplayShowHomeEnabled(true) activity.supportActionBar!!.setDisplayHomeAsUpEnabled(true) diff --git a/app/src/main/java/org/oppia/android/app/help/thirdparty/LicenseListActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/help/thirdparty/LicenseListActivityPresenter.kt index 0cc6404aa2c..c3cd9ae6400 100644 --- a/app/src/main/java/org/oppia/android/app/help/thirdparty/LicenseListActivityPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/help/thirdparty/LicenseListActivityPresenter.kt @@ -6,11 +6,13 @@ import org.oppia.android.R import org.oppia.android.app.activity.ActivityScope import org.oppia.android.databinding.LicenseListActivityBinding import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler /** The presenter for [LicenseListActivity]. */ @ActivityScope class LicenseListActivityPresenter @Inject constructor( - private val activity: AppCompatActivity + private val activity: AppCompatActivity, + private val resourceHandler: AppLanguageResourceHandler ) { /** Handles onCreate() method of the [LicenseListActivity]. */ @@ -26,7 +28,8 @@ class LicenseListActivityPresenter @Inject constructor( val licenseListActivityToolbar = binding.licenseListActivityToolbar activity.setSupportActionBar(licenseListActivityToolbar) - activity.supportActionBar!!.title = activity.getString(R.string.license_list_activity_title) + activity.supportActionBar!!.title = + resourceHandler.getStringInLocale(R.string.license_list_activity_title) activity.supportActionBar!!.setDisplayShowHomeEnabled(true) activity.supportActionBar!!.setDisplayHomeAsUpEnabled(true) diff --git a/app/src/main/java/org/oppia/android/app/help/thirdparty/LicenseListFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/help/thirdparty/LicenseListFragmentPresenter.kt index 0ab8894ca8a..74a9850a9ef 100644 --- a/app/src/main/java/org/oppia/android/app/help/thirdparty/LicenseListFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/help/thirdparty/LicenseListFragmentPresenter.kt @@ -11,12 +11,14 @@ import org.oppia.android.app.recyclerview.BindableAdapter import org.oppia.android.databinding.LicenseItemBinding import org.oppia.android.databinding.LicenseListFragmentBinding import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler /** The presenter for [LicenseListFragment]. */ @FragmentScope class LicenseListFragmentPresenter @Inject constructor( private val activity: AppCompatActivity, - private val fragment: Fragment + private val fragment: Fragment, + private val resourceHandler: AppLanguageResourceHandler ) { private lateinit var binding: LicenseListFragmentBinding @@ -27,7 +29,7 @@ class LicenseListFragmentPresenter @Inject constructor( dependencyIndex: Int, isMultipane: Boolean ): View? { - val viewModel = LicenseListViewModel(activity, dependencyIndex) + val viewModel = LicenseListViewModel(activity, dependencyIndex, resourceHandler) viewModel.isMultipane.set(isMultipane) binding = LicenseListFragmentBinding.inflate( diff --git a/app/src/main/java/org/oppia/android/app/help/thirdparty/LicenseListViewModel.kt b/app/src/main/java/org/oppia/android/app/help/thirdparty/LicenseListViewModel.kt index 6fa65e8e016..7d8a05e990d 100644 --- a/app/src/main/java/org/oppia/android/app/help/thirdparty/LicenseListViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/help/thirdparty/LicenseListViewModel.kt @@ -4,14 +4,16 @@ import androidx.appcompat.app.AppCompatActivity import org.oppia.android.R import org.oppia.android.app.help.HelpViewModel import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler /** * View model in [LicenseListFragment] that contains the list of licenses corresponding to a * third-party dependency. */ -class LicenseListViewModel @Inject constructor( +class LicenseListViewModel( val activity: AppCompatActivity, - val dependencyIndex: Int + val dependencyIndex: Int, + private val resourceHandler: AppLanguageResourceHandler ) : HelpViewModel() { /** Stores the list of licenses of the third-party dependency. */ @@ -27,7 +29,7 @@ class LicenseListViewModel @Inject constructor( dependencyIndex, /* defValue= */ 0 ) - val licenseNamesArray = activity.resources.getStringArray(licenseNamesArrayId) + val licenseNamesArray = resourceHandler.getStringArrayInLocale(licenseNamesArrayId) val itemList = licenseNamesArray.mapIndexed { licenseIndex, name -> LicenseItemViewModel(activity, name, licenseIndex, dependencyIndex, isMultipane.get()!!) } diff --git a/app/src/main/java/org/oppia/android/app/help/thirdparty/LicenseTextViewModel.kt b/app/src/main/java/org/oppia/android/app/help/thirdparty/LicenseTextViewModel.kt index d5a8e2ca8fb..fc2c7006b0a 100644 --- a/app/src/main/java/org/oppia/android/app/help/thirdparty/LicenseTextViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/help/thirdparty/LicenseTextViewModel.kt @@ -2,13 +2,15 @@ package org.oppia.android.app.help.thirdparty import androidx.appcompat.app.AppCompatActivity import org.oppia.android.R +import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.app.viewmodel.ObservableViewModel /** Content view model for the in [LicenseTextViewerFragment] that contains the license text. */ class LicenseTextViewModel( val activity: AppCompatActivity, val dependencyIndex: Int, - licenseIndex: Int + licenseIndex: Int, + resourceHandler: AppLanguageResourceHandler ) : ObservableViewModel() { private val dependenciesWithLicenseTexts = activity.resources.obtainTypedArray( R.array.third_party_dependency_license_texts_array @@ -17,9 +19,8 @@ class LicenseTextViewModel( dependencyIndex, /* defValue= */ 0 ) - private val licenseTextsArray: Array = activity.resources.getStringArray( - licenseTextsArrayId - ) + private val licenseTextsArray: List = + resourceHandler.getStringArrayInLocale(licenseTextsArrayId) /** Text of the license to be displayed in [LicenseTextViewerFragment]. */ val licenseText = licenseTextsArray[licenseIndex] } diff --git a/app/src/main/java/org/oppia/android/app/help/thirdparty/LicenseTextViewerActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/help/thirdparty/LicenseTextViewerActivityPresenter.kt index 37036da45b6..7aa4b7cd28f 100644 --- a/app/src/main/java/org/oppia/android/app/help/thirdparty/LicenseTextViewerActivityPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/help/thirdparty/LicenseTextViewerActivityPresenter.kt @@ -6,11 +6,13 @@ import org.oppia.android.R import org.oppia.android.app.activity.ActivityScope import org.oppia.android.databinding.LicenseTextViewerActivityBinding import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler /** The presenter for [LicenseTextViewerActivity]. */ @ActivityScope class LicenseTextViewerActivityPresenter @Inject constructor( - private val activity: AppCompatActivity + private val activity: AppCompatActivity, + private val resourceHandler: AppLanguageResourceHandler ) { /** Handles onCreate() method of the [LicenseTextViewerActivity]. */ @@ -29,7 +31,7 @@ class LicenseTextViewerActivityPresenter @Inject constructor( dependencyIndex, 0 ) - val licenseNames = activity.resources.getStringArray(licenseNamesArrayResId) + val licenseNames = resourceHandler.getStringArrayInLocale(licenseNamesArrayResId) val licenseTextViewerActivityToolbar = binding.licenseTextViewerActivityToolbar binding.licenseTextViewerActivityToolbarTitle.text = licenseNames[licenseIndex] activity.title = licenseNames[licenseIndex] diff --git a/app/src/main/java/org/oppia/android/app/help/thirdparty/LicenseTextViewerFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/help/thirdparty/LicenseTextViewerFragmentPresenter.kt index 859c245086d..615ca6cb1d8 100644 --- a/app/src/main/java/org/oppia/android/app/help/thirdparty/LicenseTextViewerFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/help/thirdparty/LicenseTextViewerFragmentPresenter.kt @@ -9,12 +9,14 @@ import androidx.fragment.app.Fragment import org.oppia.android.app.fragment.FragmentScope import org.oppia.android.databinding.LicenseTextViewerFragmentBinding import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler /** The presenter for [LicenseListFragment]. */ @FragmentScope class LicenseTextViewerFragmentPresenter @Inject constructor( private val activity: AppCompatActivity, private val fragment: Fragment, + private val resourceHandler: AppLanguageResourceHandler ) { private lateinit var binding: LicenseTextViewerFragmentBinding @@ -47,6 +49,6 @@ class LicenseTextViewerFragmentPresenter @Inject constructor( dependencyIndex: Int, licenseIndex: Int ): LicenseTextViewModel { - return LicenseTextViewModel(activity, dependencyIndex, licenseIndex) + return LicenseTextViewModel(activity, dependencyIndex, licenseIndex, resourceHandler) } } diff --git a/app/src/main/java/org/oppia/android/app/help/thirdparty/ThirdPartyDependencyListActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/help/thirdparty/ThirdPartyDependencyListActivityPresenter.kt index 00c6d983ac3..c3809ed404f 100644 --- a/app/src/main/java/org/oppia/android/app/help/thirdparty/ThirdPartyDependencyListActivityPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/help/thirdparty/ThirdPartyDependencyListActivityPresenter.kt @@ -6,11 +6,13 @@ import org.oppia.android.R import org.oppia.android.app.activity.ActivityScope import org.oppia.android.databinding.ThirdPartyDependencyListActivityBinding import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler /** The presenter for [ThirdPartyDependencyListActivity]. */ @ActivityScope class ThirdPartyDependencyListActivityPresenter @Inject constructor( - private val activity: AppCompatActivity + private val activity: AppCompatActivity, + private val resourceHandler: AppLanguageResourceHandler ) { /** Handles onCreate() method of the [ThirdPartyDependencyListActivity]. */ @@ -26,7 +28,7 @@ class ThirdPartyDependencyListActivityPresenter @Inject constructor( val thirdPartyDependencyListActivityToolbar = binding.thirdPartyDependencyListActivityToolbar activity.setSupportActionBar(thirdPartyDependencyListActivityToolbar) - activity.supportActionBar!!.title = activity.getString( + activity.supportActionBar!!.title = resourceHandler.getStringInLocale( R.string.third_party_dependency_list_activity_title ) activity.supportActionBar!!.setDisplayShowHomeEnabled(true) diff --git a/app/src/main/java/org/oppia/android/app/help/thirdparty/ThirdPartyDependencyListViewModel.kt b/app/src/main/java/org/oppia/android/app/help/thirdparty/ThirdPartyDependencyListViewModel.kt index 61bebe647b5..0207748617d 100644 --- a/app/src/main/java/org/oppia/android/app/help/thirdparty/ThirdPartyDependencyListViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/help/thirdparty/ThirdPartyDependencyListViewModel.kt @@ -4,13 +4,15 @@ import androidx.appcompat.app.AppCompatActivity import org.oppia.android.R import org.oppia.android.app.help.HelpViewModel import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler /** * View model in [ThirdPartyDependencyListFragment] that contains the list of third-party * dependencies and their versions. */ class ThirdPartyDependencyListViewModel @Inject constructor( - val activity: AppCompatActivity + val activity: AppCompatActivity, + private val resourceHandler: AppLanguageResourceHandler ) : HelpViewModel() { /** Stores the list of third-party dependencies. */ @@ -19,17 +21,15 @@ class ThirdPartyDependencyListViewModel @Inject constructor( } private fun getRecyclerViewItemList(): List { - val thirdPartyDependencyNames: Array = - activity.resources.getStringArray(R.array.third_party_dependency_names_array) - val thirdPartyDependencyVersions: Array = - activity.resources.getStringArray( - R.array.third_party_dependency_versions_array - ) + val thirdPartyDependencyNames = + resourceHandler.getStringArrayInLocale(R.array.third_party_dependency_names_array) + val thirdPartyDependencyVersions = + resourceHandler.getStringArrayInLocale(R.array.third_party_dependency_versions_array) return thirdPartyDependencyNames.mapIndexed { index, name -> ThirdPartyDependencyItemViewModel( activity = activity, dependencyName = name, - dependencyVersion = activity.resources.getString( + dependencyVersion = resourceHandler.getStringInLocale( R.string.third_party_dependency_version_formatter, thirdPartyDependencyVersions[index] ), diff --git a/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragment.kt b/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragment.kt index d57067fa69e..51792d75b42 100644 --- a/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragment.kt +++ b/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragment.kt @@ -13,6 +13,7 @@ import org.oppia.android.util.extensions.getProto import org.oppia.android.util.extensions.putProto import javax.inject.Inject import org.oppia.android.app.fragment.FragmentComponentImpl +import org.oppia.android.util.extensions.getStringFromBundle private const val CURRENT_EXPANDED_LIST_INDEX_SAVED_KEY = "HintsAndSolutionDialogFragment.current_expanded_list_index" @@ -101,7 +102,7 @@ class HintsAndSolutionDialogFragment : ) { "Expected arguments to be passed to HintsAndSolutionDialogFragment" } val id = checkNotNull( - args.getString(ID_ARGUMENT_KEY) + args.getStringFromBundle(ID_ARGUMENT_KEY) ) { "Expected id to be passed to HintsAndSolutionDialogFragment" } val state = args.getProto(STATE_KEY, State.getDefaultInstance()) diff --git a/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragmentPresenter.kt index e1ab2270bb4..eae991d530f 100644 --- a/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragmentPresenter.kt @@ -24,6 +24,7 @@ import org.oppia.android.util.parser.html.HtmlParser import java.lang.IllegalStateException import java.util.Locale import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler const val TAG_REVEAL_SOLUTION_DIALOG = "REVEAL_SOLUTION_DIALOG" @@ -34,7 +35,8 @@ class HintsAndSolutionDialogFragmentPresenter @Inject constructor( private val viewModelProvider: ViewModelProvider, private val htmlParserFactory: HtmlParser.Factory, @DefaultResourceBucketName private val resourceBucketName: String, - @ExplorationHtmlParserEntityType private val entityType: String + @ExplorationHtmlParserEntityType private val entityType: String, + private val resourceHandler: AppLanguageResourceHandler ) { private var currentExpandedHintListIndex: Int? = null @@ -217,8 +219,8 @@ class HintsAndSolutionDialogFragmentPresenter @Inject constructor( } } - binding.hintTitle.text = hintsViewModel.title.get()!!.replace("_", " ") - .capitalize(Locale.getDefault()) + binding.hintTitle.text = + resourceHandler.capitalizeForHumans(hintsViewModel.title.get()!!.replace("_", " ")) binding.hintsAndSolutionSummary.text = htmlParserFactory.create( resourceBucketName, @@ -300,11 +302,12 @@ class HintsAndSolutionDialogFragmentPresenter @Inject constructor( } } - binding.solutionTitle.text = solutionViewModel.title.get()!!.capitalize(Locale.getDefault()) + binding.solutionTitle.text = + resourceHandler.capitalizeForHumans(solutionViewModel.title.get()!!) // TODO(#1050): Update to display answers for any answer type. if (solutionViewModel.correctAnswer.get().isNullOrEmpty()) { binding.solutionCorrectAnswer.text = - fragment.requireContext().resources.getString( + resourceHandler.getStringInLocale( R.string.hints_android_solution_correct_answer, solutionViewModel.numerator.get().toString(), solutionViewModel.denominator.get().toString() diff --git a/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsViewModel.kt b/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsViewModel.kt index 8e964bc094e..63a67707346 100644 --- a/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsViewModel.kt @@ -2,6 +2,7 @@ package org.oppia.android.app.hintsandsolution import androidx.databinding.ObservableField import androidx.lifecycle.ViewModel +import org.oppia.android.R import org.oppia.android.app.fragment.FragmentScope import org.oppia.android.app.model.HelpIndex import org.oppia.android.app.model.Hint @@ -9,6 +10,7 @@ import org.oppia.android.app.model.Solution import org.oppia.android.domain.hintsandsolution.isHintRevealed import org.oppia.android.domain.hintsandsolution.isSolutionRevealed import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler /** * RecyclerView items are 2 times of (No. of Hints + Solution), @@ -17,16 +19,20 @@ import javax.inject.Inject */ const val RECYCLERVIEW_INDEX_CORRECTION_MULTIPLIER = 2 +private const val DEFAULT_HINT_AND_SOLUTION_SUMMARY = "" + /** [ViewModel] for Hints in [HintsAndSolutionDialogFragment]. */ @FragmentScope -class HintsViewModel @Inject constructor() : HintsAndSolutionItemViewModel() { +class HintsViewModel @Inject constructor( + private val resourceHandler: AppLanguageResourceHandler +) : HintsAndSolutionItemViewModel() { val newAvailableHintIndex = ObservableField(-1) val allHintsExhausted = ObservableField(false) val explorationId = ObservableField("") val title = ObservableField("") - val hintsAndSolutionSummary = ObservableField("") + val hintsAndSolutionSummary = ObservableField(DEFAULT_HINT_AND_SOLUTION_SUMMARY) val isHintRevealed = ObservableField(false) val hintCanBeRevealed = ObservableField(false) @@ -79,8 +85,15 @@ class HintsViewModel @Inject constructor() : HintsAndSolutionItemViewModel() { return itemList } + fun computeHintListDropDownIconContentDescription(): String { + return resourceHandler.getStringInLocale( + R.string.show_hide_hint_list, + hintsAndSolutionSummary.get() ?: DEFAULT_HINT_AND_SOLUTION_SUMMARY + ) + } + private fun addHintToList(hintIndex: Int, hint: Hint) { - val hintsViewModel = HintsViewModel() + val hintsViewModel = HintsViewModel(resourceHandler) hintsViewModel.title.set(hint.hintContent.contentId) hintsViewModel.hintsAndSolutionSummary.set(hint.hintContent.html) hintsViewModel.isHintRevealed.set(helpIndex.isHintRevealed(hintIndex, hintList)) diff --git a/app/src/main/java/org/oppia/android/app/hintsandsolution/RevealSolutionDialogFragment.kt b/app/src/main/java/org/oppia/android/app/hintsandsolution/RevealSolutionDialogFragment.kt index b14c0ec4c4a..15f6e09c6be 100755 --- a/app/src/main/java/org/oppia/android/app/hintsandsolution/RevealSolutionDialogFragment.kt +++ b/app/src/main/java/org/oppia/android/app/hintsandsolution/RevealSolutionDialogFragment.kt @@ -7,12 +7,16 @@ import android.view.View import androidx.appcompat.app.AlertDialog import androidx.appcompat.view.ContextThemeWrapper import androidx.fragment.app.DialogFragment +import javax.inject.Inject import org.oppia.android.R +import org.oppia.android.app.fragment.FragmentComponentImpl +import org.oppia.android.app.fragment.InjectableDialogFragment +import org.oppia.android.app.translation.AppLanguageResourceHandler /** * DialogFragment that asks to the user if they want to reveal solution. */ -class RevealSolutionDialogFragment : DialogFragment() { +class RevealSolutionDialogFragment : InjectableDialogFragment() { companion object { /** * This function is responsible for displaying content in DialogFragment. @@ -24,6 +28,14 @@ class RevealSolutionDialogFragment : DialogFragment() { } } + @Inject + lateinit var resourceHandler: AppLanguageResourceHandler + + override fun onAttach(context: Context) { + super.onAttach(context) + (fragmentComponent as FragmentComponentImpl).inject(this) + } + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val view = View.inflate(context, R.layout.reveal_solution_dialog, /* root= */ null) val revealSolutionInterface: RevealSolutionInterface = @@ -33,8 +45,8 @@ class RevealSolutionDialogFragment : DialogFragment() { .Builder(ContextThemeWrapper(activity as Context, R.style.OppiaDialogFragmentTheme)) .setTitle(R.string.reveal_solution) .setView(view) - .setMessage(getString(R.string.this_will_reveal_the_solution)) - .setPositiveButton(getString(R.string.reveal)) { _, _ -> + .setMessage(resourceHandler.getStringInLocale(R.string.this_will_reveal_the_solution)) + .setPositiveButton(resourceHandler.getStringInLocale(R.string.reveal)) { _, _ -> revealSolutionInterface.revealSolution() dismiss() } diff --git a/app/src/main/java/org/oppia/android/app/hintsandsolution/SolutionViewModel.kt b/app/src/main/java/org/oppia/android/app/hintsandsolution/SolutionViewModel.kt index 84a9befdff5..79f733d3761 100644 --- a/app/src/main/java/org/oppia/android/app/hintsandsolution/SolutionViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/hintsandsolution/SolutionViewModel.kt @@ -2,6 +2,7 @@ package org.oppia.android.app.hintsandsolution import androidx.databinding.ObservableField import androidx.lifecycle.ViewModel +import org.oppia.android.app.translation.AppLanguageResourceHandler /** [ViewModel] for Solution in [HintsAndSolutionDialogFragment]. */ class SolutionViewModel : HintsAndSolutionItemViewModel() { diff --git a/app/src/main/java/org/oppia/android/app/home/HomeActivity.kt b/app/src/main/java/org/oppia/android/app/home/HomeActivity.kt index 917403910e4..1538327f784 100644 --- a/app/src/main/java/org/oppia/android/app/home/HomeActivity.kt +++ b/app/src/main/java/org/oppia/android/app/home/HomeActivity.kt @@ -14,6 +14,7 @@ import org.oppia.android.app.model.HighlightItem import org.oppia.android.app.topic.TopicActivity import javax.inject.Inject import org.oppia.android.app.activity.ActivityComponentImpl +import org.oppia.android.app.translation.AppLanguageResourceHandler /** The central activity for all users entering the app. */ class HomeActivity : @@ -23,6 +24,10 @@ class HomeActivity : RouteToRecentlyPlayedListener { @Inject lateinit var homeActivityPresenter: HomeActivityPresenter + + @Inject + lateinit var resourceHandler: AppLanguageResourceHandler + private var internalProfileId: Int = -1 companion object { @@ -38,7 +43,7 @@ class HomeActivity : (activityComponent as ActivityComponentImpl).inject(this) internalProfileId = intent?.getIntExtra(NAVIGATION_PROFILE_ID_ARGUMENT_KEY, -1)!! homeActivityPresenter.handleOnCreate() - title = getString(R.string.menu_home) + title = resourceHandler.getStringInLocale(R.string.menu_home) } override fun onRestart() { diff --git a/app/src/main/java/org/oppia/android/app/home/HomeFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/home/HomeFragmentPresenter.kt index fa98386a13a..40700421d92 100644 --- a/app/src/main/java/org/oppia/android/app/home/HomeFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/home/HomeFragmentPresenter.kt @@ -29,6 +29,8 @@ import org.oppia.android.util.parser.html.StoryHtmlParserEntityType import org.oppia.android.util.parser.html.TopicHtmlParserEntityType import org.oppia.android.util.system.OppiaClock import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler +import org.oppia.android.app.utility.datetime.DateTimeUtil /** The presenter for [HomeFragment]. */ @FragmentScope @@ -40,7 +42,9 @@ class HomeFragmentPresenter @Inject constructor( private val oppiaClock: OppiaClock, private val oppiaLogger: OppiaLogger, @TopicHtmlParserEntityType private val topicEntityType: String, - @StoryHtmlParserEntityType private val storyEntityType: String + @StoryHtmlParserEntityType private val storyEntityType: String, + private val resourceHandler: AppLanguageResourceHandler, + private val dateTimeUtil: DateTimeUtil ) { private val routeToTopicListener = activity as RouteToTopicListener private lateinit var binding: HomeFragmentBinding @@ -57,13 +61,14 @@ class HomeFragmentPresenter @Inject constructor( val homeViewModel = HomeViewModel( activity, fragment, - oppiaClock, oppiaLogger, internalProfileId, profileManagementController, topicListController, topicEntityType, - storyEntityType + storyEntityType, + resourceHandler, + dateTimeUtil ) val homeAdapter = createRecyclerViewAdapter() diff --git a/app/src/main/java/org/oppia/android/app/home/HomeViewModel.kt b/app/src/main/java/org/oppia/android/app/home/HomeViewModel.kt index 5c943d24c6c..87d33168d6a 100644 --- a/app/src/main/java/org/oppia/android/app/home/HomeViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/home/HomeViewModel.kt @@ -20,6 +20,8 @@ import org.oppia.android.app.model.ProfileId import org.oppia.android.app.model.PromotedActivityList import org.oppia.android.app.model.PromotedStoryList import org.oppia.android.app.model.TopicList +import org.oppia.android.app.translation.AppLanguageResourceHandler +import org.oppia.android.app.utility.datetime.DateTimeUtil import org.oppia.android.app.viewmodel.ObservableViewModel import org.oppia.android.domain.oppialogger.OppiaLogger import org.oppia.android.domain.profile.ProfileManagementController @@ -37,17 +39,17 @@ private const val HOME_FRAGMENT_COMBINED_PROVIDER_ID = "profile+promotedActivityList+topicListProvider" /** [ViewModel] for layouts in home fragment. */ -@FragmentScope class HomeViewModel( private val activity: AppCompatActivity, private val fragment: Fragment, - private val oppiaClock: OppiaClock, private val oppiaLogger: OppiaLogger, private val internalProfileId: Int, private val profileManagementController: ProfileManagementController, private val topicListController: TopicListController, @TopicHtmlParserEntityType private val topicEntityType: String, - @StoryHtmlParserEntityType private val storyEntityType: String + @StoryHtmlParserEntityType private val storyEntityType: String, + private val resourceHandler: AppLanguageResourceHandler, + private val dateTimeUtil: DateTimeUtil ) : ObservableViewModel() { private val profileId: ProfileId = ProfileId.newBuilder().setInternalId(internalProfileId).build() @@ -111,7 +113,7 @@ class HomeViewModel( */ private fun computeWelcomeViewModel(profile: Profile): HomeItemViewModel? { return if (profile.name.isNotEmpty()) { - WelcomeViewModel(fragment, oppiaClock, profile.name) + WelcomeViewModel(profile.name, resourceHandler, dateTimeUtil) } else null } @@ -133,7 +135,8 @@ class HomeViewModel( return PromotedStoryListViewModel( activity, storyViewModelList, - promotedActivityList + promotedActivityList, + resourceHandler ) } else null } @@ -227,7 +230,8 @@ class HomeViewModel( topicSummary, topicEntityType, fragment as TopicSummaryClickListener, - position = topicIndex + position = topicIndex, + resourceHandler ) } return if (allTopicsList.isNotEmpty()) { diff --git a/app/src/main/java/org/oppia/android/app/home/WelcomeViewModel.kt b/app/src/main/java/org/oppia/android/app/home/WelcomeViewModel.kt index bd261278ba4..a57155f3a2f 100644 --- a/app/src/main/java/org/oppia/android/app/home/WelcomeViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/home/WelcomeViewModel.kt @@ -1,23 +1,24 @@ package org.oppia.android.app.home -import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModel +import org.oppia.android.R import org.oppia.android.app.utility.datetime.DateTimeUtil -import org.oppia.android.util.system.OppiaClock import java.util.Objects +import org.oppia.android.app.translation.AppLanguageResourceHandler /** [ViewModel] for welcome text in home screen. */ class WelcomeViewModel( - fragment: Fragment, - oppiaClock: OppiaClock, - val profileName: String + private val profileName: String, + private val resourceHandler: AppLanguageResourceHandler, + dateTimeUtil: DateTimeUtil ) : HomeItemViewModel() { /** Text [String] to greet the learner and display on-screen when launching the home activity. */ - val greeting: String = DateTimeUtil( - fragment.requireContext(), - oppiaClock - ).getGreetingMessage() + val greeting: String = dateTimeUtil.getGreetingMessage() + + fun computeProfileNameText(): String { + return resourceHandler.getStringInLocale(R.string.welcome_profile_name, profileName) + } // Overriding equals is needed so that DataProvider combine functions used in the HomeViewModel // will only rebind when the actual data in the data list changes, rather than when the ViewModel diff --git a/app/src/main/java/org/oppia/android/app/home/promotedlist/PromotedStoryListViewModel.kt b/app/src/main/java/org/oppia/android/app/home/promotedlist/PromotedStoryListViewModel.kt index 52bc7e33842..483e4fae75e 100644 --- a/app/src/main/java/org/oppia/android/app/home/promotedlist/PromotedStoryListViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/home/promotedlist/PromotedStoryListViewModel.kt @@ -10,12 +10,14 @@ import org.oppia.android.app.home.HomeItemViewModel import org.oppia.android.app.home.RouteToRecentlyPlayedListener import org.oppia.android.app.model.PromotedActivityList import java.util.Objects +import org.oppia.android.app.translation.AppLanguageResourceHandler /** [ViewModel] for the promoted story list displayed in [HomeFragment]. */ class PromotedStoryListViewModel( private val activity: AppCompatActivity, val promotedStoryList: List, - private val promotedActivityList: PromotedActivityList + private val promotedActivityList: PromotedActivityList, + private val resourceHandler: AppLanguageResourceHandler ) : HomeItemViewModel() { private val routeToRecentlyPlayedListener = activity as RouteToRecentlyPlayedListener private val promotedStoryListLimit = activity.resources.getInteger( @@ -33,15 +35,15 @@ class PromotedStoryListViewModel( return when { suggestedStoryList.isNotEmpty() -> { if (recentlyPlayedStoryList.isEmpty() && olderPlayedStoryList.isEmpty()) { - activity.getString(R.string.recommended_stories) + resourceHandler.getStringInLocale(R.string.recommended_stories) } else - activity.getString(R.string.stories_for_you) + resourceHandler.getStringInLocale(R.string.stories_for_you) } recentlyPlayedStoryList.isNotEmpty() -> { - activity.getString(R.string.recently_played_stories) + resourceHandler.getStringInLocale(R.string.recently_played_stories) } else -> { - activity.getString(R.string.last_played_stories) + resourceHandler.getStringInLocale(R.string.last_played_stories) } } } diff --git a/app/src/main/java/org/oppia/android/app/home/recentlyplayed/OngoingStoryViewModel.kt b/app/src/main/java/org/oppia/android/app/home/recentlyplayed/OngoingStoryViewModel.kt index 438799dbab8..18ed30b5e05 100755 --- a/app/src/main/java/org/oppia/android/app/home/recentlyplayed/OngoingStoryViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/home/recentlyplayed/OngoingStoryViewModel.kt @@ -5,6 +5,7 @@ import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.ViewModel import org.oppia.android.R import org.oppia.android.app.model.PromotedStory +import org.oppia.android.app.translation.AppLanguageResourceHandler // TODO(#297): Add download status information to promoted-story-card. @@ -14,7 +15,8 @@ class OngoingStoryViewModel( val ongoingStory: PromotedStory, val entityType: String, private val ongoingStoryClickListener: OngoingStoryClickListener, - private val position: Int + private val position: Int, + private val resourceHandler: AppLanguageResourceHandler ) : RecentlyPlayedItemViewModel() { fun clickOnOngoingStoryTile(@Suppress("UNUSED_PARAMETER") v: View) { ongoingStoryClickListener.onOngoingStoryClicked(ongoingStory) @@ -97,4 +99,10 @@ class OngoingStoryViewModel( else -> 0 } } + + fun computeLessonThumbnailContentDescription(): String { + return resourceHandler.getStringInLocale( + R.string.lesson_thumbnail_content_description, ongoingStory.nextChapterName + ) + } } diff --git a/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedFragmentPresenter.kt index 812ab3637b5..5a08159296a 100755 --- a/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedFragmentPresenter.kt @@ -28,6 +28,7 @@ import org.oppia.android.util.data.AsyncResult import org.oppia.android.util.data.DataProviders.Companion.toLiveData import org.oppia.android.util.parser.html.StoryHtmlParserEntityType import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler /** The presenter for [RecentlyPlayedFragment]. */ @FragmentScope @@ -38,7 +39,8 @@ class RecentlyPlayedFragmentPresenter @Inject constructor( private val explorationDataController: ExplorationDataController, private val topicListController: TopicListController, private val explorationCheckpointController: ExplorationCheckpointController, - @StoryHtmlParserEntityType private val entityType: String + @StoryHtmlParserEntityType private val entityType: String, + private val resourceHandler: AppLanguageResourceHandler ) { private val routeToResumeLessonListener = activity as RouteToResumeLessonListener @@ -83,17 +85,20 @@ class RecentlyPlayedFragmentPresenter @Inject constructor( fragment, { if (it.promotedStoryList.recentlyPlayedStoryList.isNotEmpty()) { - binding.recentlyPlayedToolbar.title = activity.getString(R.string.recently_played_stories) + binding.recentlyPlayedToolbar.title = + resourceHandler.getStringInLocale(R.string.recently_played_stories) addRecentlyPlayedStoryListSection(it.promotedStoryList.recentlyPlayedStoryList) } if (it.promotedStoryList.olderPlayedStoryList.isNotEmpty()) { - binding.recentlyPlayedToolbar.title = activity.getString(R.string.recently_played_stories) + binding.recentlyPlayedToolbar.title = + resourceHandler.getStringInLocale(R.string.recently_played_stories) addOlderStoryListSection(it.promotedStoryList.olderPlayedStoryList) } if (it.promotedStoryList.suggestedStoryList.isNotEmpty()) { - binding.recentlyPlayedToolbar.title = activity.getString(R.string.stories_for_you) + binding.recentlyPlayedToolbar.title = + resourceHandler.getStringInLocale(R.string.stories_for_you) addRecommendedStoryListSection(it.promotedStoryList.suggestedStoryList) } @@ -112,7 +117,9 @@ class RecentlyPlayedFragmentPresenter @Inject constructor( recentlyPlayedStoryList: MutableList ) { val recentSectionTitleViewModel = - SectionTitleViewModel(activity.getString(R.string.ongoing_story_last_week), false) + SectionTitleViewModel( + resourceHandler.getStringInLocale(R.string.ongoing_story_last_week), false + ) itemList.add(recentSectionTitleViewModel) recentlyPlayedStoryList.forEachIndexed { index, promotedStory -> val ongoingStoryViewModel = getOngoingStoryViewModel(promotedStory, index) @@ -129,7 +136,8 @@ class RecentlyPlayedFragmentPresenter @Inject constructor( promotedStory, entityType, fragment as OngoingStoryClickListener, - index + index, + resourceHandler ) } @@ -137,7 +145,7 @@ class RecentlyPlayedFragmentPresenter @Inject constructor( val showDivider = itemList.isNotEmpty() val olderSectionTitleViewModel = SectionTitleViewModel( - activity.getString(R.string.ongoing_story_last_month), + resourceHandler.getStringInLocale(R.string.ongoing_story_last_month), showDivider ) itemList.add(olderSectionTitleViewModel) @@ -151,7 +159,7 @@ class RecentlyPlayedFragmentPresenter @Inject constructor( val showDivider = itemList.isNotEmpty() val recommendedSectionTitleViewModel = SectionTitleViewModel( - activity.getString(R.string.recommended_stories), + resourceHandler.getStringInLocale(R.string.recommended_stories), showDivider ) itemList.add(recommendedSectionTitleViewModel) diff --git a/app/src/main/java/org/oppia/android/app/home/topiclist/TopicSummaryViewModel.kt b/app/src/main/java/org/oppia/android/app/home/topiclist/TopicSummaryViewModel.kt index 8f68620846c..28b961e47e1 100755 --- a/app/src/main/java/org/oppia/android/app/home/topiclist/TopicSummaryViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/home/topiclist/TopicSummaryViewModel.kt @@ -5,6 +5,7 @@ import org.oppia.android.R import org.oppia.android.app.home.HomeItemViewModel import org.oppia.android.app.model.TopicSummary import java.util.Objects +import org.oppia.android.app.translation.AppLanguageResourceHandler /** The view model corresponding to individual topic summaries in the topic summary RecyclerView. */ class TopicSummaryViewModel( @@ -12,10 +13,10 @@ class TopicSummaryViewModel( val topicSummary: TopicSummary, val entityType: String, private val topicSummaryClickListener: TopicSummaryClickListener, - private val position: Int + private val position: Int, + private val resourceHandler: AppLanguageResourceHandler ) : HomeItemViewModel() { val name: String = topicSummary.name - val totalChapterCount: Int = topicSummary.totalChapterCount private val outerMargin by lazy { activity.resources.getDimensionPixelSize(R.dimen.home_outer_margin) @@ -100,6 +101,12 @@ class TopicSummaryViewModel( } } + fun computeLessonCountText(): String { + return resourceHandler.getQuantityStringInLocale( + R.plurals.lesson_count, topicSummary.totalChapterCount, topicSummary.totalChapterCount + ) + } + // Overriding equals is needed so that DataProvider combine functions used in the HomeViewModel // will only rebind when the actual data in the data list changes, rather than when the ViewModel // object changes. diff --git a/app/src/main/java/org/oppia/android/app/mydownloads/MyDownloadsFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/mydownloads/MyDownloadsFragmentPresenter.kt index b9ffd380a18..50afc76b86c 100644 --- a/app/src/main/java/org/oppia/android/app/mydownloads/MyDownloadsFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/mydownloads/MyDownloadsFragmentPresenter.kt @@ -11,10 +11,14 @@ import org.oppia.android.R import org.oppia.android.app.fragment.FragmentScope import org.oppia.android.databinding.MyDownloadsFragmentBinding import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler /** The presenter for [MyDownloadsFragment]. */ @FragmentScope -class MyDownloadsFragmentPresenter @Inject constructor(private val fragment: Fragment) { +class MyDownloadsFragmentPresenter @Inject constructor( + private val fragment: Fragment, + private val resourceHandler: AppLanguageResourceHandler +) { fun handleCreateView(inflater: LayoutInflater, container: ViewGroup?): View? { val binding = MyDownloadsFragmentBinding.inflate( inflater, @@ -42,8 +46,8 @@ class MyDownloadsFragmentPresenter @Inject constructor(private val fragment: Fra TabLayoutMediator(tabLayout, viewPager2) { tab, position -> when (position) { - 0 -> tab.text = fragment.getString(R.string.tab_downloads) - 1 -> tab.text = fragment.getString(R.string.tab_updates) + 0 -> tab.text = resourceHandler.getStringInLocale(R.string.tab_downloads) + 1 -> tab.text = resourceHandler.getStringInLocale(R.string.tab_updates) } }.attach() } diff --git a/app/src/main/java/org/oppia/android/app/onboarding/OnboadingSlideViewModel.kt b/app/src/main/java/org/oppia/android/app/onboarding/OnboadingSlideViewModel.kt index 0886853ee4e..24222525590 100644 --- a/app/src/main/java/org/oppia/android/app/onboarding/OnboadingSlideViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/onboarding/OnboadingSlideViewModel.kt @@ -6,17 +6,20 @@ import android.content.res.Resources import androidx.databinding.ObservableField import androidx.lifecycle.ViewModel import org.oppia.android.R +import org.oppia.android.app.translation.AppLanguageResourceHandler const val TOTAL_NUMBER_OF_SLIDES = 4 /** [ViewModel] for slide in onboarding flow. */ -class OnboardingSlideViewModel(val context: Context, viewPagerSlide: ViewPagerSlide) : - OnboardingViewPagerViewModel() { - val slideImage = ObservableField(R.drawable.ic_portrait_onboarding_0) +class OnboardingSlideViewModel( + val context: Context, viewPagerSlide: ViewPagerSlide, + private val resourceHandler: AppLanguageResourceHandler +) : OnboardingViewPagerViewModel() { + val slideImage = ObservableField(R.drawable.ic_portrait_onboarding_0) val title = - ObservableField(getOnboardingSlide0Title()) + ObservableField(getOnboardingSlide0Title()) val description = - ObservableField(context.resources.getString(R.string.onboarding_slide_0_description)) + ObservableField(resourceHandler.getStringInLocale(R.string.onboarding_slide_0_description)) private val orientation = Resources.getSystem().configuration.orientation init { @@ -36,7 +39,7 @@ class OnboardingSlideViewModel(val context: Context, viewPagerSlide: ViewPagerSl slideImage.set(R.drawable.ic_portrait_onboarding_0) } title.set(getOnboardingSlide0Title()) - description.set(context.resources.getString(R.string.onboarding_slide_0_description)) + description.set(resourceHandler.getStringInLocale(R.string.onboarding_slide_0_description)) } ViewPagerSlide.SLIDE_1 -> { if (orientation == Configuration.ORIENTATION_LANDSCAPE) { @@ -48,8 +51,8 @@ class OnboardingSlideViewModel(val context: Context, viewPagerSlide: ViewPagerSl } else if (orientation == Configuration.ORIENTATION_PORTRAIT) { slideImage.set(R.drawable.ic_portrait_onboarding_1) } - title.set(context.resources.getString(R.string.onboarding_slide_1_title)) - description.set(context.resources.getString(R.string.onboarding_slide_1_description)) + title.set(resourceHandler.getStringInLocale(R.string.onboarding_slide_1_title)) + description.set(resourceHandler.getStringInLocale(R.string.onboarding_slide_1_description)) } ViewPagerSlide.SLIDE_2 -> { if (orientation == Configuration.ORIENTATION_LANDSCAPE) { @@ -61,14 +64,14 @@ class OnboardingSlideViewModel(val context: Context, viewPagerSlide: ViewPagerSl } else if (orientation == Configuration.ORIENTATION_PORTRAIT) { slideImage.set(R.drawable.ic_portrait_onboarding_2) } - title.set(context.resources.getString(R.string.onboarding_slide_2_title)) - description.set(context.resources.getString(R.string.onboarding_slide_2_description)) + title.set(resourceHandler.getStringInLocale(R.string.onboarding_slide_2_title)) + description.set(resourceHandler.getStringInLocale(R.string.onboarding_slide_2_description)) } } } private fun getOnboardingSlide0Title(): String { - val appName = context.resources.getString(R.string.app_name) - return context.resources.getString(R.string.onboarding_slide_0_title, appName) + val appName = resourceHandler.getStringInLocale(R.string.app_name) + return resourceHandler.getStringInLocale(R.string.onboarding_slide_0_title, appName) } } diff --git a/app/src/main/java/org/oppia/android/app/onboarding/OnboardingFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/onboarding/OnboardingFragmentPresenter.kt index 3b493d73cd4..649d330a57d 100644 --- a/app/src/main/java/org/oppia/android/app/onboarding/OnboardingFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/onboarding/OnboardingFragmentPresenter.kt @@ -17,6 +17,7 @@ import org.oppia.android.databinding.OnboardingSlideBinding import org.oppia.android.databinding.OnboardingSlideFinalBinding import org.oppia.android.util.statusbar.StatusBarColor import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler /** The presenter for [OnboardingFragment]. */ @FragmentScope @@ -24,7 +25,8 @@ class OnboardingFragmentPresenter @Inject constructor( private val activity: AppCompatActivity, private val fragment: Fragment, private val viewModelProvider: ViewModelProvider, - private val viewModelProviderFinalSlide: ViewModelProvider + private val viewModelProviderFinalSlide: ViewModelProvider, + private val resourceHandler: AppLanguageResourceHandler ) : OnboardingNavigationListener { private val dotsList = ArrayList() private lateinit var binding: OnboardingFragmentBinding @@ -51,9 +53,15 @@ class OnboardingFragmentPresenter @Inject constructor( val onboardingViewPagerBindableAdapter = createViewPagerAdapter() onboardingViewPagerBindableAdapter.setData( listOf( - OnboardingSlideViewModel(context = activity, viewPagerSlide = ViewPagerSlide.SLIDE_0), - OnboardingSlideViewModel(context = activity, viewPagerSlide = ViewPagerSlide.SLIDE_1), - OnboardingSlideViewModel(context = activity, viewPagerSlide = ViewPagerSlide.SLIDE_2), + OnboardingSlideViewModel( + context = activity, viewPagerSlide = ViewPagerSlide.SLIDE_0, resourceHandler + ), + OnboardingSlideViewModel( + context = activity, viewPagerSlide = ViewPagerSlide.SLIDE_1, resourceHandler + ), + OnboardingSlideViewModel( + context = activity, viewPagerSlide = ViewPagerSlide.SLIDE_2, resourceHandler + ), getOnboardingSlideFinalViewModel() ) ) diff --git a/app/src/main/java/org/oppia/android/app/onboarding/OnboardingViewModel.kt b/app/src/main/java/org/oppia/android/app/onboarding/OnboardingViewModel.kt index 3c899d86026..af28bfb7644 100644 --- a/app/src/main/java/org/oppia/android/app/onboarding/OnboardingViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/onboarding/OnboardingViewModel.kt @@ -2,15 +2,32 @@ package org.oppia.android.app.onboarding import androidx.databinding.ObservableField import androidx.lifecycle.ViewModel +import org.oppia.android.R import org.oppia.android.app.viewmodel.ObservableViewModel import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler + +private const val INITIAL_SLIDE_NUMBER = 0 /** [ViewModel] for [OnboardingFragment]. */ -class OnboardingViewModel @Inject constructor() : ObservableViewModel() { - val slideNumber = ObservableField(0) +class OnboardingViewModel @Inject constructor( + private val resourceHandler: AppLanguageResourceHandler +) : ObservableViewModel() { + val slideNumber = ObservableField(INITIAL_SLIDE_NUMBER) val totalNumberOfSlides = TOTAL_NUMBER_OF_SLIDES + val slideDotsContainerContentDescription = + ObservableField(computeSlideDotsContainerContentDescription(INITIAL_SLIDE_NUMBER)) fun slideChanged(slideIndex: Int) { slideNumber.set(slideIndex) + slideDotsContainerContentDescription.set( + computeSlideDotsContainerContentDescription(slideIndex) + ) + } + + private fun computeSlideDotsContainerContentDescription(slideNumber: Int): String { + return resourceHandler.getStringInLocale( + R.string.onboarding_slide_dots_content_description, slideNumber + 1, totalNumberOfSlides + ) } } diff --git a/app/src/main/java/org/oppia/android/app/ongoingtopiclist/OngoingTopicItemViewModel.kt b/app/src/main/java/org/oppia/android/app/ongoingtopiclist/OngoingTopicItemViewModel.kt index 1b6b97f1722..d9248566367 100644 --- a/app/src/main/java/org/oppia/android/app/ongoingtopiclist/OngoingTopicItemViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/ongoingtopiclist/OngoingTopicItemViewModel.kt @@ -2,9 +2,11 @@ package org.oppia.android.app.ongoingtopiclist import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.ViewModel +import org.oppia.android.R import org.oppia.android.app.home.RouteToTopicListener import org.oppia.android.app.model.Topic import org.oppia.android.app.shim.IntentFactoryShim +import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.app.viewmodel.ObservableViewModel /** [ViewModel] for displaying topic item in [OngoingTopicListActivity]. */ @@ -13,15 +15,20 @@ class OngoingTopicItemViewModel( private val internalProfileId: Int, val topic: Topic, val entityType: String, - private val intentFactoryShim: IntentFactoryShim -) : - ObservableViewModel(), - RouteToTopicListener { + private val intentFactoryShim: IntentFactoryShim, + private val resourceHandler: AppLanguageResourceHandler +) : ObservableViewModel(), RouteToTopicListener { fun onTopicItemClicked() { routeToTopic(internalProfileId, topic.topicId) } + fun computeStoryCountText(): String { + return resourceHandler.getQuantityStringInLocale( + R.plurals.lesson_count, topic.storyCount, topic.storyCount + ) + } + override fun routeToTopic(internalProfileId: Int, topicId: String) { val intent = intentFactoryShim.createTopicActivityIntent( activity.applicationContext, diff --git a/app/src/main/java/org/oppia/android/app/ongoingtopiclist/OngoingTopicListViewModel.kt b/app/src/main/java/org/oppia/android/app/ongoingtopiclist/OngoingTopicListViewModel.kt index 1af69aba548..7bd77b411fe 100644 --- a/app/src/main/java/org/oppia/android/app/ongoingtopiclist/OngoingTopicListViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/ongoingtopiclist/OngoingTopicListViewModel.kt @@ -14,6 +14,7 @@ import org.oppia.android.util.data.AsyncResult import org.oppia.android.util.data.DataProviders.Companion.toLiveData import org.oppia.android.util.parser.html.TopicHtmlParserEntityType import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler /** The ObservableViewModel for [OngoingTopicListFragment]. */ @FragmentScope @@ -22,7 +23,8 @@ class OngoingTopicListViewModel @Inject constructor( private val topicController: TopicController, private val oppiaLogger: OppiaLogger, private val intentFactoryShim: IntentFactoryShim, - @TopicHtmlParserEntityType private val entityType: String + @TopicHtmlParserEntityType private val entityType: String, + private val resourceHandler: AppLanguageResourceHandler ) : ObservableViewModel() { /** [internalProfileId] needs to be set before any of the live data members can be accessed. */ private var internalProfileId: Int = -1 @@ -64,7 +66,9 @@ class OngoingTopicListViewModel @Inject constructor( val itemViewModelList: MutableList = mutableListOf() itemViewModelList.addAll( ongoingTopicList.topicList.map { topic -> - OngoingTopicItemViewModel(activity, internalProfileId, topic, entityType, intentFactoryShim) + OngoingTopicItemViewModel( + activity, internalProfileId, topic, entityType, intentFactoryShim, resourceHandler + ) } ) return itemViewModelList diff --git a/app/src/main/java/org/oppia/android/app/options/AppLanguageFragment.kt b/app/src/main/java/org/oppia/android/app/options/AppLanguageFragment.kt index dc0883c206f..7aac76102de 100644 --- a/app/src/main/java/org/oppia/android/app/options/AppLanguageFragment.kt +++ b/app/src/main/java/org/oppia/android/app/options/AppLanguageFragment.kt @@ -8,6 +8,7 @@ import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableFragment import javax.inject.Inject import org.oppia.android.app.fragment.FragmentComponentImpl +import org.oppia.android.util.extensions.getStringFromBundle private const val APP_LANGUAGE_PREFERENCE_TITLE_ARGUMENT_KEY = "AppLanguageFragment.app_language_preference_title" @@ -46,9 +47,9 @@ class AppLanguageFragment : ): View? { val args = checkNotNull(arguments) { "Expected arguments to be passed to AppLanguageFragment" } - val prefsKey = args.getString(APP_LANGUAGE_PREFERENCE_TITLE_ARGUMENT_KEY) + val prefsKey = args.getStringFromBundle(APP_LANGUAGE_PREFERENCE_TITLE_ARGUMENT_KEY) val prefsSummaryValue = if (savedInstanceState == null) { - args.getString(APP_LANGUAGE_PREFERENCE_SUMMARY_VALUE_ARGUMENT_KEY) + args.getStringFromBundle(APP_LANGUAGE_PREFERENCE_SUMMARY_VALUE_ARGUMENT_KEY) } else { savedInstanceState.get(SELECTED_LANGUAGE_SAVED_KEY) as String } diff --git a/app/src/main/java/org/oppia/android/app/options/AudioLanguageFragment.kt b/app/src/main/java/org/oppia/android/app/options/AudioLanguageFragment.kt index 9a095a1f47b..be58e27ec59 100644 --- a/app/src/main/java/org/oppia/android/app/options/AudioLanguageFragment.kt +++ b/app/src/main/java/org/oppia/android/app/options/AudioLanguageFragment.kt @@ -8,6 +8,7 @@ import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableFragment import javax.inject.Inject import org.oppia.android.app.fragment.FragmentComponentImpl +import org.oppia.android.util.extensions.getStringFromBundle private const val AUDIO_LANGUAGE_PREFERENCE_TITLE_ARGUMENT_KEY = "AudioLanguageFragment.audio_language_preference_title" @@ -47,9 +48,9 @@ class AudioLanguageFragment : ): View? { val args = checkNotNull(arguments) { "Expected arguments to be passed to AudioLanguageFragment" } - val prefsKey = args.getString(AUDIO_LANGUAGE_PREFERENCE_TITLE_ARGUMENT_KEY) + val prefsKey = args.getStringFromBundle(AUDIO_LANGUAGE_PREFERENCE_TITLE_ARGUMENT_KEY) val audioLanguageDefaultSummary = checkNotNull( - args.getString(AUDIO_LANGUAGE_PREFERENCE_SUMMARY_VALUE_ARGUMENT_KEY) + args.getStringFromBundle(AUDIO_LANGUAGE_PREFERENCE_SUMMARY_VALUE_ARGUMENT_KEY) ) val prefsSummaryValue = if (savedInstanceState == null) { audioLanguageDefaultSummary diff --git a/app/src/main/java/org/oppia/android/app/options/OptionsActivity.kt b/app/src/main/java/org/oppia/android/app/options/OptionsActivity.kt index 5136ef00a52..d04fb4ec4ed 100644 --- a/app/src/main/java/org/oppia/android/app/options/OptionsActivity.kt +++ b/app/src/main/java/org/oppia/android/app/options/OptionsActivity.kt @@ -9,6 +9,8 @@ import org.oppia.android.app.activity.InjectableAppCompatActivity import org.oppia.android.app.drawer.NAVIGATION_PROFILE_ID_ARGUMENT_KEY import javax.inject.Inject import org.oppia.android.app.activity.ActivityComponentImpl +import org.oppia.android.app.translation.AppLanguageResourceHandler +import org.oppia.android.util.extensions.getStringFromBundle private const val SELECTED_OPTIONS_TITLE_SAVED_KEY = "OptionsActivity.selected_options_title" private const val SELECTED_FRAGMENT_SAVED_KEY = "OptionsActivity.selected_fragment" @@ -28,6 +30,9 @@ class OptionsActivity : @Inject lateinit var optionActivityPresenter: OptionsActivityPresenter + @Inject + lateinit var resourceHandler: AppLanguageResourceHandler + // used to initially load the suitable fragment in the case of multipane. private var isFirstOpen = true private lateinit var selectedFragment: String @@ -64,14 +69,15 @@ class OptionsActivity : } else { savedInstanceState.get(SELECTED_FRAGMENT_SAVED_KEY) as String } - val extraOptionsTitle = savedInstanceState?.getString(SELECTED_OPTIONS_TITLE_SAVED_KEY) + val extraOptionsTitle = + savedInstanceState?.getStringFromBundle(SELECTED_OPTIONS_TITLE_SAVED_KEY) optionActivityPresenter.handleOnCreate( isFromNavigationDrawer, extraOptionsTitle, isFirstOpen, selectedFragment ) - title = getString(R.string.menu_options) + title = resourceHandler.getStringInLocale(R.string.menu_options) } override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { @@ -127,19 +133,25 @@ class OptionsActivity : override fun loadReadingTextSizeFragment(textSize: String) { selectedFragment = READING_TEXT_SIZE_FRAGMENT - optionActivityPresenter.setExtraOptionTitle(getString(R.string.reading_text_size)) + optionActivityPresenter.setExtraOptionTitle( + resourceHandler.getStringInLocale(R.string.reading_text_size) + ) optionActivityPresenter.loadReadingTextSizeFragment(textSize) } override fun loadAppLanguageFragment(appLanguage: String) { selectedFragment = APP_LANGUAGE_FRAGMENT - optionActivityPresenter.setExtraOptionTitle(getString(R.string.app_language)) + optionActivityPresenter.setExtraOptionTitle( + resourceHandler.getStringInLocale(R.string.app_language) + ) optionActivityPresenter.loadAppLanguageFragment(appLanguage) } override fun loadAudioLanguageFragment(audioLanguage: String) { selectedFragment = AUDIO_LANGUAGE_FRAGMENT - optionActivityPresenter.setExtraOptionTitle(getString(R.string.audio_language)) + optionActivityPresenter.setExtraOptionTitle( + resourceHandler.getStringInLocale(R.string.audio_language) + ) optionActivityPresenter.loadAudioLanguageFragment(audioLanguage) } diff --git a/app/src/main/java/org/oppia/android/app/options/OptionsFragment.kt b/app/src/main/java/org/oppia/android/app/options/OptionsFragment.kt index 882b2266f6a..61a1de13b42 100644 --- a/app/src/main/java/org/oppia/android/app/options/OptionsFragment.kt +++ b/app/src/main/java/org/oppia/android/app/options/OptionsFragment.kt @@ -8,6 +8,7 @@ import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableFragment import javax.inject.Inject import org.oppia.android.app.fragment.FragmentComponentImpl +import org.oppia.android.util.extensions.getStringFromBundle const val MESSAGE_READING_TEXT_SIZE_ARGUMENT_KEY = "OptionsFragment.message_reading_text_size" const val MESSAGE_APP_LANGUAGE_ARGUMENT_KEY = "OptionsFragment.message_app_language" @@ -55,7 +56,7 @@ class OptionsFragment : InjectableFragment() { checkNotNull(arguments) { "Expected arguments to be passed to OptionsFragment" } val isMultipane = args.getBoolean(IS_MULTIPANE_EXTRA) val isFirstOpen = args.getBoolean(IS_FIRST_OPEN_EXTRA) - val selectedFragment = checkNotNull(args.getString(SELECTED_FRAGMENT_EXTRA)) + val selectedFragment = checkNotNull(args.getStringFromBundle(SELECTED_FRAGMENT_EXTRA)) return optionsFragmentPresenter.handleCreateView( inflater, container, diff --git a/app/src/main/java/org/oppia/android/app/options/ReadingTextSizeFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/options/ReadingTextSizeFragmentPresenter.kt index 2ce2bd44626..fa11b0ac23b 100644 --- a/app/src/main/java/org/oppia/android/app/options/ReadingTextSizeFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/options/ReadingTextSizeFragmentPresenter.kt @@ -10,13 +10,15 @@ import org.oppia.android.app.recyclerview.BindableAdapter import org.oppia.android.databinding.ReadingTextSizeFragmentBinding import org.oppia.android.databinding.TextSizeItemsBinding import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler /** The presenter for [ReadingTextSizeFragment]. */ class ReadingTextSizeFragmentPresenter @Inject constructor( private val fragment: Fragment, - private val readingTextSizeSelectionViewModel: ReadingTextSizeSelectionViewModel + private val readingTextSizeSelectionViewModel: ReadingTextSizeSelectionViewModel, + resourceHandler: AppLanguageResourceHandler ) { - private var fontSize: String = fragment.requireContext().resources.getString( + private var fontSize: String = resourceHandler.getStringInLocale( R.string.reading_text_size_medium ) diff --git a/app/src/main/java/org/oppia/android/app/options/ReadingTextSizeSelectionViewModel.kt b/app/src/main/java/org/oppia/android/app/options/ReadingTextSizeSelectionViewModel.kt index e387a022170..7eb286fe194 100644 --- a/app/src/main/java/org/oppia/android/app/options/ReadingTextSizeSelectionViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/options/ReadingTextSizeSelectionViewModel.kt @@ -6,11 +6,13 @@ import org.oppia.android.app.fragment.FragmentScope import org.oppia.android.app.model.ReadingTextSize import org.oppia.android.app.viewmodel.ObservableViewModel import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler /** Text Size list view model for the recycler view in [ReadingTextSizeFragment]. */ @FragmentScope class ReadingTextSizeSelectionViewModel @Inject constructor( - fragment: Fragment + fragment: Fragment, + private val resourceHandler: AppLanguageResourceHandler ) : ObservableViewModel() { private val resourceBundle = fragment.requireContext().resources @@ -22,25 +24,29 @@ class ReadingTextSizeSelectionViewModel @Inject constructor( resourceBundle, ReadingTextSize.SMALL_TEXT_SIZE, selectedTextSize, - textSizeRadioButtonListener + textSizeRadioButtonListener, + resourceHandler ), TextSizeItemViewModel( resourceBundle, ReadingTextSize.MEDIUM_TEXT_SIZE, selectedTextSize, - textSizeRadioButtonListener + textSizeRadioButtonListener, + resourceHandler ), TextSizeItemViewModel( resourceBundle, ReadingTextSize.LARGE_TEXT_SIZE, selectedTextSize, - textSizeRadioButtonListener + textSizeRadioButtonListener, + resourceHandler ), TextSizeItemViewModel( resourceBundle, ReadingTextSize.EXTRA_LARGE_TEXT_SIZE, selectedTextSize, - textSizeRadioButtonListener + textSizeRadioButtonListener, + resourceHandler ), ) diff --git a/app/src/main/java/org/oppia/android/app/options/TextSizeItemViewModel.kt b/app/src/main/java/org/oppia/android/app/options/TextSizeItemViewModel.kt index f97740e4705..a8e240609c8 100644 --- a/app/src/main/java/org/oppia/android/app/options/TextSizeItemViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/options/TextSizeItemViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.LiveData import androidx.lifecycle.Transformations import org.oppia.android.R import org.oppia.android.app.model.ReadingTextSize +import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.app.viewmodel.ObservableViewModel private const val SMALL_TEXT_SIZE_SCALE = 0.8f @@ -13,21 +14,25 @@ private const val LARGE_TEXT_SIZE_SCALE = 1.2f private const val EXTRA_LARGE_TEXT_SIZE_SCALE = 1.4f /** Text Size item view model for the recycler view in [ReadingTextSizeFragment]. */ -class TextSizeItemViewModel constructor( +class TextSizeItemViewModel( val resources: Resources, val readingTextSize: ReadingTextSize, private val selectedTextSize: LiveData, - val textSizeRadioButtonListener: TextSizeRadioButtonListener + val textSizeRadioButtonListener: TextSizeRadioButtonListener, + private val resourceHandler: AppLanguageResourceHandler ) : ObservableViewModel() { private val defaultReadingTextSizeInFloat by lazy { resources.getDimension(R.dimen.default_reading_text_size) } val textSizeName: String by lazy { when (readingTextSize) { - ReadingTextSize.SMALL_TEXT_SIZE -> resources.getString(R.string.reading_text_size_small) - ReadingTextSize.MEDIUM_TEXT_SIZE -> resources.getString(R.string.reading_text_size_medium) - ReadingTextSize.LARGE_TEXT_SIZE -> resources.getString(R.string.reading_text_size_large) - else -> resources.getString(R.string.reading_text_size_extra_large) + ReadingTextSize.SMALL_TEXT_SIZE -> + resourceHandler.getStringInLocale(R.string.reading_text_size_small) + ReadingTextSize.MEDIUM_TEXT_SIZE -> + resourceHandler.getStringInLocale(R.string.reading_text_size_medium) + ReadingTextSize.LARGE_TEXT_SIZE -> + resourceHandler.getStringInLocale(R.string.reading_text_size_large) + else -> resourceHandler.getStringInLocale(R.string.reading_text_size_extra_large) } } val textSize: Float by lazy { diff --git a/app/src/main/java/org/oppia/android/app/parser/StringToFractionParser.kt b/app/src/main/java/org/oppia/android/app/parser/StringToFractionParser.kt index d6d2bab22a5..62fc2e9a282 100644 --- a/app/src/main/java/org/oppia/android/app/parser/StringToFractionParser.kt +++ b/app/src/main/java/org/oppia/android/app/parser/StringToFractionParser.kt @@ -1,9 +1,9 @@ package org.oppia.android.app.parser -import android.content.Context import androidx.annotation.StringRes import org.oppia.android.R import org.oppia.android.app.model.Fraction +import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.domain.util.normalizeWhitespace /** This class contains method that helps to parse string to fraction. */ @@ -20,11 +20,11 @@ class StringToFractionParser { /** * Returns a [FractionParsingError] for the specified text input if it's an invalid fraction, or - * [FractionParsingError.VALID] if no issues are found. Note that a valid fraction returned by this method is guaranteed - * to be parsed correctly by [parseRegularFraction]. + * [FractionParsingError.VALID] if no issues are found. Note that a valid fraction returned by + * this method is guaranteed to be parsed correctly by [parseRegularFraction]. * - * This method should only be used when a user tries submitting an answer. Real-time error detection should be done - * using [getRealTimeAnswerError], instead. + * This method should only be used when a user tries submitting an answer. Real-time error + * detection should be done using [getRealTimeAnswerError], instead. */ fun getSubmitTimeError(text: String): FractionParsingError { if (invalidCharsLengthRegex.find(text) != null) @@ -38,12 +38,12 @@ class StringToFractionParser { } /** - * Returns a [FractionParsingError] for obvious incorrect fraction formatting issues for the specified raw text, or - * [FractionParsingError.VALID] if not such issues are found. + * Returns a [FractionParsingError] for obvious incorrect fraction formatting issues for the + * specified raw text, or [FractionParsingError.VALID] if not such issues are found. * - * Note that this method returning a valid result does not guarantee the text is a valid fraction-- - * [getSubmitTimeError] should be used for that, instead. This method is meant to be used as a quick sanity check for - * general validity, not for definite correctness. + * Note that this method returning a valid result does not guarantee the text is a valid + * fraction--[getSubmitTimeError] should be used for that, instead. This method is meant to be + * used as a quick sanity check for general validity, not for definite correctness. */ fun getRealTimeAnswerError(text: String): FractionParsingError { val normalized = text.normalizeWhitespace() @@ -116,9 +116,10 @@ class StringToFractionParser { DIVISION_BY_ZERO(error = R.string.fraction_error_divide_by_zero), NUMBER_TOO_LONG(error = R.string.fraction_error_larger_than_seven_digits); - /** Returns the string corresponding to this error's string resources, or null if there is none. */ - fun getErrorMessageFromStringRes(context: Context): String? { - return error?.let(context::getString) - } + /** + * Returns the string corresponding to this error's string resources, or null if there is none. + */ + fun getErrorMessageFromStringRes(resourceHandler: AppLanguageResourceHandler): String? = + error?.let(resourceHandler::getStringInLocale) } } diff --git a/app/src/main/java/org/oppia/android/app/parser/StringToNumberParser.kt b/app/src/main/java/org/oppia/android/app/parser/StringToNumberParser.kt index 6215dec4861..ddf4eecfda0 100644 --- a/app/src/main/java/org/oppia/android/app/parser/StringToNumberParser.kt +++ b/app/src/main/java/org/oppia/android/app/parser/StringToNumberParser.kt @@ -3,20 +3,24 @@ package org.oppia.android.app.parser import android.content.Context import androidx.annotation.StringRes import org.oppia.android.R +import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.domain.util.normalizeWhitespace -/** This class contains methods that help to parse string to number, check realtime and submit time errors. */ +/** + * This class contains methods that help to parse string to number, check realtime and submit time + * errors. + */ class StringToNumberParser { private val validCharsRegex = """^[\d\s.-]+$""".toRegex() /** - * Returns a [NumericInputParsingError] for obvious incorrect number formatting issues for the specified raw text, or - * [NumericInputParsingError.VALID] if not such issues are found. + * Returns a [NumericInputParsingError] for obvious incorrect number formatting issues for the + * specified raw text, or [NumericInputParsingError.VALID] if not such issues are found. * * Note that this method returning a valid result does not guarantee the text is a valid number - * [getSubmitTimeError] should be used for that, instead. This method is meant to be used as a quick sanity check for - * general validity, not for definite correctness. + * [getSubmitTimeError] should be used for that, instead. This method is meant to be used as a + * quick sanity check for general validity, not for definite correctness. */ fun getRealTimeAnswerError(text: String): NumericInputParsingError { val normalized = text.normalizeWhitespace() @@ -31,11 +35,11 @@ class StringToNumberParser { /** * Returns a [NumericInputParsingError] for the specified text input if it's an invalid number, or - * [NumericInputParsingError.VALID] if no issues are found. Note that a valid number returned by this method is guaranteed - * to be parsed correctly. + * [NumericInputParsingError.VALID] if no issues are found. Note that a valid number returned by + * this method is guaranteed to be parsed correctly. * - * This method should only be used when a user tries submitting an answer. Real-time error detection should be done - * using [getRealTimeAnswerError], instead. + * This method should only be used when a user tries submitting an answer. Real-time error + * detection should be done using [getRealTimeAnswerError], instead. */ fun getSubmitTimeError(text: String): NumericInputParsingError { if (text.length > 15) { @@ -56,9 +60,10 @@ class StringToNumberParser { STARTING_WITH_FLOATING_POINT(error = R.string.number_error_starting_with_floating_point), NUMBER_TOO_LONG(error = R.string.number_error_larger_than_fifteen_characters); - /** Returns the string corresponding to this error's string resources, or null if there is none. */ - fun getErrorMessageFromStringRes(context: Context): String? { - return error?.let(context::getString) - } + /** + * Returns the string corresponding to this error's string resources, or null if there is none. + */ + fun getErrorMessageFromStringRes(resourceHandler: AppLanguageResourceHandler): String? = + error?.let(resourceHandler::getStringInLocale) } } diff --git a/app/src/main/java/org/oppia/android/app/parser/StringToRatioParser.kt b/app/src/main/java/org/oppia/android/app/parser/StringToRatioParser.kt index 8da5cba2442..9691f639c8d 100644 --- a/app/src/main/java/org/oppia/android/app/parser/StringToRatioParser.kt +++ b/app/src/main/java/org/oppia/android/app/parser/StringToRatioParser.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.annotation.StringRes import org.oppia.android.R import org.oppia.android.app.model.RatioExpression +import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.domain.util.normalizeWhitespace import org.oppia.android.domain.util.removeWhitespace @@ -19,11 +20,11 @@ class StringToRatioParser { /** * Returns a [RatioParsingError] for the specified text input if it's an invalid ratio, or - * [RatioParsingError.VALID] if no issues are found. Note that a valid ratio returned by this method is guaranteed - * to be parsed correctly by [parseRatioOrNull]. + * [RatioParsingError.VALID] if no issues are found. Note that a valid ratio returned by this + * method is guaranteed to be parsed correctly by [parseRatioOrNull]. * - * This method should only be used when a user tries submitting an answer. Real-time error detection should be done - * using [getRealTimeAnswerError], instead. + * This method should only be used when a user tries submitting an answer. Real-time error + * detection should be done using [getRealTimeAnswerError], instead. */ fun getSubmitTimeError(text: String, numberOfTerms: Int): RatioParsingError { val normalized = text.normalizeWhitespace() @@ -39,12 +40,12 @@ class StringToRatioParser { } /** - * Returns a [RatioParsingError] for obvious incorrect ratio formatting issues for the specified raw text, or - * [RatioParsingError.VALID] if not such issues are found. + * Returns a [RatioParsingError] for obvious incorrect ratio formatting issues for the specified + * raw text, or [RatioParsingError.VALID] if not such issues are found. * * Note that this method returning a valid result does not guarantee the text is a valid ratio-- - * [getSubmitTimeError] should be used for that, instead. This method is meant to be used as a quick sanity check for - * general validity, not for definite correctness. + * [getSubmitTimeError] should be used for that, instead. This method is meant to be used as a + * quick sanity check for general validity, not for definite correctness. */ fun getRealTimeAnswerError(text: String): RatioParsingError { return when { @@ -79,9 +80,10 @@ class StringToRatioParser { INVALID_SIZE(error = R.string.ratio_error_invalid_size), INCLUDES_ZERO(error = R.string.ratio_error_includes_zero); - /** Returns the string corresponding to this error's string resources, or null if there is none. */ - fun getErrorMessageFromStringRes(context: Context): String? { - return error?.let(context::getString) - } + /** + * Returns the string corresponding to this error's string resources, or null if there is none. + */ + fun getErrorMessageFromStringRes(resourceHandler: AppLanguageResourceHandler): String? = + error?.let(resourceHandler::getStringInLocale) } } diff --git a/app/src/main/java/org/oppia/android/app/player/audio/AudioFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/player/audio/AudioFragmentPresenter.kt index 3d72a3d5e1f..6dbefe58749 100755 --- a/app/src/main/java/org/oppia/android/app/player/audio/AudioFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/player/audio/AudioFragmentPresenter.kt @@ -30,6 +30,7 @@ import org.oppia.android.util.data.AsyncResult import org.oppia.android.util.data.DataProviders.Companion.toLiveData import org.oppia.android.util.networking.NetworkConnectionUtil import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler const val TAG_LANGUAGE_DIALOG = "LANGUAGE_DIALOG" private const val TAG_CELLULAR_DATA_DIALOG = "CELLULAR_DATA_DIALOG" @@ -45,7 +46,8 @@ class AudioFragmentPresenter @Inject constructor( private val profileManagementController: ProfileManagementController, private val networkConnectionUtil: NetworkConnectionUtil, private val viewModelProvider: ViewModelProvider, - private val oppiaLogger: OppiaLogger + private val oppiaLogger: OppiaLogger, + private val resourceHandler: AppLanguageResourceHandler ) { var userIsSeeking = false var userProgress = 0 @@ -282,9 +284,11 @@ class AudioFragmentPresenter @Inject constructor( private fun showOfflineDialog() { AlertDialog.Builder(activity, R.style.AlertDialogTheme) - .setTitle(context.getString(R.string.audio_dialog_offline_title)) - .setMessage(context.getString(R.string.audio_dialog_offline_message)) - .setPositiveButton(context.getString(R.string.audio_dialog_offline_positive)) { dialog, _ -> + .setTitle(resourceHandler.getStringInLocale(R.string.audio_dialog_offline_title)) + .setMessage(resourceHandler.getStringInLocale(R.string.audio_dialog_offline_message)) + .setPositiveButton( + resourceHandler.getStringInLocale(R.string.audio_dialog_offline_positive) + ) { dialog, _ -> dialog.dismiss() }.create().show() } diff --git a/app/src/main/java/org/oppia/android/app/player/audio/AudioViewModel.kt b/app/src/main/java/org/oppia/android/app/player/audio/AudioViewModel.kt index 0eeaf2ee24f..37c24a3d147 100644 --- a/app/src/main/java/org/oppia/android/app/player/audio/AudioViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/audio/AudioViewModel.kt @@ -1,7 +1,6 @@ package org.oppia.android.app.player.audio import androidx.databinding.ObservableField -import androidx.fragment.app.Fragment import androidx.lifecycle.LiveData import androidx.lifecycle.Transformations import org.oppia.android.app.fragment.FragmentScope @@ -14,15 +13,15 @@ import org.oppia.android.domain.audio.AudioPlayerController.PlayProgress import org.oppia.android.domain.audio.AudioPlayerController.PlayStatus import org.oppia.android.util.data.AsyncResult import org.oppia.android.util.gcsresource.DefaultResourceBucketName -import java.util.Locale import javax.inject.Inject +import org.oppia.android.util.locale.OppiaLocale /** [ObservableViewModel] for audio-player state. */ @FragmentScope class AudioViewModel @Inject constructor( private val audioPlayerController: AudioPlayerController, - private val fragment: Fragment, - @DefaultResourceBucketName private val gcsResource: String + @DefaultResourceBucketName private val gcsResource: String, + private val machineLocale: OppiaLocale.MachineLocale ) : ObservableViewModel() { private lateinit var state: State @@ -85,7 +84,7 @@ class AudioViewModel @Inject constructor( state.recordedVoiceoversMap[contentId ?: state.content.contentId] ?: VoiceoverMapping.getDefaultInstance() ).voiceoverMappingMap - languages = voiceoverMap.keys.toList().map { it.toLowerCase(Locale.getDefault()) } + languages = voiceoverMap.keys.toList().map { machineLocale.run { it.toMachineLowerCase() } } when { selectedLanguageCode.isEmpty() && languages.any { it == defaultLanguage diff --git a/app/src/main/java/org/oppia/android/app/player/audio/CellularAudioDialogFragment.kt b/app/src/main/java/org/oppia/android/app/player/audio/CellularAudioDialogFragment.kt index 44b3f54d133..8f156354ffb 100755 --- a/app/src/main/java/org/oppia/android/app/player/audio/CellularAudioDialogFragment.kt +++ b/app/src/main/java/org/oppia/android/app/player/audio/CellularAudioDialogFragment.kt @@ -9,11 +9,13 @@ import androidx.appcompat.app.AlertDialog import androidx.appcompat.view.ContextThemeWrapper import androidx.fragment.app.DialogFragment import org.oppia.android.R +import org.oppia.android.app.fragment.FragmentComponentImpl +import org.oppia.android.app.fragment.InjectableDialogFragment /** * DialogFragment that indicates to the user they are on cellular when trying to play an audio voiceover. */ -class CellularAudioDialogFragment : DialogFragment() { +class CellularAudioDialogFragment : InjectableDialogFragment() { companion object { /** * This function is responsible for displaying content in DialogFragment. @@ -25,6 +27,11 @@ class CellularAudioDialogFragment : DialogFragment() { } } + override fun onAttach(context: Context) { + super.onAttach(context) + (fragmentComponent as FragmentComponentImpl).inject(this) + } + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val view = View.inflate(context, R.layout.cellular_data_dialog, /* root= */ null) val checkBox = view.findViewById(R.id.cellular_data_dialog_checkbox) diff --git a/app/src/main/java/org/oppia/android/app/player/audio/LanguageDialogFragment.kt b/app/src/main/java/org/oppia/android/app/player/audio/LanguageDialogFragment.kt index a9951af00af..172c0978f9e 100644 --- a/app/src/main/java/org/oppia/android/app/player/audio/LanguageDialogFragment.kt +++ b/app/src/main/java/org/oppia/android/app/player/audio/LanguageDialogFragment.kt @@ -9,6 +9,8 @@ import androidx.fragment.app.DialogFragment import org.oppia.android.R import java.util.Locale import kotlin.collections.ArrayList +import org.oppia.android.app.fragment.FragmentComponentImpl +import org.oppia.android.app.fragment.InjectableDialogFragment private const val LANGUAGE_LIST_ARGUMENT_KEY = "LanguageDialogFragment.language_list" private const val SELECTED_INDEX_ARGUMENT_KEY = "LanguageDialogFragment.selected_index" @@ -16,7 +18,7 @@ private const val SELECTED_INDEX_ARGUMENT_KEY = "LanguageDialogFragment.selected /** * DialogFragment that controls language selection in audio and written translations. */ -class LanguageDialogFragment : DialogFragment() { +class LanguageDialogFragment : InjectableDialogFragment() { companion object { /** * This function is responsible for displaying content in DialogFragment. @@ -39,8 +41,12 @@ class LanguageDialogFragment : DialogFragment() { } } + override fun onAttach(context: Context) { + super.onAttach(context) + (fragmentComponent as FragmentComponentImpl).inject(this) + } + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val args = checkNotNull(arguments) { "Expected arguments to be pass to LanguageDialogFragment" } var selectedIndex = args.getInt(SELECTED_INDEX_ARGUMENT_KEY, 0) diff --git a/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationFragment.kt b/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationFragment.kt index eee860760d5..84e626ef8e7 100755 --- a/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationFragment.kt +++ b/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationFragment.kt @@ -9,6 +9,7 @@ import org.oppia.android.app.fragment.InjectableFragment import org.oppia.android.app.utility.FontScaleConfigurationUtil import javax.inject.Inject import org.oppia.android.app.fragment.FragmentComponentImpl +import org.oppia.android.util.extensions.getStringFromBundle /** Fragment that contains displays single exploration. */ class ExplorationFragment : InjectableFragment() { @@ -61,7 +62,7 @@ class ExplorationFragment : InjectableFragment() { super.onAttach(context) (fragmentComponent as FragmentComponentImpl).inject(this) val readingTextSize = - arguments!!.getString(STORY_DEFAULT_FONT_SIZE_ARGUMENT_KEY) + arguments!!.getStringFromBundle(STORY_DEFAULT_FONT_SIZE_ARGUMENT_KEY) checkNotNull(readingTextSize) { "ExplorationFragment must be created with a reading text size" } fontScaleConfigurationUtil.adjustFontScale(context, readingTextSize) } @@ -74,13 +75,13 @@ class ExplorationFragment : InjectableFragment() { val profileId = arguments!!.getInt(INTERNAL_PROFILE_ID_ARGUMENT_KEY, -1) val topicId = - arguments!!.getString(TOPIC_ID_ARGUMENT_KEY) + arguments!!.getStringFromBundle(TOPIC_ID_ARGUMENT_KEY) checkNotNull(topicId) { "StateFragment must be created with an topic ID" } val storyId = - arguments!!.getString(STORY_ID_ARGUMENT_KEY) + arguments!!.getStringFromBundle(STORY_ID_ARGUMENT_KEY) checkNotNull(storyId) { "StateFragment must be created with an story ID" } val explorationId = - arguments!!.getString(EXPLORATION_ID_ARGUMENT_KEY) + arguments!!.getStringFromBundle(EXPLORATION_ID_ARGUMENT_KEY) checkNotNull(explorationId) { "StateFragment must be created with an exploration ID" } return explorationFragmentPresenter.handleCreateView( inflater, diff --git a/app/src/main/java/org/oppia/android/app/player/state/ImageRegionSelectionInteractionView.kt b/app/src/main/java/org/oppia/android/app/player/state/ImageRegionSelectionInteractionView.kt index d0883b9b75c..3b75d855f15 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/ImageRegionSelectionInteractionView.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/ImageRegionSelectionInteractionView.kt @@ -22,6 +22,7 @@ import org.oppia.android.util.parser.image.ImageLoader import org.oppia.android.util.parser.image.ImageViewTarget import javax.inject.Inject import org.oppia.android.app.view.ViewComponentImpl +import org.oppia.android.util.locale.OppiaLocale /** * A custom [AppCompatImageView] with a list of [LabeledRegion] to work with @@ -66,6 +67,9 @@ class ImageRegionSelectionInteractionView @JvmOverloads constructor( @Inject lateinit var bindingInterface: ViewBindingShim + @Inject + lateinit var machineLocale: OppiaLocale.MachineLocale + private lateinit var entityId: String private lateinit var overlayView: FrameLayout private lateinit var onRegionClicked: OnClickableAreaClickedListener @@ -78,11 +82,13 @@ class ImageRegionSelectionInteractionView @JvmOverloads constructor( loadImage() } - /** loads an image using Glide from [urlString]. */ + /** Initiates the asynchronous loading process for the interaction's image region. */ private fun loadImage() { - val imageName = String.format(imageDownloadUrlTemplate, entityType, entityId, imageUrl) + val imageName = machineLocale.run { + imageDownloadUrlTemplate.formatForMachines(entityType, entityId, imageUrl) + } val imageUrl = "$gcsPrefix/$resourceBucketName/$imageName" - if (imageUrl.endsWith("svg", ignoreCase = true)) { + if (machineLocale.run { imageUrl.endsWithIgnoreCase("svg") }) { imageLoader.loadBlockSvg(imageUrl, ImageViewTarget(this)) } else { imageLoader.loadBitmap(imageUrl, ImageViewTarget(this)) diff --git a/app/src/main/java/org/oppia/android/app/player/state/StateFragment.kt b/app/src/main/java/org/oppia/android/app/player/state/StateFragment.kt index 4488ec806ce..476acebdd24 100755 --- a/app/src/main/java/org/oppia/android/app/player/state/StateFragment.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/StateFragment.kt @@ -20,6 +20,7 @@ import org.oppia.android.app.player.state.listener.ShowHintAvailabilityListener import org.oppia.android.app.player.state.listener.SubmitNavigationButtonListener import javax.inject.Inject import org.oppia.android.app.fragment.FragmentComponentImpl +import org.oppia.android.util.extensions.getStringFromBundle /** Fragment that represents the current state of an exploration. */ class StateFragment : @@ -74,9 +75,10 @@ class StateFragment : savedInstanceState: Bundle? ): View? { val internalProfileId = arguments!!.getInt(STATE_FRAGMENT_PROFILE_ID_ARGUMENT_KEY, -1) - val topicId = arguments!!.getString(STATE_FRAGMENT_TOPIC_ID_ARGUMENT_KEY)!! - val storyId = arguments!!.getString(STATE_FRAGMENT_STORY_ID_ARGUMENT_KEY)!! - val explorationId = arguments!!.getString(STATE_FRAGMENT_EXPLORATION_ID_ARGUMENT_KEY)!! + val topicId = arguments!!.getStringFromBundle(STATE_FRAGMENT_TOPIC_ID_ARGUMENT_KEY)!! + val storyId = arguments!!.getStringFromBundle(STATE_FRAGMENT_STORY_ID_ARGUMENT_KEY)!! + val explorationId = + arguments!!.getStringFromBundle(STATE_FRAGMENT_EXPLORATION_ID_ARGUMENT_KEY)!! return stateFragmentPresenter.handleCreateView( inflater, container, diff --git a/app/src/main/java/org/oppia/android/app/player/state/StatePlayerRecyclerViewAssembler.kt b/app/src/main/java/org/oppia/android/app/player/state/StatePlayerRecyclerViewAssembler.kt index b0183fc96c0..d7db45f3be0 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/StatePlayerRecyclerViewAssembler.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/StatePlayerRecyclerViewAssembler.kt @@ -86,6 +86,7 @@ import org.oppia.android.databinding.TextInputInteractionItemBinding import org.oppia.android.util.parser.html.HtmlParser import org.oppia.android.util.threading.BackgroundDispatcher import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler private typealias AudioUiManagerRetriever = () -> AudioUiManager? @@ -133,7 +134,8 @@ class StatePlayerRecyclerViewAssembler private constructor( private val interactionViewModelFactoryMap: Map< String, @JvmSuppressWildcards InteractionViewModelFactory>, backgroundCoroutineDispatcher: CoroutineDispatcher, - private val hasConversationView: Boolean + private val hasConversationView: Boolean, + private val resourceHandler: AppLanguageResourceHandler ) : HtmlParser.CustomOppiaTagActionListener { /** * A list of view models corresponding to past view models that are hidden by default. These are @@ -336,7 +338,8 @@ class StatePlayerRecyclerViewAssembler private constructor( hasConversationView, ObservableBoolean(hasPreviousResponsesExpanded), fragment as PreviousResponsesHeaderClickListener, - isSplitView.get()!! + isSplitView.get()!!, + resourceHandler ).let { viewModel -> pendingItemList += viewModel previousAnswerViewModels += viewModel @@ -526,9 +529,10 @@ class StatePlayerRecyclerViewAssembler private constructor( gcsEntityId, hasConversationView, isSplitView.get()!!, - playerFeatureSet.conceptCardSupport + playerFeatureSet.conceptCardSupport, + resourceHandler ) - submittedAnswerViewModel.isCorrectAnswer.set(isAnswerCorrect) + submittedAnswerViewModel.setIsCorrectAnswer(isAnswerCorrect) submittedAnswerViewModel.isExtraInteractionAnswerCorrect.set(isAnswerCorrect) return submittedAnswerViewModel } @@ -847,7 +851,8 @@ class StatePlayerRecyclerViewAssembler private constructor( private val fragment: Fragment, private val context: Context, private val interactionViewModelFactoryMap: Map, - private val backgroundCoroutineDispatcher: CoroutineDispatcher + private val backgroundCoroutineDispatcher: CoroutineDispatcher, + private val resourceHandler: AppLanguageResourceHandler ) { private val adapterBuilder = BindableAdapter.MultiTypeBuilder.newBuilder( StateItemViewModel::viewType @@ -1023,10 +1028,12 @@ class StatePlayerRecyclerViewAssembler private constructor( imageCenterAlign = false, customOppiaTagActionListener = customTagListener ) - binding.submittedAnswer = htmlParser.parseOppiaHtml( - userAnswer.htmlAnswer, - binding.submittedAnswerTextView, - supportsConceptCards = submittedAnswerViewModel.supportsConceptCards + submittedAnswerViewModel.setSubmittedAnswer( + htmlParser.parseOppiaHtml( + userAnswer.htmlAnswer, + binding.submittedAnswerTextView, + supportsConceptCards = submittedAnswerViewModel.supportsConceptCards + ) ) } UserAnswer.TextualAnswerCase.LIST_OF_HTML_ANSWERS -> { @@ -1040,8 +1047,9 @@ class StatePlayerRecyclerViewAssembler private constructor( } else -> { showSingleAnswer(binding) - binding.submittedAnswer = userAnswer.plainAnswer - binding.accessibleAnswer = userAnswer.contentDescription + submittedAnswerViewModel.setSubmittedAnswer( + userAnswer.plainAnswer, accessibleAnswer = userAnswer.contentDescription + ) } } } @@ -1305,7 +1313,8 @@ class StatePlayerRecyclerViewAssembler private constructor( audioUiManagerRetriever, interactionViewModelFactoryMap, backgroundCoroutineDispatcher, - hasConversationView + hasConversationView, + resourceHandler ) if (playerFeatureSet.conceptCardSupport) { customTagListener.proxyListener = assembler @@ -1320,7 +1329,8 @@ class StatePlayerRecyclerViewAssembler private constructor( private val context: Context, private val interactionViewModelFactoryMap: Map< String, @JvmSuppressWildcards InteractionViewModelFactory>, - @BackgroundDispatcher private val backgroundCoroutineDispatcher: CoroutineDispatcher + @BackgroundDispatcher private val backgroundCoroutineDispatcher: CoroutineDispatcher, + private val resourceHandler: AppLanguageResourceHandler ) { /** * Returns a new [Builder] for the specified GCS resource bucket information for loading @@ -1334,7 +1344,8 @@ class StatePlayerRecyclerViewAssembler private constructor( fragment, context, interactionViewModelFactoryMap, - backgroundCoroutineDispatcher + backgroundCoroutineDispatcher, + resourceHandler ) } } diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/DragAndDropSortInteractionViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/DragAndDropSortInteractionViewModel.kt index d52e45442b1..fd8cb16eec7 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/DragAndDropSortInteractionViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/DragAndDropSortInteractionViewModel.kt @@ -17,6 +17,7 @@ import org.oppia.android.app.player.state.answerhandling.InteractionAnswerHandle import org.oppia.android.app.recyclerview.BindableAdapter import org.oppia.android.app.recyclerview.OnDragEndedListener import org.oppia.android.app.recyclerview.OnItemDragListener +import org.oppia.android.app.translation.AppLanguageResourceHandler /** [StateItemViewModel] for drag drop & sort choice list. */ class DragAndDropSortInteractionViewModel( @@ -24,7 +25,8 @@ class DragAndDropSortInteractionViewModel( val hasConversationView: Boolean, interaction: Interaction, private val interactionAnswerErrorOrAvailabilityCheckReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, // ktlint-disable max-line-length - val isSplitView: Boolean + val isSplitView: Boolean, + private val resourceHandler: AppLanguageResourceHandler ) : StateItemViewModel(ViewType.DRAG_DROP_SORT_INTERACTION), InteractionAnswerHandler, OnItemDragListener, @@ -46,7 +48,7 @@ class DragAndDropSortInteractionViewModel( } private val _choiceItems: MutableList = - computeChoiceItems(contentIdHtmlMap, choiceSubtitledHtmls, this) + computeChoiceItems(contentIdHtmlMap, choiceSubtitledHtmls, this, resourceHandler) val choiceItems: List = _choiceItems @@ -168,7 +170,8 @@ class DragAndDropSortInteractionViewModel( }.build(), itemIndex = 0, listSize = 0, - dragAndDropSortInteractionViewModel = this + dragAndDropSortInteractionViewModel = this, + resourceHandler = resourceHandler ) ) } @@ -185,7 +188,8 @@ class DragAndDropSortInteractionViewModel( private fun computeChoiceItems( contentIdHtmlMap: Map, choiceStrings: List, - dragAndDropSortInteractionViewModel: DragAndDropSortInteractionViewModel + dragAndDropSortInteractionViewModel: DragAndDropSortInteractionViewModel, + resourceHandler: AppLanguageResourceHandler ): MutableList { return choiceStrings.mapIndexed { index, subtitledHtml -> DragDropInteractionContentViewModel( @@ -199,7 +203,8 @@ class DragAndDropSortInteractionViewModel( }.build(), itemIndex = index, listSize = choiceStrings.size, - dragAndDropSortInteractionViewModel = dragAndDropSortInteractionViewModel + dragAndDropSortInteractionViewModel = dragAndDropSortInteractionViewModel, + resourceHandler = resourceHandler ) }.toMutableList() } diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/DragDropInteractionContentViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/DragDropInteractionContentViewModel.kt index babfea05b60..d8c86043ef8 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/DragDropInteractionContentViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/DragDropInteractionContentViewModel.kt @@ -1,8 +1,10 @@ package org.oppia.android.app.player.state.itemviewmodel import androidx.recyclerview.widget.RecyclerView +import org.oppia.android.R import org.oppia.android.app.model.SetOfTranslatableHtmlContentIds import org.oppia.android.app.model.StringList +import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.app.viewmodel.ObservableViewModel /** [ObservableViewModel] for DragDropSortInput values. */ @@ -11,7 +13,8 @@ class DragDropInteractionContentViewModel( var htmlContent: SetOfTranslatableHtmlContentIds, var itemIndex: Int, var listSize: Int, - val dragAndDropSortInteractionViewModel: DragAndDropSortInteractionViewModel + val dragAndDropSortInteractionViewModel: DragAndDropSortInteractionViewModel, + private val resourceHandler: AppLanguageResourceHandler ) : ObservableViewModel() { fun handleGrouping(adapter: RecyclerView.Adapter) { @@ -37,4 +40,24 @@ class DragDropInteractionContentViewModel( fun computeStringList(): StringList = StringList.newBuilder().apply { addAllHtml(htmlContent.contentIdsList.mapNotNull { contentIdHtmlMap[it.contentId] }) }.build() + + fun computeDragDropMoveUpItemContentDescription(): String { + return if (itemIndex != 0) { + resourceHandler.getStringInLocale(R.string.move_item_up_content_description, itemIndex) + } else resourceHandler.getStringInLocale(R.string.up_button_disabled) + } + + fun computeDragDropMoveDownItemContentDescription(): String { + return if (itemIndex != listSize - 1) { + resourceHandler.getStringInLocale(R.string.move_item_down_content_description, itemIndex + 2) + } else resourceHandler.getStringInLocale(R.string.down_button_disabled) + } + + fun computeDragDropGroupItemContentDescription(): String { + return resourceHandler.getStringInLocale(R.string.link_to_item_below, itemIndex + 2) + } + + fun computeDragDropUnlinkItemContentDescription(): String { + return resourceHandler.getStringInLocale(R.string.unlink_items, itemIndex + 1) + } } diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/FractionInteractionViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/FractionInteractionViewModel.kt index c142dc3cb3e..aae7fe57621 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/FractionInteractionViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/FractionInteractionViewModel.kt @@ -1,6 +1,5 @@ package org.oppia.android.app.player.state.itemviewmodel -import android.content.Context import android.text.Editable import android.text.TextWatcher import androidx.databinding.Observable @@ -13,14 +12,15 @@ import org.oppia.android.app.parser.StringToFractionParser import org.oppia.android.app.player.state.answerhandling.AnswerErrorCategory import org.oppia.android.app.player.state.answerhandling.InteractionAnswerErrorOrAvailabilityCheckReceiver import org.oppia.android.app.player.state.answerhandling.InteractionAnswerHandler +import org.oppia.android.app.translation.AppLanguageResourceHandler /** [StateItemViewModel] for the fraction input interaction. */ class FractionInteractionViewModel( interaction: Interaction, - private val context: Context, val hasConversationView: Boolean, val isSplitView: Boolean, - private val interactionAnswerErrorOrAvailabilityCheckReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver // ktlint-disable max-line-length + private val errorOrAvailabilityCheckReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, + private val resourceHandler: AppLanguageResourceHandler ) : StateItemViewModel(ViewType.FRACTION_INPUT_INTERACTION), InteractionAnswerHandler { private var pendingAnswerError: String? = null var answerText: CharSequence = "" @@ -34,7 +34,7 @@ class FractionInteractionViewModel( val callback: Observable.OnPropertyChangedCallback = object : Observable.OnPropertyChangedCallback() { override fun onPropertyChanged(sender: Observable, propertyId: Int) { - interactionAnswerErrorOrAvailabilityCheckReceiver.onPendingAnswerErrorOrAvailabilityCheck( + errorOrAvailabilityCheckReceiver.onPendingAnswerErrorOrAvailabilityCheck( pendingAnswerError, answerText.isNotEmpty() ) @@ -63,15 +63,11 @@ class FractionInteractionViewModel( AnswerErrorCategory.REAL_TIME -> pendingAnswerError = stringToFractionParser.getRealTimeAnswerError(answerText.toString()) - .getErrorMessageFromStringRes( - context - ) + .getErrorMessageFromStringRes(resourceHandler) AnswerErrorCategory.SUBMIT_TIME -> pendingAnswerError = stringToFractionParser.getSubmitTimeError(answerText.toString()) - .getErrorMessageFromStringRes( - context - ) + .getErrorMessageFromStringRes(resourceHandler) } errorMessage.set(pendingAnswerError) } @@ -104,8 +100,9 @@ class FractionInteractionViewModel( interaction.customizationArgsMap["allowNonzeroIntegerPart"]?.boolValue ?: true return when { customPlaceholder.isNotEmpty() -> customPlaceholder - !allowNonzeroIntegerPart -> context.getString(R.string.fractions_default_hint_text_no_integer) - else -> context.getString(R.string.fractions_default_hint_text) + !allowNonzeroIntegerPart -> + resourceHandler.getStringInLocale(R.string.fractions_default_hint_text_no_integer) + else -> resourceHandler.getStringInLocale(R.string.fractions_default_hint_text) } } } diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/ImageRegionSelectionInteractionViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/ImageRegionSelectionInteractionViewModel.kt index 10af3b17dae..e368def3377 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/ImageRegionSelectionInteractionViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/ImageRegionSelectionInteractionViewModel.kt @@ -11,6 +11,7 @@ import org.oppia.android.app.model.InteractionObject import org.oppia.android.app.model.UserAnswer import org.oppia.android.app.player.state.answerhandling.InteractionAnswerErrorOrAvailabilityCheckReceiver import org.oppia.android.app.player.state.answerhandling.InteractionAnswerHandler +import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.app.utility.DefaultRegionClickedEvent import org.oppia.android.app.utility.NamedRegionClickedEvent import org.oppia.android.app.utility.OnClickableAreaClickedListener @@ -23,7 +24,7 @@ class ImageRegionSelectionInteractionViewModel( interaction: Interaction, private val errorOrAvailabilityCheckReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, val isSplitView: Boolean, - val context: Context + private val resourceHandler: AppLanguageResourceHandler ) : StateItemViewModel(ViewType.IMAGE_REGION_SELECTION_INTERACTION), InteractionAnswerHandler, OnClickableAreaClickedListener { @@ -71,7 +72,7 @@ class ImageRegionSelectionInteractionViewModel( val answerTextString = answerText.toString() userAnswerBuilder.answer = InteractionObject.newBuilder().setClickOnImage(parseClickOnImage(answerTextString)).build() - userAnswerBuilder.plainAnswer = context.getString( + userAnswerBuilder.plainAnswer = resourceHandler.getStringInLocale( R.string.image_interaction_answer_text, answerTextString ) diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/InteractionViewModelModule.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/InteractionViewModelModule.kt index dbb462f829a..88330e331d2 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/InteractionViewModelModule.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/InteractionViewModelModule.kt @@ -1,12 +1,12 @@ package org.oppia.android.app.player.state.itemviewmodel -import android.content.Context import androidx.fragment.app.Fragment import dagger.Module import dagger.Provides import dagger.multibindings.IntoMap import dagger.multibindings.StringKey import org.oppia.android.app.player.state.listener.PreviousNavigationButtonListener +import org.oppia.android.app.translation.AppLanguageResourceHandler /** * Module to provide interaction view model-specific dependencies for interactions that should be @@ -24,7 +24,8 @@ class InteractionViewModelModule { @IntoMap @StringKey("Continue") fun provideContinueInteractionViewModelFactory(fragment: Fragment): InteractionViewModelFactory { - return { _, hasConversationView, _, interactionAnswerReceiver, _, hasPreviousButton, isSplitView -> // ktlint-disable max-line-length + return { _, hasConversationView, _, interactionAnswerReceiver, _, hasPreviousButton, + isSplitView -> ContinueInteractionViewModel( interactionAnswerReceiver, hasConversationView, @@ -39,7 +40,8 @@ class InteractionViewModelModule { @IntoMap @StringKey("MultipleChoiceInput") fun provideMultipleChoiceInputViewModelFactory(): InteractionViewModelFactory { - return { entityId, hasConversationView, interaction, interactionAnswerReceiver, interactionAnswerErrorReceiver, _, isSplitView -> // ktlint-disable max-line-length + return { entityId, hasConversationView, interaction, interactionAnswerReceiver, + interactionAnswerErrorReceiver, _, isSplitView -> SelectionInteractionViewModel( entityId, hasConversationView, @@ -55,7 +57,8 @@ class InteractionViewModelModule { @IntoMap @StringKey("ItemSelectionInput") fun provideItemSelectionInputViewModelFactory(): InteractionViewModelFactory { - return { entityId, hasConversationView, interaction, interactionAnswerReceiver, interactionAnswerErrorReceiver, _, isSplitView -> // ktlint-disable max-line-length + return { entityId, hasConversationView, interaction, interactionAnswerReceiver, + interactionAnswerErrorReceiver, _, isSplitView -> SelectionInteractionViewModel( entityId, hasConversationView, @@ -70,14 +73,17 @@ class InteractionViewModelModule { @Provides @IntoMap @StringKey("FractionInput") - fun provideFractionInputViewModelFactory(context: Context): InteractionViewModelFactory { - return { _, hasConversationView, interaction, _, interactionAnswerErrorReceiver, _, isSplitView -> // ktlint-disable max-line-length + fun provideFractionInputViewModelFactory( + resourceHandler: AppLanguageResourceHandler + ): InteractionViewModelFactory { + return { _, hasConversationView, interaction, _, interactionAnswerErrorReceiver, _, + isSplitView -> FractionInteractionViewModel( interaction, - context, hasConversationView, isSplitView, - interactionAnswerErrorReceiver + interactionAnswerErrorReceiver, + resourceHandler ) } } @@ -85,13 +91,15 @@ class InteractionViewModelModule { @Provides @IntoMap @StringKey("NumericInput") - fun provideNumericInputViewModelFactory(context: Context): InteractionViewModelFactory { + fun provideNumericInputViewModelFactory( + resourceHandler: AppLanguageResourceHandler + ): InteractionViewModelFactory { return { _, hasConversationView, _, _, interactionAnswerErrorReceiver, _, isSplitView -> NumericInputViewModel( - context, hasConversationView, interactionAnswerErrorReceiver, - isSplitView + isSplitView, + resourceHandler ) } } @@ -100,7 +108,8 @@ class InteractionViewModelModule { @IntoMap @StringKey("TextInput") fun provideTextInputViewModelFactory(): InteractionViewModelFactory { - return { _, hasConversationView, interaction, _, interactionAnswerErrorReceiver, _, isSplitView -> // ktlint-disable max-line-length + return { _, hasConversationView, interaction, _, interactionAnswerErrorReceiver, _, + isSplitView -> TextInputViewModel( interaction, hasConversationView, interactionAnswerErrorReceiver, isSplitView ) @@ -110,10 +119,14 @@ class InteractionViewModelModule { @Provides @IntoMap @StringKey("DragAndDropSortInput") - fun provideDragAndDropSortInputViewModelFactory(): InteractionViewModelFactory { - return { entityId, hasConversationView, interaction, _, interactionAnswerErrorReceiver, _, isSplitView -> // ktlint-disable max-line-length + fun provideDragAndDropSortInputViewModelFactory( + resourceHandler: AppLanguageResourceHandler + ): InteractionViewModelFactory { + return { entityId, hasConversationView, interaction, _, interactionAnswerErrorReceiver, _, + isSplitView -> DragAndDropSortInteractionViewModel( - entityId, hasConversationView, interaction, interactionAnswerErrorReceiver, isSplitView + entityId, hasConversationView, interaction, interactionAnswerErrorReceiver, isSplitView, + resourceHandler ) } } @@ -121,7 +134,9 @@ class InteractionViewModelModule { @Provides @IntoMap @StringKey("ImageClickInput") - fun provideImageClickInputViewModelFactory(context: Context): InteractionViewModelFactory { + fun provideImageClickInputViewModelFactory( + resourceHandler: AppLanguageResourceHandler + ): InteractionViewModelFactory { return { entityId, hasConversationView, interaction, _, answerErrorReceiver, _, isSplitView -> ImageRegionSelectionInteractionViewModel( entityId, @@ -129,7 +144,7 @@ class InteractionViewModelModule { interaction, answerErrorReceiver, isSplitView, - context + resourceHandler ) } } @@ -137,14 +152,16 @@ class InteractionViewModelModule { @Provides @IntoMap @StringKey("RatioExpressionInput") - fun provideRatioExpressionInputViewModelFactory(context: Context): InteractionViewModelFactory { + fun provideRatioExpressionInputViewModelFactory( + resourceHandler: AppLanguageResourceHandler + ): InteractionViewModelFactory { return { _, hasConversationView, interaction, _, answerErrorReceiver, _, isSplitView -> RatioExpressionInputInteractionViewModel( interaction, - context, hasConversationView, isSplitView, - answerErrorReceiver + answerErrorReceiver, + resourceHandler ) } } diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/NumericInputViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/NumericInputViewModel.kt index 0825021f7e9..01bc68eca04 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/NumericInputViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/NumericInputViewModel.kt @@ -1,6 +1,5 @@ package org.oppia.android.app.player.state.itemviewmodel -import android.content.Context import android.text.Editable import android.text.TextWatcher import androidx.databinding.Observable @@ -11,13 +10,14 @@ import org.oppia.android.app.parser.StringToNumberParser import org.oppia.android.app.player.state.answerhandling.AnswerErrorCategory import org.oppia.android.app.player.state.answerhandling.InteractionAnswerErrorOrAvailabilityCheckReceiver import org.oppia.android.app.player.state.answerhandling.InteractionAnswerHandler +import org.oppia.android.app.translation.AppLanguageResourceHandler /** [StateItemViewModel] for the numeric input interaction. */ class NumericInputViewModel( - private val context: Context, val hasConversationView: Boolean, private val interactionAnswerErrorOrAvailabilityCheckReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, // ktlint-disable max-line-length - val isSplitView: Boolean + val isSplitView: Boolean, + private val resourceHandler: AppLanguageResourceHandler ) : StateItemViewModel(ViewType.NUMERIC_INPUT_INTERACTION), InteractionAnswerHandler { var answerText: CharSequence = "" private var pendingAnswerError: String? = null @@ -45,10 +45,10 @@ class NumericInputViewModel( pendingAnswerError = when (category) { AnswerErrorCategory.REAL_TIME -> stringToNumberParser.getRealTimeAnswerError(answerText.toString()) - .getErrorMessageFromStringRes(context) + .getErrorMessageFromStringRes(resourceHandler) AnswerErrorCategory.SUBMIT_TIME -> stringToNumberParser.getSubmitTimeError(answerText.toString()) - .getErrorMessageFromStringRes(context) + .getErrorMessageFromStringRes(resourceHandler) } } errorMessage.set(pendingAnswerError) diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/PreviousResponsesHeaderViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/PreviousResponsesHeaderViewModel.kt index 0492b5c8215..97a14ff7ce1 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/PreviousResponsesHeaderViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/PreviousResponsesHeaderViewModel.kt @@ -1,16 +1,25 @@ package org.oppia.android.app.player.state.itemviewmodel import androidx.databinding.ObservableBoolean +import org.oppia.android.R import org.oppia.android.app.player.state.listener.PreviousResponsesHeaderClickListener +import org.oppia.android.app.translation.AppLanguageResourceHandler /** [StateItemViewModel] for the header of the section of previously submitted answers. */ class PreviousResponsesHeaderViewModel( - val previousAnswerCount: Int, + private val previousAnswerCount: Int, val hasConversationView: Boolean, var isExpanded: ObservableBoolean, private val previousResponsesHeaderClickListener: PreviousResponsesHeaderClickListener, - val isSplitView: Boolean + val isSplitView: Boolean, + private val resourceHandler: AppLanguageResourceHandler ) : StateItemViewModel(ViewType.PREVIOUS_RESPONSES_HEADER) { /** Called when the user clicks on the previous response header. */ fun onResponsesHeaderClicked() = previousResponsesHeaderClickListener.onResponsesHeaderClicked() + + fun computePreviousResponsesHeaderText(): String { + return resourceHandler.getStringInLocale( + R.string.previous_responses_header, previousAnswerCount + ) + } } diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/RatioExpressionInputInteractionViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/RatioExpressionInputInteractionViewModel.kt index 0979bd4e785..768ff05f560 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/RatioExpressionInputInteractionViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/RatioExpressionInputInteractionViewModel.kt @@ -1,6 +1,5 @@ package org.oppia.android.app.player.state.itemviewmodel -import android.content.Context import android.text.Editable import android.text.TextWatcher import androidx.databinding.Observable @@ -13,16 +12,17 @@ import org.oppia.android.app.parser.StringToRatioParser import org.oppia.android.app.player.state.answerhandling.AnswerErrorCategory import org.oppia.android.app.player.state.answerhandling.InteractionAnswerErrorOrAvailabilityCheckReceiver import org.oppia.android.app.player.state.answerhandling.InteractionAnswerHandler +import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.app.utility.toAccessibleAnswerString import org.oppia.android.domain.util.toAnswerString /** [StateItemViewModel] for the ratio expression input interaction. */ class RatioExpressionInputInteractionViewModel( interaction: Interaction, - private val context: Context, val hasConversationView: Boolean, val isSplitView: Boolean, - private val errorOrAvailabilityCheckReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver + private val errorOrAvailabilityCheckReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, + private val resourceHandler: AppLanguageResourceHandler ) : StateItemViewModel(ViewType.RATIO_EXPRESSION_INPUT_INTERACTION), InteractionAnswerHandler { private var pendingAnswerError: String? = null var answerText: CharSequence = "" @@ -56,7 +56,7 @@ class RatioExpressionInputInteractionViewModel( .setRatioExpression(ratioAnswer) .build() userAnswerBuilder.plainAnswer = ratioAnswer.toAnswerString() - userAnswerBuilder.contentDescription = ratioAnswer.toAccessibleAnswerString(context) + userAnswerBuilder.contentDescription = ratioAnswer.toAccessibleAnswerString(resourceHandler) } return userAnswerBuilder.build() } @@ -68,17 +68,13 @@ class RatioExpressionInputInteractionViewModel( AnswerErrorCategory.REAL_TIME -> pendingAnswerError = stringToRatioParser.getRealTimeAnswerError(answerText.toString()) - .getErrorMessageFromStringRes( - context - ) + .getErrorMessageFromStringRes(resourceHandler) AnswerErrorCategory.SUBMIT_TIME -> pendingAnswerError = stringToRatioParser.getSubmitTimeError( answerText.toString(), numberOfTerms = numberOfTerms - ).getErrorMessageFromStringRes( - context - ) + ).getErrorMessageFromStringRes(resourceHandler) } errorMessage.set(pendingAnswerError) } @@ -109,7 +105,7 @@ class RatioExpressionInputInteractionViewModel( interaction.customizationArgsMap["placeholder"]?.subtitledUnicode?.unicodeStr ?: "" return when { placeholder.isNotEmpty() -> placeholder - else -> context.getString(R.string.ratio_default_hint_text) + else -> resourceHandler.getStringInLocale(R.string.ratio_default_hint_text) } } } diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/SubmittedAnswerViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/SubmittedAnswerViewModel.kt index c971e6aad5e..497e6ff8467 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/SubmittedAnswerViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/SubmittedAnswerViewModel.kt @@ -1,7 +1,9 @@ package org.oppia.android.app.player.state.itemviewmodel import androidx.databinding.ObservableField +import org.oppia.android.R import org.oppia.android.app.model.UserAnswer +import org.oppia.android.app.translation.AppLanguageResourceHandler /** [StateItemViewModel] for previously submitted answers. */ class SubmittedAnswerViewModel( @@ -9,8 +11,53 @@ class SubmittedAnswerViewModel( val gcsEntityId: String, val hasConversationView: Boolean, val isSplitView: Boolean, - val supportsConceptCards: Boolean + val supportsConceptCards: Boolean, + private val resourceHandler: AppLanguageResourceHandler ) : StateItemViewModel(ViewType.SUBMITTED_ANSWER) { - val isCorrectAnswer = ObservableField(false) - val isExtraInteractionAnswerCorrect = ObservableField(false) + val isCorrectAnswer = ObservableField(DEFAULT_IS_CORRECT_ANSWER) + val submittedAnswer: ObservableField = ObservableField(DEFAULT_SUBMITTED_ANSWER) + val isExtraInteractionAnswerCorrect = ObservableField(DEFAULT_IS_CORRECT_ANSWER) + val submittedAnswerContentDescription: ObservableField = + ObservableField(computeSubmittedAnswerContentDescription( + DEFAULT_IS_CORRECT_ANSWER, DEFAULT_SUBMITTED_ANSWER, DEFAULT_ACCESSIBLE_ANSWER + )) + private var accessibleAnswer: String? = DEFAULT_ACCESSIBLE_ANSWER + + fun setSubmittedAnswer(submittedAnswer: CharSequence, accessibleAnswer: String? = null) { + this.submittedAnswer.set(submittedAnswer) + this.accessibleAnswer = accessibleAnswer + updateSubmittedAnswerContentDescription() + } + + fun setIsCorrectAnswer(isCorrectAnswer: Boolean) { + this.isCorrectAnswer.set(isCorrectAnswer) + updateSubmittedAnswerContentDescription() + } + + private fun updateSubmittedAnswerContentDescription() { + submittedAnswerContentDescription.set( + computeSubmittedAnswerContentDescription( + isCorrectAnswer.get() ?: DEFAULT_IS_CORRECT_ANSWER, + submittedAnswer.get() ?: DEFAULT_SUBMITTED_ANSWER, + accessibleAnswer + ) + ) + } + + private fun computeSubmittedAnswerContentDescription( + isCorrectAnswer: Boolean, submittedAnswer: CharSequence, accessibleAnswer: String? + ): String { + val answer = accessibleAnswer ?: submittedAnswer + return if (isCorrectAnswer) { + resourceHandler.getStringInLocale(R.string.correct_submitted_answer_with_append, answer) + } else { + resourceHandler.getStringInLocale(R.string.incorrect_submitted_answer_with_append, answer) + } + } + + private companion object { + private const val DEFAULT_IS_CORRECT_ANSWER = false + private const val DEFAULT_SUBMITTED_ANSWER = "" + private val DEFAULT_ACCESSIBLE_ANSWER: String? = null + } } diff --git a/app/src/main/java/org/oppia/android/app/player/stopplaying/ProgressDatabaseFullDialogFragment.kt b/app/src/main/java/org/oppia/android/app/player/stopplaying/ProgressDatabaseFullDialogFragment.kt index a2a8c359ac5..8e0aae921d6 100644 --- a/app/src/main/java/org/oppia/android/app/player/stopplaying/ProgressDatabaseFullDialogFragment.kt +++ b/app/src/main/java/org/oppia/android/app/player/stopplaying/ProgressDatabaseFullDialogFragment.kt @@ -5,8 +5,12 @@ import android.content.Context import android.os.Bundle import androidx.appcompat.app.AlertDialog import androidx.appcompat.view.ContextThemeWrapper -import androidx.fragment.app.DialogFragment +import javax.inject.Inject import org.oppia.android.R +import org.oppia.android.app.fragment.FragmentComponentImpl +import org.oppia.android.app.fragment.InjectableDialogFragment +import org.oppia.android.app.translation.AppLanguageResourceHandler +import org.oppia.android.util.extensions.getStringFromBundle private const val OLDEST_SAVED_EXPLORATION_TITLE_ARGUMENT_KEY = "MaximumStorageCapacityReachedDialogFragment.oldest_saved_exploration_title" @@ -20,7 +24,10 @@ private const val OLDEST_SAVED_EXPLORATION_TITLE_ARGUMENT_KEY = * the current progress, leave the exploration without saving the current progress, or go back to * continue the current exploration. */ -class ProgressDatabaseFullDialogFragment : DialogFragment() { +class ProgressDatabaseFullDialogFragment : InjectableDialogFragment() { + @Inject + lateinit var resourceHandler: AppLanguageResourceHandler + companion object { /** * Responsible for displaying content in DialogFragment. @@ -39,9 +46,14 @@ class ProgressDatabaseFullDialogFragment : DialogFragment() { } } + override fun onAttach(context: Context) { + super.onAttach(context) + (fragmentComponent as FragmentComponentImpl).inject(this) + } + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val oldestSavedExplorationTitle = arguments - ?.getString(OLDEST_SAVED_EXPLORATION_TITLE_ARGUMENT_KEY) + ?.getStringFromBundle(OLDEST_SAVED_EXPLORATION_TITLE_ARGUMENT_KEY) val stopStatePlayingSessionListenerWithSavedProgressListener: StopStatePlayingSessionWithSavedProgressListener = activity as StopStatePlayingSessionWithSavedProgressListener @@ -50,7 +62,9 @@ class ProgressDatabaseFullDialogFragment : DialogFragment() { .Builder(ContextThemeWrapper(activity as Context, R.style.OppiaDialogFragmentTheme)) .setTitle(R.string.progress_database_full_dialog_title) .setMessage( - getString(R.string.progress_database_full_dialog_description, oldestSavedExplorationTitle) + resourceHandler.getStringInLocale( + R.string.progress_database_full_dialog_description, oldestSavedExplorationTitle + ) ) .setPositiveButton(R.string.progress_database_full_dialog_continue_button) { _, _ -> stopStatePlayingSessionListenerWithSavedProgressListener diff --git a/app/src/main/java/org/oppia/android/app/player/stopplaying/StopExplorationDialogFragment.kt b/app/src/main/java/org/oppia/android/app/player/stopplaying/StopExplorationDialogFragment.kt index 855bb11e841..c473434a01a 100644 --- a/app/src/main/java/org/oppia/android/app/player/stopplaying/StopExplorationDialogFragment.kt +++ b/app/src/main/java/org/oppia/android/app/player/stopplaying/StopExplorationDialogFragment.kt @@ -7,13 +7,15 @@ import androidx.appcompat.app.AlertDialog import androidx.appcompat.view.ContextThemeWrapper import androidx.fragment.app.DialogFragment import org.oppia.android.R +import org.oppia.android.app.fragment.FragmentComponentImpl +import org.oppia.android.app.fragment.InjectableDialogFragment /** * DialogFragment that is visible to the user when they exit a partially complete exploration * if the exploration has saved progress and the checkpoint database has not exceeded the allocated * limit. */ -class StopExplorationDialogFragment : DialogFragment() { +class StopExplorationDialogFragment : InjectableDialogFragment() { companion object { /** * Responsible for displaying content in DialogFragment. @@ -25,6 +27,11 @@ class StopExplorationDialogFragment : DialogFragment() { } } + override fun onAttach(context: Context) { + super.onAttach(context) + (fragmentComponent as FragmentComponentImpl).inject(this) + } + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val stopStatePlayingSessionListener: StopStatePlayingSessionListener = activity as StopStatePlayingSessionListener diff --git a/app/src/main/java/org/oppia/android/app/player/stopplaying/UnsavedExplorationDialogFragment.kt b/app/src/main/java/org/oppia/android/app/player/stopplaying/UnsavedExplorationDialogFragment.kt index fd7a638d3d5..bcb5556affd 100644 --- a/app/src/main/java/org/oppia/android/app/player/stopplaying/UnsavedExplorationDialogFragment.kt +++ b/app/src/main/java/org/oppia/android/app/player/stopplaying/UnsavedExplorationDialogFragment.kt @@ -7,12 +7,14 @@ import androidx.appcompat.app.AlertDialog import androidx.appcompat.view.ContextThemeWrapper import androidx.fragment.app.DialogFragment import org.oppia.android.R +import org.oppia.android.app.fragment.FragmentComponentImpl +import org.oppia.android.app.fragment.InjectableDialogFragment /** * DialogFragment that visible to the user when they exit a partially complete exploration with * unsaved progress. */ -class UnsavedExplorationDialogFragment : DialogFragment() { +class UnsavedExplorationDialogFragment : InjectableDialogFragment() { companion object { /** * Responsible for displaying content in DialogFragment. @@ -24,6 +26,11 @@ class UnsavedExplorationDialogFragment : DialogFragment() { } } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + (fragmentComponent as FragmentComponentImpl).inject(this) + } + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val stopStatePlayingSessionWithSavedProgressListener: StopStatePlayingSessionWithSavedProgressListener = diff --git a/app/src/main/java/org/oppia/android/app/profile/AddProfileActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/profile/AddProfileActivityPresenter.kt index 7107744e1d1..64f16b21f0d 100644 --- a/app/src/main/java/org/oppia/android/app/profile/AddProfileActivityPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/profile/AddProfileActivityPresenter.kt @@ -33,6 +33,7 @@ import org.oppia.android.domain.profile.ProfileManagementController import org.oppia.android.util.data.AsyncResult import org.oppia.android.util.data.DataProviders.Companion.toLiveData import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler const val GALLERY_INTENT_RESULT_CODE = 1 @@ -41,7 +42,8 @@ const val GALLERY_INTENT_RESULT_CODE = 1 class AddProfileActivityPresenter @Inject constructor( private val activity: AppCompatActivity, private val profileManagementController: ProfileManagementController, - private val viewModelProvider: ViewModelProvider + private val viewModelProvider: ViewModelProvider, + private val resourceHandler: AppLanguageResourceHandler ) { private lateinit var uploadImageView: ImageView private val profileViewModel by lazy { @@ -79,7 +81,7 @@ class AddProfileActivityPresenter @Inject constructor( } val toolbar = activity.findViewById(R.id.add_profile_activity_toolbar) as Toolbar activity.setSupportActionBar(toolbar) - activity.supportActionBar?.title = activity.getString(R.string.add_profile_title) + activity.supportActionBar?.title = resourceHandler.getStringInLocale(R.string.add_profile_title) activity.supportActionBar?.setDisplayHomeAsUpEnabled(true) activity.supportActionBar?.setHomeAsUpIndicator(R.drawable.ic_close_white_24dp) activity.supportActionBar?.setHomeActionContentDescription(R.string.admin_auth_close) @@ -254,7 +256,7 @@ class AddProfileActivityPresenter @Inject constructor( var failed = false if (name.isEmpty()) { profileViewModel.nameErrorMsg.set( - activity.resources.getString( + resourceHandler.getStringInLocale( R.string.add_profile_error_name_empty ) ) @@ -262,7 +264,7 @@ class AddProfileActivityPresenter @Inject constructor( } if (pin.isNotEmpty() && pin.length < 3) { profileViewModel.pinErrorMsg.set( - activity.resources.getString( + resourceHandler.getStringInLocale( R.string.add_profile_error_pin_length ) ) @@ -270,7 +272,7 @@ class AddProfileActivityPresenter @Inject constructor( } if (pin != confirmPin) { profileViewModel.confirmPinErrorMsg.set( - activity.resources.getString( + resourceHandler.getStringInLocale( R.string.add_profile_error_pin_confirm_wrong ) ) @@ -291,13 +293,13 @@ class AddProfileActivityPresenter @Inject constructor( when (result.getErrorOrNull()) { is ProfileManagementController.ProfileNameNotUniqueException -> profileViewModel.nameErrorMsg.set( - activity.resources.getString( + resourceHandler.getStringInLocale( R.string.add_profile_error_name_not_unique ) ) is ProfileManagementController.ProfileNameOnlyLettersException -> profileViewModel.nameErrorMsg.set( - activity.resources.getString( + resourceHandler.getStringInLocale( R.string.add_profile_error_name_only_letters ) ) diff --git a/app/src/main/java/org/oppia/android/app/profile/AdminAuthActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/profile/AdminAuthActivityPresenter.kt index 162766397d2..93259169308 100644 --- a/app/src/main/java/org/oppia/android/app/profile/AdminAuthActivityPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/profile/AdminAuthActivityPresenter.kt @@ -12,13 +12,15 @@ import org.oppia.android.app.utility.TextInputEditTextHelper.Companion.onTextCha import org.oppia.android.app.viewmodel.ViewModelProvider import org.oppia.android.databinding.AdminAuthActivityBinding import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler /** The presenter for [AdminAuthActivity]. */ @ActivityScope class AdminAuthActivityPresenter @Inject constructor( private val context: Context, private val activity: AppCompatActivity, - private val viewModelProvider: ViewModelProvider + private val viewModelProvider: ViewModelProvider, + private val resourceHandler: AppLanguageResourceHandler ) { private lateinit var binding: AdminAuthActivityBinding private val authViewModel by lazy { @@ -91,7 +93,9 @@ class AdminAuthActivityPresenter @Inject constructor( } } } else if (inputPin.length == adminPin.length) { - authViewModel.errorMessage.set(activity.resources.getString(R.string.admin_auth_incorrect)) + authViewModel.errorMessage.set( + resourceHandler.getStringInLocale(R.string.admin_auth_incorrect) + ) } } } @@ -99,20 +103,24 @@ class AdminAuthActivityPresenter @Inject constructor( private fun setTitleAndSubTitle(binding: AdminAuthActivityBinding?) { when (activity.intent.getIntExtra(ADMIN_AUTH_ENUM_EXTRA_KEY, 0)) { AdminAuthEnum.PROFILE_ADMIN_CONTROLS.value -> { - activity.title = context.getString(R.string.admin_auth_activity_access_controls_title) + activity.title = + resourceHandler.getStringInLocale(R.string.admin_auth_activity_access_controls_title) binding?.adminAuthToolbar?.title = - context.resources.getString(R.string.administrator_controls) + resourceHandler.getStringInLocale(R.string.administrator_controls) binding?.adminAuthHeadingTextview?.text = - context.resources.getString(R.string.admin_auth_heading) + resourceHandler.getStringInLocale(R.string.admin_auth_heading) binding?.adminAuthSubText?.text = - context.resources.getString(R.string.admin_auth_admin_controls_sub) + resourceHandler.getStringInLocale(R.string.admin_auth_admin_controls_sub) } AdminAuthEnum.PROFILE_ADD_PROFILE.value -> { - activity.title = context.getString(R.string.admin_auth_activity_add_profiles_title) - binding?.adminAuthToolbar?.title = context.resources.getString(R.string.add_profile_title) + activity.title = + resourceHandler.getStringInLocale(R.string.admin_auth_activity_add_profiles_title) + binding?.adminAuthToolbar?.title = + resourceHandler.getStringInLocale(R.string.add_profile_title) binding?.adminAuthHeadingTextview?.text = - context.resources.getString(R.string.admin_auth_heading) - binding?.adminAuthSubText?.text = context.resources.getString(R.string.admin_auth_sub) + resourceHandler.getStringInLocale(R.string.admin_auth_heading) + binding?.adminAuthSubText?.text = + resourceHandler.getStringInLocale(R.string.admin_auth_sub) } } } diff --git a/app/src/main/java/org/oppia/android/app/profile/AdminPinActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/profile/AdminPinActivityPresenter.kt index 45a38f555d7..16c50439c14 100644 --- a/app/src/main/java/org/oppia/android/app/profile/AdminPinActivityPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/profile/AdminPinActivityPresenter.kt @@ -16,6 +16,7 @@ import org.oppia.android.databinding.AdminPinActivityBinding import org.oppia.android.domain.profile.ProfileManagementController import org.oppia.android.util.data.DataProviders.Companion.toLiveData import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler /** The presenter for [AdminPinActivity]. */ @ActivityScope @@ -23,7 +24,8 @@ class AdminPinActivityPresenter @Inject constructor( private val context: Context, private val activity: AppCompatActivity, private val profileManagementController: ProfileManagementController, - private val viewModelProvider: ViewModelProvider + private val viewModelProvider: ViewModelProvider, + private val resourceHandler: AppLanguageResourceHandler ) { private var inputtedPin = false @@ -87,7 +89,7 @@ class AdminPinActivityPresenter @Inject constructor( var failed = false if (inputPin.length < 5) { adminViewModel.pinErrorMsg.set( - activity.getString( + resourceHandler.getStringInLocale( R.string.admin_pin_error_pin_length ) ) @@ -95,7 +97,7 @@ class AdminPinActivityPresenter @Inject constructor( } if (inputPin != confirmPin) { adminViewModel.confirmPinErrorMsg.set( - activity.getString( + resourceHandler.getStringInLocale( R.string.admin_pin_error_pin_confirm_wrong ) ) diff --git a/app/src/main/java/org/oppia/android/app/profile/AdminSettingsDialogFragment.kt b/app/src/main/java/org/oppia/android/app/profile/AdminSettingsDialogFragment.kt index 3dc4b1b0731..d2acc944ca2 100644 --- a/app/src/main/java/org/oppia/android/app/profile/AdminSettingsDialogFragment.kt +++ b/app/src/main/java/org/oppia/android/app/profile/AdminSettingsDialogFragment.kt @@ -6,6 +6,7 @@ import android.os.Bundle import org.oppia.android.app.fragment.InjectableDialogFragment import javax.inject.Inject import org.oppia.android.app.fragment.FragmentComponentImpl +import org.oppia.android.util.extensions.getStringFromBundle const val ADMIN_SETTINGS_PIN_ARGUMENT_KEY = "AdminSettingsDialogFragment.admin_settings_pin" @@ -30,7 +31,7 @@ class AdminSettingsDialogFragment : InjectableDialogFragment() { } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { - val adminPin = arguments?.getString(ADMIN_SETTINGS_PIN_ARGUMENT_KEY) + val adminPin = arguments?.getStringFromBundle(ADMIN_SETTINGS_PIN_ARGUMENT_KEY) checkNotNull(adminPin) { "Admin Pin must not be null" } return adminSettingsDialogFragmentPresenter.handleOnCreateDialog( activity as ProfileRouteDialogInterface, diff --git a/app/src/main/java/org/oppia/android/app/profile/AdminSettingsDialogFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/profile/AdminSettingsDialogFragmentPresenter.kt index 2dcbedacb9e..d7d5c3d25cb 100644 --- a/app/src/main/java/org/oppia/android/app/profile/AdminSettingsDialogFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/profile/AdminSettingsDialogFragmentPresenter.kt @@ -13,13 +13,15 @@ import org.oppia.android.app.utility.TextInputEditTextHelper.Companion.onTextCha import org.oppia.android.app.viewmodel.ViewModelProvider import org.oppia.android.databinding.AdminSettingsDialogBinding import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler /** The presenter for [AdminSettingsDialogFragment]. */ @FragmentScope class AdminSettingsDialogFragmentPresenter @Inject constructor( private val fragment: Fragment, private val activity: AppCompatActivity, - private val viewModelProvider: ViewModelProvider + private val viewModelProvider: ViewModelProvider, + private val resourceHandler: AppLanguageResourceHandler ) { private val adminViewModel by lazy { getAdminSettingsViewModel() @@ -79,7 +81,7 @@ class AdminSettingsDialogFragmentPresenter @Inject constructor( dialog.getButton(AlertDialog.BUTTON_POSITIVE).setOnClickListener { if (binding.adminSettingsInputPinEditText.text?.isEmpty()!!) { adminViewModel.errorMessage.set( - fragment.resources.getString( + resourceHandler.getStringInLocale( R.string.admin_auth_null ) ) @@ -89,7 +91,7 @@ class AdminSettingsDialogFragmentPresenter @Inject constructor( routeDialogInterface.routeToResetPinDialog() } else { adminViewModel.errorMessage.set( - fragment.resources.getString( + resourceHandler.getStringInLocale( R.string.admin_settings_incorrect ) ) diff --git a/app/src/main/java/org/oppia/android/app/profile/PinPasswordActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/profile/PinPasswordActivityPresenter.kt index d360dc47a39..3726e3d3bb7 100644 --- a/app/src/main/java/org/oppia/android/app/profile/PinPasswordActivityPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/profile/PinPasswordActivityPresenter.kt @@ -21,6 +21,7 @@ import org.oppia.android.domain.profile.ProfileManagementController import org.oppia.android.util.data.DataProviders.Companion.toLiveData import org.oppia.android.util.statusbar.StatusBarColor import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler private const val TAG_ADMIN_SETTINGS_DIALOG = "ADMIN_SETTINGS_DIALOG" private const val TAG_RESET_PIN_DIALOG = "RESET_PIN_DIALOG" @@ -30,7 +31,8 @@ class PinPasswordActivityPresenter @Inject constructor( private val activity: AppCompatActivity, private val profileManagementController: ProfileManagementController, private val lifecycleSafeTimerFactory: LifecycleSafeTimerFactory, - private val viewModelProvider: ViewModelProvider + private val viewModelProvider: ViewModelProvider, + private val resourceHandler: AppLanguageResourceHandler ) { private val pinViewModel by lazy { getPinPasswordViewModel() @@ -148,11 +150,11 @@ class PinPasswordActivityPresenter @Inject constructor( } private fun showAdminForgotPin() { - val appName = activity.resources.getString(R.string.app_name) + val appName = resourceHandler.getStringInLocale(R.string.app_name) pinViewModel.showAdminPinForgotPasswordPopUp.set(true) alertDialog = AlertDialog.Builder(activity, R.style.AlertDialogTheme) .setTitle(R.string.pin_password_forgot_title) - .setMessage(activity.resources.getString(R.string.pin_password_forgot_message, appName)) + .setMessage(resourceHandler.getStringInLocale(R.string.pin_password_forgot_message, appName)) .setNegativeButton(R.string.admin_settings_cancel) { dialog, _ -> pinViewModel.showAdminPinForgotPasswordPopUp.set(false) dialog.dismiss() diff --git a/app/src/main/java/org/oppia/android/app/profile/PinPasswordViewModel.kt b/app/src/main/java/org/oppia/android/app/profile/PinPasswordViewModel.kt index 72a7620bce1..81ddd336e8f 100644 --- a/app/src/main/java/org/oppia/android/app/profile/PinPasswordViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/profile/PinPasswordViewModel.kt @@ -3,6 +3,7 @@ package org.oppia.android.app.profile import androidx.databinding.ObservableField import androidx.lifecycle.LiveData import androidx.lifecycle.Transformations +import org.oppia.android.R import org.oppia.android.app.activity.ActivityScope import org.oppia.android.app.model.Profile import org.oppia.android.app.model.ProfileId @@ -12,12 +13,14 @@ import org.oppia.android.domain.profile.ProfileManagementController import org.oppia.android.util.data.AsyncResult import org.oppia.android.util.data.DataProviders.Companion.toLiveData import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler /** The ViewModel for [PinPasswordActivity]. */ @ActivityScope class PinPasswordViewModel @Inject constructor( private val profileManagementController: ProfileManagementController, - private val oppiaLogger: OppiaLogger + private val oppiaLogger: OppiaLogger, + private val resourceHandler: AppLanguageResourceHandler ) : ObservableViewModel() { private lateinit var profileId: ProfileId val showError = ObservableField(false) @@ -34,6 +37,12 @@ class PinPasswordViewModel @Inject constructor( ) } + val helloText: LiveData by lazy { + Transformations.map(profile) { profile -> + resourceHandler.getStringInLocale(R.string.pin_password_hello, profile.name) + } + } + fun setProfileId(id: Int) { profileId = ProfileId.newBuilder().setInternalId(id).build() } diff --git a/app/src/main/java/org/oppia/android/app/profile/ProfileChooserViewModel.kt b/app/src/main/java/org/oppia/android/app/profile/ProfileChooserViewModel.kt index 12421a27986..0f93e209ce1 100644 --- a/app/src/main/java/org/oppia/android/app/profile/ProfileChooserViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/profile/ProfileChooserViewModel.kt @@ -13,15 +13,16 @@ import org.oppia.android.domain.oppialogger.OppiaLogger import org.oppia.android.domain.profile.ProfileManagementController import org.oppia.android.util.data.AsyncResult import org.oppia.android.util.data.DataProviders.Companion.toLiveData -import java.util.Locale import javax.inject.Inject +import org.oppia.android.util.locale.OppiaLocale /** The ViewModel for [ProfileChooserFragment]. */ @FragmentScope class ProfileChooserViewModel @Inject constructor( fragment: Fragment, private val oppiaLogger: OppiaLogger, - private val profileManagementController: ProfileManagementController + private val profileManagementController: ProfileManagementController, + private val machineLocale: OppiaLocale.MachineLocale ) : ObservableViewModel() { private val routeToAdminPinListener = fragment as RouteToAdminPinListener @@ -59,7 +60,7 @@ class ProfileChooserViewModel @Inject constructor( } val sortedProfileList = profileList.sortedBy { - it.profile.name.toLowerCase(Locale.getDefault()) + machineLocale.run { it.profile.name.toMachineLowerCase() } }.toMutableList() val adminProfile = sortedProfileList.find { it.profile.isAdmin }!! diff --git a/app/src/main/java/org/oppia/android/app/profile/ResetPinDialogFragment.kt b/app/src/main/java/org/oppia/android/app/profile/ResetPinDialogFragment.kt index e5f7ee0ca46..7276cfbd3fb 100644 --- a/app/src/main/java/org/oppia/android/app/profile/ResetPinDialogFragment.kt +++ b/app/src/main/java/org/oppia/android/app/profile/ResetPinDialogFragment.kt @@ -6,6 +6,7 @@ import android.os.Bundle import org.oppia.android.app.fragment.InjectableDialogFragment import javax.inject.Inject import org.oppia.android.app.fragment.FragmentComponentImpl +import org.oppia.android.util.extensions.getStringFromBundle const val RESET_PIN_PROFILE_ID_ARGUMENT_KEY = "ResetPinDialogFragment.reset_pin_profile_id" const val RESET_PIN_NAME_ARGUMENT_KEY = "ResetPinDialogFragment.reset_pin_name" @@ -33,7 +34,7 @@ class ResetPinDialogFragment : InjectableDialogFragment() { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val profileId = arguments?.getInt(RESET_PIN_PROFILE_ID_ARGUMENT_KEY) - val name = arguments?.getString(RESET_PIN_NAME_ARGUMENT_KEY) + val name = arguments?.getStringFromBundle(RESET_PIN_NAME_ARGUMENT_KEY) checkNotNull(profileId) { "Profile Id must not be null" } checkNotNull(name) { "Name must not be null" } return resetPinDialogFragmentPresenter.handleOnCreateDialog( diff --git a/app/src/main/java/org/oppia/android/app/profile/ResetPinDialogFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/profile/ResetPinDialogFragmentPresenter.kt index f673a83859f..b1422b19818 100644 --- a/app/src/main/java/org/oppia/android/app/profile/ResetPinDialogFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/profile/ResetPinDialogFragmentPresenter.kt @@ -17,6 +17,7 @@ import org.oppia.android.databinding.ResetPinDialogBinding import org.oppia.android.domain.profile.ProfileManagementController import org.oppia.android.util.data.DataProviders.Companion.toLiveData import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler /** The presenter for [ResetPinDialogFragment]. */ @FragmentScope @@ -24,7 +25,8 @@ class ResetPinDialogFragmentPresenter @Inject constructor( private val fragment: Fragment, private val activity: AppCompatActivity, private val profileManagementController: ProfileManagementController, - private val viewModelProvider: ViewModelProvider + private val viewModelProvider: ViewModelProvider, + private val resourceHandler: AppLanguageResourceHandler ) { private val resetViewModel by lazy { getResetPinViewModel() @@ -45,7 +47,7 @@ class ResetPinDialogFragmentPresenter @Inject constructor( lifecycleOwner = fragment viewModel = resetViewModel } - resetViewModel.name.set(name) + resetViewModel.setName(name) // [onTextChanged] is a extension function defined at [TextInputEditTextHelper] binding.resetPinInputPinEditText.onTextChanged { confirmPin -> @@ -100,7 +102,7 @@ class ResetPinDialogFragmentPresenter @Inject constructor( ) } else { resetViewModel.errorMessage.set( - fragment.resources.getString( + resourceHandler.getStringInLocale( R.string.add_profile_error_pin_length ) ) diff --git a/app/src/main/java/org/oppia/android/app/profile/ResetPinViewModel.kt b/app/src/main/java/org/oppia/android/app/profile/ResetPinViewModel.kt index 169da656836..9beae22de3e 100644 --- a/app/src/main/java/org/oppia/android/app/profile/ResetPinViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/profile/ResetPinViewModel.kt @@ -1,14 +1,29 @@ package org.oppia.android.app.profile import androidx.databinding.ObservableField +import org.oppia.android.R import org.oppia.android.app.fragment.FragmentScope import org.oppia.android.app.viewmodel.ObservableViewModel import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler + +private const val DEFAULT_NAME = "" /** The ViewModel for [ResetPinDialogFragment]. */ @FragmentScope -class ResetPinViewModel @Inject constructor() : ObservableViewModel() { - val name = ObservableField("") +class ResetPinViewModel @Inject constructor( + private val resourceHandler: AppLanguageResourceHandler +) : ObservableViewModel() { val inputPin = ObservableField("") val errorMessage = ObservableField("") + val resetPinInputPinHintText: ObservableField = + ObservableField(computeResetPinInputPinHint(DEFAULT_NAME)) + + fun setName(name: String) { + resetPinInputPinHintText.set(computeResetPinInputPinHint(name)) + } + + private fun computeResetPinInputPinHint(name: String): String { + return resourceHandler.getStringInLocale(R.string.admin_settings_enter_user_new_pin, name) + } } diff --git a/app/src/main/java/org/oppia/android/app/profileprogress/ProfilePictureEditDialogFragment.kt b/app/src/main/java/org/oppia/android/app/profileprogress/ProfilePictureEditDialogFragment.kt index ccd94db5d1c..f17a841929d 100644 --- a/app/src/main/java/org/oppia/android/app/profileprogress/ProfilePictureEditDialogFragment.kt +++ b/app/src/main/java/org/oppia/android/app/profileprogress/ProfilePictureEditDialogFragment.kt @@ -9,9 +9,11 @@ import androidx.appcompat.app.AlertDialog import androidx.appcompat.view.ContextThemeWrapper import androidx.fragment.app.DialogFragment import org.oppia.android.R +import org.oppia.android.app.fragment.FragmentComponentImpl +import org.oppia.android.app.fragment.InjectableDialogFragment /** [DialogFragment] that gives option to either view the profile picture or change the current profile picture. */ -class ProfilePictureEditDialogFragment : DialogFragment() { +class ProfilePictureEditDialogFragment : InjectableDialogFragment() { companion object { /** * This function is responsible for displaying content in DialogFragment. @@ -23,6 +25,11 @@ class ProfilePictureEditDialogFragment : DialogFragment() { } } + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + (fragmentComponent as FragmentComponentImpl).inject(this) + } + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val view = View.inflate( context, diff --git a/app/src/main/java/org/oppia/android/app/profileprogress/ProfileProgressViewModel.kt b/app/src/main/java/org/oppia/android/app/profileprogress/ProfileProgressViewModel.kt index be98b34ac99..abc44c76599 100644 --- a/app/src/main/java/org/oppia/android/app/profileprogress/ProfileProgressViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/profileprogress/ProfileProgressViewModel.kt @@ -23,6 +23,7 @@ import org.oppia.android.util.data.AsyncResult import org.oppia.android.util.data.DataProviders.Companion.toLiveData import org.oppia.android.util.parser.html.StoryHtmlParserEntityType import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler /** The [ObservableViewModel] for [ProfileProgressFragment]. */ @FragmentScope @@ -34,7 +35,8 @@ class ProfileProgressViewModel @Inject constructor( private val topicController: TopicController, private val topicListController: TopicListController, private val oppiaLogger: OppiaLogger, - @StoryHtmlParserEntityType private val entityType: String + @StoryHtmlParserEntityType private val entityType: String, + private val resourceHandler: AppLanguageResourceHandler ) { /** [internalProfileId] needs to be set before any of the live data members can be accessed. */ private var internalProfileId: Int = -1 @@ -140,7 +142,7 @@ class ProfileProgressViewModel @Inject constructor( itemViewModelList.addAll( itemList.map { story -> RecentlyPlayedStorySummaryViewModel( - activity, internalProfileId, story, entityType, intentFactoryShim + activity, internalProfileId, story, entityType, intentFactoryShim, resourceHandler ) } ) diff --git a/app/src/main/java/org/oppia/android/app/profileprogress/RecentlyPlayedStorySummaryViewModel.kt b/app/src/main/java/org/oppia/android/app/profileprogress/RecentlyPlayedStorySummaryViewModel.kt index 24c51105cce..44c3cbbf365 100644 --- a/app/src/main/java/org/oppia/android/app/profileprogress/RecentlyPlayedStorySummaryViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/profileprogress/RecentlyPlayedStorySummaryViewModel.kt @@ -1,9 +1,11 @@ package org.oppia.android.app.profileprogress import androidx.appcompat.app.AppCompatActivity +import org.oppia.android.R import org.oppia.android.app.home.RouteToTopicPlayStoryListener import org.oppia.android.app.model.PromotedStory import org.oppia.android.app.shim.IntentFactoryShim +import org.oppia.android.app.translation.AppLanguageResourceHandler /** Recently played item [ViewModel] for the recycler view in [ProfileProgressFragment]. */ class RecentlyPlayedStorySummaryViewModel( @@ -11,13 +13,20 @@ class RecentlyPlayedStorySummaryViewModel( private val internalProfileId: Int, val promotedStory: PromotedStory, val entityType: String, - private val intentFactoryShim: IntentFactoryShim + private val intentFactoryShim: IntentFactoryShim, + private val resourceHandler: AppLanguageResourceHandler ) : ProfileProgressItemViewModel(), RouteToTopicPlayStoryListener { fun onStoryItemClicked() { routeToTopicPlayStory(internalProfileId, promotedStory.topicId, promotedStory.storyId) } + fun computeLessonThumbnailContentDescription(): String { + return resourceHandler.getStringInLocale( + R.string.lesson_thumbnail_content_description, promotedStory.nextChapterName + ) + } + override fun routeToTopicPlayStory(internalProfileId: Int, topicId: String, storyId: String) { val intent = intentFactoryShim.createTopicPlayStoryActivityIntent( activity.applicationContext, diff --git a/app/src/main/java/org/oppia/android/app/resumelesson/ResumeLessonFragment.kt b/app/src/main/java/org/oppia/android/app/resumelesson/ResumeLessonFragment.kt index bb09c5c387c..f3a71b9fee3 100644 --- a/app/src/main/java/org/oppia/android/app/resumelesson/ResumeLessonFragment.kt +++ b/app/src/main/java/org/oppia/android/app/resumelesson/ResumeLessonFragment.kt @@ -11,6 +11,7 @@ import org.oppia.android.util.extensions.getProto import org.oppia.android.util.extensions.putProto import javax.inject.Inject import org.oppia.android.app.fragment.FragmentComponentImpl +import org.oppia.android.util.extensions.getStringFromBundle /** Fragment that allows the user to resume a saved exploration. */ class ResumeLessonFragment : InjectableFragment() { @@ -74,15 +75,15 @@ class ResumeLessonFragment : InjectableFragment() { "Expected profile ID to be included in arguments for ResumeLessonFragment." } val topicId = - checkNotNull(arguments?.getString(RESUME_LESSON_FRAGMENT_TOPIC_ID_KEY)) { + checkNotNull(arguments?.getStringFromBundle(RESUME_LESSON_FRAGMENT_TOPIC_ID_KEY)) { "Expected topic ID to be included in arguments for ResumeLessonFragment." } val storyId = - checkNotNull(arguments?.getString(RESUME_LESSON_FRAGMENT_STORY_ID_KEY)) { + checkNotNull(arguments?.getStringFromBundle(RESUME_LESSON_FRAGMENT_STORY_ID_KEY)) { "Expected story ID to be included in arguments for ResumeLessonFragment." } val explorationId = - checkNotNull(arguments?.getString(RESUME_LESSON_FRAGMENT_EXPLORATION_ID_KEY)) { + checkNotNull(arguments?.getStringFromBundle(RESUME_LESSON_FRAGMENT_EXPLORATION_ID_KEY)) { "Expected exploration ID to be included in arguments for ResumeLessonFragment." } val backflowScreen = arguments?.getInt(RESUME_LESSON_FRAGMENT_BACKFLOW_SCREEN_KEY, -1) diff --git a/app/src/main/java/org/oppia/android/app/settings/profile/ProfileEditDeletionDialogFragment.kt b/app/src/main/java/org/oppia/android/app/settings/profile/ProfileEditDeletionDialogFragment.kt index f1bba8c6045..ec44902e98f 100644 --- a/app/src/main/java/org/oppia/android/app/settings/profile/ProfileEditDeletionDialogFragment.kt +++ b/app/src/main/java/org/oppia/android/app/settings/profile/ProfileEditDeletionDialogFragment.kt @@ -6,9 +6,11 @@ import android.os.Bundle import androidx.appcompat.app.AlertDialog import androidx.fragment.app.DialogFragment import org.oppia.android.R +import org.oppia.android.app.fragment.FragmentComponentImpl +import org.oppia.android.app.fragment.InjectableDialogFragment /** [DialogFragment] that gives option to delete profile. */ -class ProfileEditDeletionDialogFragment : DialogFragment() { +class ProfileEditDeletionDialogFragment : InjectableDialogFragment() { companion object { // TODO(#1655): Re-restrict access to fields in tests post-Gradle. @@ -26,6 +28,11 @@ class ProfileEditDeletionDialogFragment : DialogFragment() { lateinit var profileEditDialogInterface: ProfileEditDialogInterface + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + (fragmentComponent as FragmentComponentImpl).inject(this) + } + override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val args = checkNotNull(arguments) { diff --git a/app/src/main/java/org/oppia/android/app/settings/profile/ProfileListActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/settings/profile/ProfileListActivityPresenter.kt index 5338813cb5d..6e9bd5725af 100644 --- a/app/src/main/java/org/oppia/android/app/settings/profile/ProfileListActivityPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/settings/profile/ProfileListActivityPresenter.kt @@ -4,14 +4,16 @@ import androidx.appcompat.app.AppCompatActivity import org.oppia.android.R import org.oppia.android.app.activity.ActivityScope import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler /** The presenter for [ProfileListActivity]. */ @ActivityScope class ProfileListActivityPresenter @Inject constructor( - private val activity: AppCompatActivity + private val activity: AppCompatActivity, + private val resourceHandler: AppLanguageResourceHandler ) { fun handleOnCreate() { - activity.title = activity.getString(R.string.profile_list_activity_title) + activity.title = resourceHandler.getStringInLocale(R.string.profile_list_activity_title) activity.supportActionBar?.setDisplayHomeAsUpEnabled(true) activity.supportActionBar?.setHomeAsUpIndicator(R.drawable.ic_arrow_back_white_24dp) activity.setContentView(R.layout.profile_list_activity) diff --git a/app/src/main/java/org/oppia/android/app/settings/profile/ProfileListViewModel.kt b/app/src/main/java/org/oppia/android/app/settings/profile/ProfileListViewModel.kt index 9a227e42d21..b185e8b6f93 100644 --- a/app/src/main/java/org/oppia/android/app/settings/profile/ProfileListViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/settings/profile/ProfileListViewModel.kt @@ -9,14 +9,15 @@ import org.oppia.android.domain.oppialogger.OppiaLogger import org.oppia.android.domain.profile.ProfileManagementController import org.oppia.android.util.data.AsyncResult import org.oppia.android.util.data.DataProviders.Companion.toLiveData -import java.util.Locale import javax.inject.Inject +import org.oppia.android.util.locale.OppiaLocale /** The ViewModel for [ProfileListActivity]. */ @ActivityScope class ProfileListViewModel @Inject constructor( private val oppiaLogger: OppiaLogger, - private val profileManagementController: ProfileManagementController + private val profileManagementController: ProfileManagementController, + private val machineLocale: OppiaLocale.MachineLocale ) : ObservableViewModel() { val profiles: LiveData> by lazy { Transformations.map( @@ -35,7 +36,7 @@ class ProfileListViewModel @Inject constructor( val profileList = profilesResult.getOrDefault(emptyList()) val sortedProfileList = profileList.sortedBy { - it.name.toLowerCase(Locale.getDefault()) + machineLocale.run { it.name.toMachineLowerCase() } }.toMutableList() val adminProfile = sortedProfileList.find { it.isAdmin } diff --git a/app/src/main/java/org/oppia/android/app/settings/profile/ProfileRenameActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/settings/profile/ProfileRenameActivityPresenter.kt index aa36e273176..df23f91c3c5 100644 --- a/app/src/main/java/org/oppia/android/app/settings/profile/ProfileRenameActivityPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/settings/profile/ProfileRenameActivityPresenter.kt @@ -18,13 +18,15 @@ import org.oppia.android.domain.profile.ProfileManagementController import org.oppia.android.util.data.AsyncResult import org.oppia.android.util.data.DataProviders.Companion.toLiveData import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler /** The presenter for [ProfileRenameActivity]. */ @ActivityScope class ProfileRenameActivityPresenter @Inject constructor( private val activity: AppCompatActivity, private val profileManagementController: ProfileManagementController, - private val viewModelProvider: ViewModelProvider + private val viewModelProvider: ViewModelProvider, + private val resourceHandler: AppLanguageResourceHandler ) { private val renameViewModel: ProfileRenameViewModel by lazy { getProfileRenameViewModel() @@ -59,7 +61,7 @@ class ProfileRenameActivityPresenter @Inject constructor( val name = binding.profileRenameInputEditText.text.toString() if (name.isEmpty()) { renameViewModel.nameErrorMsg.set( - activity.resources.getString( + resourceHandler.getStringInLocale( R.string.add_profile_error_name_empty ) ) @@ -109,13 +111,13 @@ class ProfileRenameActivityPresenter @Inject constructor( when (result.getErrorOrNull()) { is ProfileManagementController.ProfileNameNotUniqueException -> renameViewModel.nameErrorMsg.set( - activity.resources.getString( + resourceHandler.getStringInLocale( R.string.add_profile_error_name_not_unique ) ) is ProfileManagementController.ProfileNameOnlyLettersException -> renameViewModel.nameErrorMsg.set( - activity.resources.getString( + resourceHandler.getStringInLocale( R.string.add_profile_error_name_only_letters ) ) diff --git a/app/src/main/java/org/oppia/android/app/settings/profile/ProfileResetPinActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/settings/profile/ProfileResetPinActivityPresenter.kt index b5b014469db..0cf2b49e7e9 100644 --- a/app/src/main/java/org/oppia/android/app/settings/profile/ProfileResetPinActivityPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/settings/profile/ProfileResetPinActivityPresenter.kt @@ -15,13 +15,15 @@ import org.oppia.android.databinding.ProfileResetPinActivityBinding import org.oppia.android.domain.profile.ProfileManagementController import org.oppia.android.util.data.DataProviders.Companion.toLiveData import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler /** The presenter for [ProfileResetPinActivity]. */ @ActivityScope class ProfileResetPinActivityPresenter @Inject constructor( private val activity: AppCompatActivity, private val profileManagementController: ProfileManagementController, - private val viewModelProvider: ViewModelProvider + private val viewModelProvider: ViewModelProvider, + private val resourceHandler: AppLanguageResourceHandler ) { private var inputtedPin = false private var inputtedConfirmPin = false @@ -108,7 +110,7 @@ class ProfileResetPinActivityPresenter @Inject constructor( if (isAdmin) { if (pin.length < 5) { resetViewModel.pinErrorMsg.set( - activity.resources.getString( + resourceHandler.getStringInLocale( R.string.profile_reset_pin_error_admin_pin_length ) ) @@ -117,7 +119,7 @@ class ProfileResetPinActivityPresenter @Inject constructor( } else { if (pin.length < 3) { resetViewModel.pinErrorMsg.set( - activity.resources.getString( + resourceHandler.getStringInLocale( R.string.profile_reset_pin_error_user_pin_length ) ) @@ -126,7 +128,7 @@ class ProfileResetPinActivityPresenter @Inject constructor( } if (pin != confirmPin) { resetViewModel.confirmErrorMsg.set( - activity.resources.getString( + resourceHandler.getStringInLocale( R.string.add_profile_error_pin_confirm_wrong ) ) diff --git a/app/src/main/java/org/oppia/android/app/splash/SplashActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/splash/SplashActivityPresenter.kt index ae51532eecc..bf481b28d7f 100644 --- a/app/src/main/java/org/oppia/android/app/splash/SplashActivityPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/splash/SplashActivityPresenter.kt @@ -14,7 +14,7 @@ import org.oppia.android.app.onboarding.OnboardingActivity import org.oppia.android.app.profile.ProfileChooserActivity import org.oppia.android.app.translation.AppLanguageLocaleHandler import org.oppia.android.domain.locale.LocaleController -import org.oppia.android.domain.locale.OppiaLocale +import org.oppia.android.util.locale.OppiaLocale import org.oppia.android.domain.onboarding.AppStartupStateController import org.oppia.android.domain.oppialogger.OppiaLogger import org.oppia.android.domain.topic.PrimeTopicAssetsController diff --git a/app/src/main/java/org/oppia/android/app/story/StoryFragment.kt b/app/src/main/java/org/oppia/android/app/story/StoryFragment.kt index 8259149370a..8f72a5221ca 100644 --- a/app/src/main/java/org/oppia/android/app/story/StoryFragment.kt +++ b/app/src/main/java/org/oppia/android/app/story/StoryFragment.kt @@ -9,6 +9,7 @@ import org.oppia.android.app.fragment.InjectableFragment import org.oppia.android.app.model.ExplorationCheckpoint import javax.inject.Inject import org.oppia.android.app.fragment.FragmentComponentImpl +import org.oppia.android.util.extensions.getStringFromBundle private const val INTERNAL_PROFILE_ID_ARGUMENT_KEY = "StoryFragment.internal_profile_id" private const val KEY_TOPIC_ID_ARGUMENT = "TOPIC_ID" @@ -47,11 +48,11 @@ class StoryFragment : InjectableFragment(), ExplorationSelectionListener, StoryF } val internalProfileId = args.getInt(INTERNAL_PROFILE_ID_ARGUMENT_KEY, -1) val topicId = - checkNotNull(args.getString(KEY_TOPIC_ID_ARGUMENT)) { + checkNotNull(args.getStringFromBundle(KEY_TOPIC_ID_ARGUMENT)) { "Expected topicId to be passed to StoryFragment" } val storyId = - checkNotNull(args.getString(KEY_STORY_ID_ARGUMENT)) { + checkNotNull(args.getStringFromBundle(KEY_STORY_ID_ARGUMENT)) { "Expected storyId to be passed to StoryFragment" } return storyFragmentPresenter.handleCreateView( diff --git a/app/src/main/java/org/oppia/android/app/story/StoryFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/story/StoryFragmentPresenter.kt index c0f5d230448..39daf1df466 100644 --- a/app/src/main/java/org/oppia/android/app/story/StoryFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/story/StoryFragmentPresenter.kt @@ -40,6 +40,7 @@ import org.oppia.android.util.parser.html.HtmlParser import org.oppia.android.util.parser.html.TopicHtmlParserEntityType import org.oppia.android.util.system.OppiaClock import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler /** The presenter for [StoryFragment]. */ class StoryFragmentPresenter @Inject constructor( @@ -50,7 +51,8 @@ class StoryFragmentPresenter @Inject constructor( private val htmlParserFactory: HtmlParser.Factory, private val explorationDataController: ExplorationDataController, @DefaultResourceBucketName private val resourceBucketName: String, - @TopicHtmlParserEntityType private val entityType: String + @TopicHtmlParserEntityType private val entityType: String, + private val resourceHandler: AppLanguageResourceHandler ) { private val routeToExplorationListener = activity as RouteToExplorationListener private val routeToResumeLessonListener = activity as RouteToResumeLessonListener @@ -175,7 +177,7 @@ class StoryFragmentPresenter @Inject constructor( if (storyItemViewModel.chapterSummary.chapterPlayState == ChapterPlayState.NOT_PLAYABLE_MISSING_PREREQUISITES ) { - val missingPrerequisiteSummary = fragment.getString( + val missingPrerequisiteSummary = resourceHandler.getStringInLocale( R.string.chapter_prerequisite_title_label, storyItemViewModel.index.toString(), storyItemViewModel.missingPrerequisiteChapter.name diff --git a/app/src/main/java/org/oppia/android/app/story/StoryViewModel.kt b/app/src/main/java/org/oppia/android/app/story/StoryViewModel.kt index 61a53fd3f76..cc9d21145a3 100644 --- a/app/src/main/java/org/oppia/android/app/story/StoryViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/story/StoryViewModel.kt @@ -18,6 +18,7 @@ import org.oppia.android.util.data.AsyncResult import org.oppia.android.util.data.DataProviders.Companion.toLiveData import org.oppia.android.util.parser.html.StoryHtmlParserEntityType import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler /** The ViewModel for StoryFragment. */ @FragmentScope @@ -26,7 +27,8 @@ class StoryViewModel @Inject constructor( private val topicController: TopicController, private val explorationCheckpointController: ExplorationCheckpointController, private val oppiaLogger: OppiaLogger, - @StoryHtmlParserEntityType val entityType: String + @StoryHtmlParserEntityType val entityType: String, + private val resourceHandler: AppLanguageResourceHandler ) { private var internalProfileId: Int = -1 private lateinit var topicId: String @@ -93,7 +95,7 @@ class StoryViewModel @Inject constructor( // List with only the header val itemViewModelList: MutableList = mutableListOf( - StoryHeaderViewModel(completedCount, chapterList.size) as StoryItemViewModel + StoryHeaderViewModel(completedCount, chapterList.size, resourceHandler) as StoryItemViewModel ) // Add the rest of the list @@ -108,7 +110,8 @@ class StoryViewModel @Inject constructor( topicId, storyId, chapter, - entityType + entityType, + resourceHandler ) } ) diff --git a/app/src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryChapterSummaryViewModel.kt b/app/src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryChapterSummaryViewModel.kt index 6b1705cacab..1bba4ae11b5 100644 --- a/app/src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryChapterSummaryViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryChapterSummaryViewModel.kt @@ -2,12 +2,14 @@ package org.oppia.android.app.story.storyitemviewmodel import androidx.fragment.app.Fragment import androidx.lifecycle.Observer +import org.oppia.android.R import org.oppia.android.app.model.ChapterPlayState import org.oppia.android.app.model.ChapterSummary import org.oppia.android.app.model.ExplorationCheckpoint import org.oppia.android.app.model.LessonThumbnail import org.oppia.android.app.model.ProfileId import org.oppia.android.app.story.ExplorationSelectionListener +import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.domain.exploration.lightweightcheckpointing.ExplorationCheckpointController import org.oppia.android.util.data.AsyncResult import org.oppia.android.util.data.DataProviders.Companion.toLiveData @@ -22,11 +24,12 @@ class StoryChapterSummaryViewModel( val topicId: String, val storyId: String, val chapterSummary: ChapterSummary, - val entityType: String + val entityType: String, + private val resourceHandler: AppLanguageResourceHandler ) : StoryItemViewModel() { val explorationId: String = chapterSummary.explorationId - val name: String = chapterSummary.name + private val name: String = chapterSummary.name val summary: String = chapterSummary.summary val chapterThumbnail: LessonThumbnail = chapterSummary.chapterThumbnail val missingPrerequisiteChapter: ChapterSummary = chapterSummary.missingPrerequisiteChapter @@ -91,4 +94,8 @@ class StoryChapterSummaryViewModel( ) } } + + fun computeChapterTitleText(): String { + return resourceHandler.getStringInLocale(R.string.chapter_name, index + 1, name) + } } diff --git a/app/src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryHeaderViewModel.kt b/app/src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryHeaderViewModel.kt index 68dd69bf29b..34f9418efac 100644 --- a/app/src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryHeaderViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryHeaderViewModel.kt @@ -1,7 +1,17 @@ package org.oppia.android.app.story.storyitemviewmodel +import org.oppia.android.R +import org.oppia.android.app.translation.AppLanguageResourceHandler + /** Header view model for the recycler view in [StoryFragment]. */ class StoryHeaderViewModel( - val completedChapters: Int, - val totalChapters: Int -) : StoryItemViewModel() + private val completedChapters: Int, + private val totalChapters: Int, + private val resourceHandler: AppLanguageResourceHandler +) : StoryItemViewModel() { + fun computeStoryProgressChapterCompletedText(): String { + return resourceHandler.getQuantityStringInLocale( + R.plurals.story_total_chapters, totalChapters, completedChapters, totalChapters + ) + } +} diff --git a/app/src/main/java/org/oppia/android/app/testing/InputInteractionViewTestActivity.kt b/app/src/main/java/org/oppia/android/app/testing/InputInteractionViewTestActivity.kt index 4808730782a..b082d1f5b9d 100644 --- a/app/src/main/java/org/oppia/android/app/testing/InputInteractionViewTestActivity.kt +++ b/app/src/main/java/org/oppia/android/app/testing/InputInteractionViewTestActivity.kt @@ -2,9 +2,11 @@ package org.oppia.android.app.testing import android.os.Bundle import android.view.View -import androidx.appcompat.app.AppCompatActivity import androidx.databinding.DataBindingUtil +import javax.inject.Inject import org.oppia.android.R +import org.oppia.android.app.activity.ActivityComponentImpl +import org.oppia.android.app.activity.InjectableAppCompatActivity import org.oppia.android.app.customview.interaction.FractionInputInteractionView import org.oppia.android.app.customview.interaction.NumericInputInteractionView import org.oppia.android.app.customview.interaction.TextInputInteractionView @@ -17,6 +19,7 @@ import org.oppia.android.app.player.state.itemviewmodel.NumericInputViewModel import org.oppia.android.app.player.state.itemviewmodel.RatioExpressionInputInteractionViewModel import org.oppia.android.app.player.state.itemviewmodel.TextInputViewModel import org.oppia.android.app.player.state.listener.StateKeyboardButtonListener +import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.databinding.ActivityInputInteractionViewTestBinding /** @@ -24,20 +27,21 @@ import org.oppia.android.databinding.ActivityInputInteractionViewTestBinding * It contains [FractionInputInteractionView], [NumericInputInteractionView],and [TextInputInteractionView]. */ class InputInteractionViewTestActivity : - AppCompatActivity(), + InjectableAppCompatActivity(), StateKeyboardButtonListener, InteractionAnswerErrorOrAvailabilityCheckReceiver { - override fun onEditorAction(actionCode: Int) { - } - private lateinit var binding: ActivityInputInteractionViewTestBinding lateinit var fractionInteractionViewModel: FractionInteractionViewModel lateinit var ratioExpressionInputInteractionViewModel: RatioExpressionInputInteractionViewModel + + @Inject + lateinit var resourceHandler: AppLanguageResourceHandler + val numericInputViewModel = NumericInputViewModel( - context = this, hasConversationView = false, interactionAnswerErrorOrAvailabilityCheckReceiver = this, - isSplitView = false + isSplitView = false, + resourceHandler = resourceHandler ) val textInputViewModel = TextInputViewModel( @@ -49,15 +53,16 @@ class InputInteractionViewTestActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + (activityComponent as ActivityComponentImpl).inject(this) binding = DataBindingUtil.setContentView( this, R.layout.activity_input_interaction_view_test ) fractionInteractionViewModel = FractionInteractionViewModel( interaction = Interaction.getDefaultInstance(), - context = this, hasConversationView = false, isSplitView = false, - interactionAnswerErrorOrAvailabilityCheckReceiver = this + errorOrAvailabilityCheckReceiver = this, + resourceHandler = resourceHandler ) ratioExpressionInputInteractionViewModel = RatioExpressionInputInteractionViewModel( @@ -65,10 +70,10 @@ class InputInteractionViewTestActivity : "numberOfTerms", SchemaObject.newBuilder().setSignedInt(3).build() ).build(), - context = this, hasConversationView = false, isSplitView = false, - errorOrAvailabilityCheckReceiver = this + errorOrAvailabilityCheckReceiver = this, + resourceHandler = resourceHandler ) binding.numericInputViewModel = numericInputViewModel binding.textInputViewModel = textInputViewModel @@ -89,4 +94,7 @@ class InputInteractionViewTestActivity : ) { binding.submitButton.isEnabled = pendingAnswerError == null } + + override fun onEditorAction(actionCode: Int) { + } } diff --git a/app/src/main/java/org/oppia/android/app/testing/NavigationDrawerTestActivity.kt b/app/src/main/java/org/oppia/android/app/testing/NavigationDrawerTestActivity.kt index 2f3b05c90dc..dbdab69dff7 100644 --- a/app/src/main/java/org/oppia/android/app/testing/NavigationDrawerTestActivity.kt +++ b/app/src/main/java/org/oppia/android/app/testing/NavigationDrawerTestActivity.kt @@ -14,6 +14,7 @@ import org.oppia.android.app.home.recentlyplayed.RecentlyPlayedActivity import org.oppia.android.app.topic.TopicActivity import javax.inject.Inject import org.oppia.android.app.activity.ActivityComponentImpl +import org.oppia.android.app.translation.AppLanguageResourceHandler class NavigationDrawerTestActivity : InjectableAppCompatActivity(), @@ -22,6 +23,10 @@ class NavigationDrawerTestActivity : RouteToRecentlyPlayedListener { @Inject lateinit var homeActivityPresenter: HomeActivityPresenter + + @Inject + lateinit var resourceHandler: AppLanguageResourceHandler + private var internalProfileId: Int = -1 companion object { @@ -37,7 +42,7 @@ class NavigationDrawerTestActivity : (activityComponent as ActivityComponentImpl).inject(this) internalProfileId = intent?.getIntExtra(NAVIGATION_PROFILE_ID_ARGUMENT_KEY, -1)!! homeActivityPresenter.handleOnCreate() - title = getString(R.string.menu_home) + title = resourceHandler.getStringInLocale(R.string.menu_home) } override fun onRestart() { diff --git a/app/src/main/java/org/oppia/android/app/topic/TopicFragment.kt b/app/src/main/java/org/oppia/android/app/topic/TopicFragment.kt index e7f5a820bdb..1cc4ab14718 100644 --- a/app/src/main/java/org/oppia/android/app/topic/TopicFragment.kt +++ b/app/src/main/java/org/oppia/android/app/topic/TopicFragment.kt @@ -9,6 +9,7 @@ import org.oppia.android.app.fragment.InjectableFragment import org.oppia.android.domain.topic.TEST_TOPIC_ID_0 import javax.inject.Inject import org.oppia.android.app.fragment.FragmentComponentImpl +import org.oppia.android.util.extensions.getStringFromBundle /** Fragment that contains tabs for Topic. */ class TopicFragment : InjectableFragment() { @@ -26,8 +27,8 @@ class TopicFragment : InjectableFragment() { savedInstanceState: Bundle? ): View? { val internalProfileId = arguments?.getInt(PROFILE_ID_ARGUMENT_KEY) ?: -1 - val topicId = arguments?.getString(TOPIC_ID_ARGUMENT_KEY) ?: TEST_TOPIC_ID_0 - val storyId = arguments?.getString(STORY_ID_ARGUMENT_KEY) ?: "" + val topicId = arguments?.getStringFromBundle(TOPIC_ID_ARGUMENT_KEY) ?: TEST_TOPIC_ID_0 + val storyId = arguments?.getStringFromBundle(STORY_ID_ARGUMENT_KEY) ?: "" return topicFragmentPresenter.handleCreateView( inflater, diff --git a/app/src/main/java/org/oppia/android/app/topic/TopicFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/topic/TopicFragmentPresenter.kt index bb253642f86..4c2c644dc2d 100644 --- a/app/src/main/java/org/oppia/android/app/topic/TopicFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/topic/TopicFragmentPresenter.kt @@ -17,6 +17,7 @@ import org.oppia.android.databinding.TopicFragmentBinding import org.oppia.android.domain.oppialogger.OppiaLogger import org.oppia.android.util.system.OppiaClock import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler /** The presenter for [TopicFragment]. */ @FragmentScope @@ -26,7 +27,8 @@ class TopicFragmentPresenter @Inject constructor( private val viewModelProvider: ViewModelProvider, private val oppiaLogger: OppiaLogger, private val oppiaClock: OppiaClock, - @EnablePracticeTab private val enablePracticeTab: Boolean + @EnablePracticeTab private val enablePracticeTab: Boolean, + private val resourceHandler: AppLanguageResourceHandler ) { private lateinit var tabLayout: TabLayout private var internalProfileId: Int = -1 @@ -82,7 +84,7 @@ class TopicFragmentPresenter @Inject constructor( viewPager2.adapter = adapter TabLayoutMediator(tabLayout, viewPager2) { tab, position -> val topicTab = TopicTab.getTabForPosition(position, enablePracticeTab) - tab.text = fragment.getString(topicTab.tabLabelResId) + tab.text = resourceHandler.getStringInLocale(topicTab.tabLabelResId) tab.icon = ContextCompat.getDrawable(activity, topicTab.tabIconResId) }.attach() if (!isConfigChanged && topicId.isNotEmpty()) { diff --git a/app/src/main/java/org/oppia/android/app/topic/TopicViewModel.kt b/app/src/main/java/org/oppia/android/app/topic/TopicViewModel.kt index 8b20690bcb4..a9c3b28f488 100644 --- a/app/src/main/java/org/oppia/android/app/topic/TopicViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/topic/TopicViewModel.kt @@ -2,6 +2,7 @@ package org.oppia.android.app.topic import androidx.lifecycle.LiveData import androidx.lifecycle.Transformations +import org.oppia.android.R import org.oppia.android.app.fragment.FragmentScope import org.oppia.android.app.model.ProfileId import org.oppia.android.app.model.Topic @@ -11,12 +12,14 @@ import org.oppia.android.domain.topic.TopicController import org.oppia.android.util.data.AsyncResult import org.oppia.android.util.data.DataProviders.Companion.toLiveData import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler /** The ObservableViewModel for [TopicFragment]. */ @FragmentScope class TopicViewModel @Inject constructor( private val topicController: TopicController, - private val oppiaLogger: OppiaLogger + private val oppiaLogger: OppiaLogger, + private val resourceHandler: AppLanguageResourceHandler ) : ObservableViewModel() { private var internalProfileId: Int = -1 private lateinit var topicId: String @@ -32,8 +35,12 @@ class TopicViewModel @Inject constructor( Transformations.map(topicResultLiveData, ::processTopicResult) } - val topicNameLiveData: LiveData by lazy { - Transformations.map(topicLiveData, Topic::getName) + private val topicNameLiveData by lazy { Transformations.map(topicLiveData, Topic::getName) } + + val topicToolbarTitleLiveData: LiveData by lazy { + Transformations.map(topicNameLiveData) { name -> + resourceHandler.getStringInLocale(R.string.topic_name, name) + } } fun setInternalProfileId(internalProfileId: Int) { diff --git a/app/src/main/java/org/oppia/android/app/topic/conceptcard/ConceptCardFragment.kt b/app/src/main/java/org/oppia/android/app/topic/conceptcard/ConceptCardFragment.kt index d3c6d5dd377..bebf2d810b0 100644 --- a/app/src/main/java/org/oppia/android/app/topic/conceptcard/ConceptCardFragment.kt +++ b/app/src/main/java/org/oppia/android/app/topic/conceptcard/ConceptCardFragment.kt @@ -9,6 +9,7 @@ import org.oppia.android.R import org.oppia.android.app.fragment.InjectableDialogFragment import javax.inject.Inject import org.oppia.android.app.fragment.FragmentComponentImpl +import org.oppia.android.util.extensions.getStringFromBundle private const val SKILL_ID_ARGUMENT_KEY = "ConceptCardFragment.skill_id" @@ -56,7 +57,7 @@ class ConceptCardFragment : InjectableDialogFragment() { "Expected arguments to be passed to ConceptCardFragment" } val skillId = - checkNotNull(args.getString(SKILL_ID_ARGUMENT_KEY)) { + checkNotNull(args.getStringFromBundle(SKILL_ID_ARGUMENT_KEY)) { "Expected skillId to be passed to ConceptCardFragment" } return conceptCardFragmentPresenter.handleCreateView(inflater, container, skillId) diff --git a/app/src/main/java/org/oppia/android/app/topic/info/TopicInfoFragment.kt b/app/src/main/java/org/oppia/android/app/topic/info/TopicInfoFragment.kt index 7db7db05383..85ac1789340 100644 --- a/app/src/main/java/org/oppia/android/app/topic/info/TopicInfoFragment.kt +++ b/app/src/main/java/org/oppia/android/app/topic/info/TopicInfoFragment.kt @@ -10,6 +10,7 @@ import org.oppia.android.app.topic.PROFILE_ID_ARGUMENT_KEY import org.oppia.android.app.topic.TOPIC_ID_ARGUMENT_KEY import javax.inject.Inject import org.oppia.android.app.fragment.FragmentComponentImpl +import org.oppia.android.util.extensions.getStringFromBundle /** Fragment that contains info of Topic. */ class TopicInfoFragment : InjectableFragment() { @@ -39,7 +40,7 @@ class TopicInfoFragment : InjectableFragment() { savedInstanceState: Bundle? ): View? { val internalProfileId = arguments?.getInt(PROFILE_ID_ARGUMENT_KEY, -1)!! - val topicId = checkNotNull(arguments?.getString(TOPIC_ID_ARGUMENT_KEY)) { + val topicId = checkNotNull(arguments?.getStringFromBundle(TOPIC_ID_ARGUMENT_KEY)) { "Expected topic ID to be included in arguments for TopicInfoFragment." } return topicInfoFragmentPresenter.handleCreateView( diff --git a/app/src/main/java/org/oppia/android/app/topic/info/TopicInfoFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/topic/info/TopicInfoFragmentPresenter.kt index aff985c771c..8ea1b0ac18e 100644 --- a/app/src/main/java/org/oppia/android/app/topic/info/TopicInfoFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/topic/info/TopicInfoFragmentPresenter.kt @@ -74,9 +74,8 @@ class TopicInfoFragmentPresenter @Inject constructor( private fun subscribeToTopicLiveData() { topicLiveData.observe( - fragment, - Observer { topic -> - topicInfoViewModel.topic.set(topic) + fragment, { topic -> + topicInfoViewModel.setTopic(topic) topicInfoViewModel.topicDescription.set(topic.description) topicInfoViewModel.calculateTopicSizeWithUnit() controlSeeMoreTextVisibility() diff --git a/app/src/main/java/org/oppia/android/app/topic/info/TopicInfoViewModel.kt b/app/src/main/java/org/oppia/android/app/topic/info/TopicInfoViewModel.kt index fafff0721de..ad7e5288010 100644 --- a/app/src/main/java/org/oppia/android/app/topic/info/TopicInfoViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/topic/info/TopicInfoViewModel.kt @@ -9,36 +9,56 @@ import org.oppia.android.app.model.Topic import org.oppia.android.app.viewmodel.ObservableViewModel import org.oppia.android.util.parser.html.TopicHtmlParserEntityType import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler /** [ViewModel] for showing topic info details. */ @FragmentScope class TopicInfoViewModel @Inject constructor( private val context: Context, - @TopicHtmlParserEntityType val entityType: String + @TopicHtmlParserEntityType val entityType: String, + private val resourceHandler: AppLanguageResourceHandler ) : ObservableViewModel() { - val topic = ObservableField(Topic.getDefaultInstance()) - val topicSize = ObservableField("") + val topic = ObservableField(DEFAULT_TOPIC) + val storyCountText: ObservableField = + ObservableField(computeStoryCountText(DEFAULT_TOPIC)) + val topicSizeText: ObservableField = ObservableField("") val topicDescription = ObservableField("") var downloadStatusIndicatorDrawableResourceId = ObservableField(R.drawable.ic_available_offline_primary_24dp) - val isDescriptionExpanded = ObservableField(true) - val isSeeMoreVisible = ObservableField(true) + val isDescriptionExpanded = ObservableField(true) + val isSeeMoreVisible = ObservableField(true) + + fun setTopic(topic: Topic) { + this.topic.set(topic) + storyCountText.set(computeStoryCountText(topic)) + } fun calculateTopicSizeWithUnit() { + // TODO: file an issue to combine these strings into one. val sizeWithUnit = topic.get()?.let { topic -> val sizeInBytes: Int = topic.diskSizeBytes.toInt() val sizeInKb = sizeInBytes / 1024 val sizeInMb = sizeInKb / 1024 val sizeInGb = sizeInMb / 1024 return@let when { - sizeInGb >= 1 -> context.getString(R.string.size_gb, roundUpToHundreds(sizeInGb)) - sizeInMb >= 1 -> context.getString(R.string.size_mb, roundUpToHundreds(sizeInMb)) - sizeInKb >= 1 -> context.getString(R.string.size_kb, roundUpToHundreds(sizeInKb)) - else -> context.getString(R.string.size_bytes, roundUpToHundreds(sizeInBytes)) + sizeInGb >= 1 -> + resourceHandler.getStringInLocale(R.string.size_gb, roundUpToHundreds(sizeInGb)) + sizeInMb >= 1 -> + resourceHandler.getStringInLocale(R.string.size_mb, roundUpToHundreds(sizeInMb)) + sizeInKb >= 1 -> + resourceHandler.getStringInLocale(R.string.size_kb, roundUpToHundreds(sizeInKb)) + else -> + resourceHandler.getStringInLocale(R.string.size_bytes, roundUpToHundreds(sizeInBytes)) } - } ?: context.getString(R.string.unknown_size) - topicSize.set(sizeWithUnit) + } ?: resourceHandler.getStringInLocale(R.string.unknown_size) + topicSizeText.set(resourceHandler.getStringInLocale(R.string.topic_download_text, sizeWithUnit)) + } + + private fun computeStoryCountText(topic: Topic): String { + return resourceHandler.getQuantityStringInLocale( + R.plurals.story_count, topic.storyCount, topic.storyCount + ) } private fun roundUpToHundreds(intValue: Int): Int { @@ -48,4 +68,8 @@ class TopicInfoViewModel @Inject constructor( fun clickSeeMore() { isDescriptionExpanded.set(!isDescriptionExpanded.get()!!) } + + private companion object { + private val DEFAULT_TOPIC = Topic.getDefaultInstance() + } } diff --git a/app/src/main/java/org/oppia/android/app/topic/lessons/ChapterSummaryViewModel.kt b/app/src/main/java/org/oppia/android/app/topic/lessons/ChapterSummaryViewModel.kt index 07061c312ad..670305cb2d2 100644 --- a/app/src/main/java/org/oppia/android/app/topic/lessons/ChapterSummaryViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/topic/lessons/ChapterSummaryViewModel.kt @@ -1,7 +1,9 @@ package org.oppia.android.app.topic.lessons import androidx.lifecycle.ViewModel +import org.oppia.android.R import org.oppia.android.app.model.ChapterPlayState +import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.app.viewmodel.ObservableViewModel /** [ViewModel] for displaying a chapter summary. */ @@ -10,11 +12,24 @@ class ChapterSummaryViewModel( val explorationId: String, val chapterName: String, val storyId: String, - val index: Int, - private val chapterSummarySelector: ChapterSummarySelector + private val index: Int, + private val chapterSummarySelector: ChapterSummarySelector, + private val resourceHandler: AppLanguageResourceHandler ) : ObservableViewModel() { fun onClick(explorationId: String) { chapterSummarySelector.selectChapterSummary(storyId, explorationId, chapterPlayState) } + + fun computeChapterPlayStateIconContentDescription(): String { + return if (chapterPlayState == ChapterPlayState.COMPLETED) { + resourceHandler.getStringInLocale(R.string.chapter_completed, index + 1, chapterName) + } else { + resourceHandler.getStringInLocale(R.string.chapter_in_progress, index + 1, chapterName) + } + } + + fun computePlayChapterIndexText(): String { + return resourceHandler.getStringInLocale(R.string.topic_play_chapter_index, index + 1) + } } diff --git a/app/src/main/java/org/oppia/android/app/topic/lessons/StorySummaryViewModel.kt b/app/src/main/java/org/oppia/android/app/topic/lessons/StorySummaryViewModel.kt index 5dec2a94271..da0f5eb071e 100644 --- a/app/src/main/java/org/oppia/android/app/topic/lessons/StorySummaryViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/topic/lessons/StorySummaryViewModel.kt @@ -1,19 +1,60 @@ package org.oppia.android.app.topic.lessons +import androidx.databinding.ObservableField import androidx.lifecycle.ViewModel +import org.oppia.android.R import org.oppia.android.app.model.StorySummary +import org.oppia.android.app.translation.AppLanguageResourceHandler + +private const val DEFAULT_STORY_PERCENTAGE = 0 /** [ViewModel] for displaying a story summary. */ class StorySummaryViewModel( val storySummary: StorySummary, private val storySummarySelector: StorySummarySelector, - private val chapterSummarySelector: ChapterSummarySelector + private val chapterSummarySelector: ChapterSummarySelector, + private val resourceHandler: AppLanguageResourceHandler ) : TopicLessonsItemViewModel() { + val storyPercentage: ObservableField = ObservableField(DEFAULT_STORY_PERCENTAGE) + val storyProgressPercentageText: ObservableField = + ObservableField(computeStoryProgressPercentageText(DEFAULT_STORY_PERCENTAGE)) val chapterSummaryItemList: List by lazy { computeChapterSummaryItemList() } + fun clickOnStorySummaryTitle() { + storySummarySelector.selectStorySummary(storySummary) + } + + fun setStoryPercentage(storyPercentage: Int) { + this.storyPercentage.set(storyPercentage) + storyProgressPercentageText.set(computeStoryProgressPercentageText(storyPercentage)) + } + + fun computeStoryNameChapterCountContainerContentDescription(): String { + // TODO: file an issue to combine this into a single string. + val chapterCountText = + resourceHandler.getQuantityStringInLocale( + R.plurals.chapter_count, storySummary.chapterCount, storySummary.chapterCount + ) + return resourceHandler.getStringInLocale( + R.string.chapter_count_with_story_name, chapterCountText, storySummary.storyName + ) + } + + fun computeChapterCountText(): String { + return resourceHandler.getQuantityStringInLocale( + R.plurals.chapter_count, storySummary.chapterCount, storySummary.chapterCount + ) + } + + private fun computeStoryProgressPercentageText(storyPercentage: Int): String { + return resourceHandler.getStringInLocale( + R.string.topic_story_progress_percentage, storyPercentage + ) + } + private fun computeChapterSummaryItemList(): List { return storySummary.chapterList.mapIndexed { index, chapterSummary -> ChapterSummaryViewModel( @@ -22,12 +63,9 @@ class StorySummaryViewModel( chapterName = chapterSummary.name, storyId = storySummary.storyId, index = index, - chapterSummarySelector = chapterSummarySelector + chapterSummarySelector = chapterSummarySelector, + resourceHandler = resourceHandler ) } } - - fun clickOnStorySummaryTitle() { - storySummarySelector.selectStorySummary(storySummary) - } } diff --git a/app/src/main/java/org/oppia/android/app/topic/lessons/TopicLessonViewModel.kt b/app/src/main/java/org/oppia/android/app/topic/lessons/TopicLessonViewModel.kt index 7e910b89cb3..bc1c7dc473f 100644 --- a/app/src/main/java/org/oppia/android/app/topic/lessons/TopicLessonViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/topic/lessons/TopicLessonViewModel.kt @@ -13,13 +13,15 @@ import org.oppia.android.domain.topic.TopicController import org.oppia.android.util.data.AsyncResult import org.oppia.android.util.data.DataProviders.Companion.toLiveData import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler /** [ViewModel] for [TopicLessonsFragment]. */ @FragmentScope class TopicLessonViewModel @Inject constructor( private val fragment: Fragment, private val oppiaLogger: OppiaLogger, - private val topicController: TopicController + private val topicController: TopicController, + private val resourceHandler: AppLanguageResourceHandler ) { private var internalProfileId: Int = -1 private lateinit var topicId: String @@ -65,7 +67,8 @@ class TopicLessonViewModel @Inject constructor( StorySummaryViewModel( storySummary, fragment as StorySummarySelector, - fragment as ChapterSummarySelector + fragment as ChapterSummarySelector, + resourceHandler ) ) } diff --git a/app/src/main/java/org/oppia/android/app/topic/lessons/TopicLessonsFragment.kt b/app/src/main/java/org/oppia/android/app/topic/lessons/TopicLessonsFragment.kt index 33897f4c9bd..df01c281eb4 100644 --- a/app/src/main/java/org/oppia/android/app/topic/lessons/TopicLessonsFragment.kt +++ b/app/src/main/java/org/oppia/android/app/topic/lessons/TopicLessonsFragment.kt @@ -13,6 +13,7 @@ import org.oppia.android.app.topic.STORY_ID_ARGUMENT_KEY import org.oppia.android.app.topic.TOPIC_ID_ARGUMENT_KEY import javax.inject.Inject import org.oppia.android.app.fragment.FragmentComponentImpl +import org.oppia.android.util.extensions.getStringFromBundle private const val CURRENT_EXPANDED_LIST_INDEX_SAVED_KEY = "TopicLessonsFragment.current_expanded_list_index" @@ -67,10 +68,10 @@ class TopicLessonsFragment : } } val internalProfileId = arguments?.getInt(PROFILE_ID_ARGUMENT_KEY, -1)!! - val topicId = checkNotNull(arguments?.getString(TOPIC_ID_ARGUMENT_KEY)) { + val topicId = checkNotNull(arguments?.getStringFromBundle(TOPIC_ID_ARGUMENT_KEY)) { "Expected topic ID to be included in arguments for TopicLessonsFragment." } - val storyId = arguments?.getString(STORY_ID_ARGUMENT_KEY) ?: "" + val storyId = arguments?.getStringFromBundle(STORY_ID_ARGUMENT_KEY) ?: "" return topicLessonsFragmentPresenter.handleCreateView( inflater, diff --git a/app/src/main/java/org/oppia/android/app/topic/lessons/TopicLessonsFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/topic/lessons/TopicLessonsFragmentPresenter.kt index e0cdd83caf5..90669341e43 100644 --- a/app/src/main/java/org/oppia/android/app/topic/lessons/TopicLessonsFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/topic/lessons/TopicLessonsFragmentPresenter.kt @@ -164,7 +164,7 @@ class TopicLessonsFragmentPresenter @Inject constructor( val storyPercentage: Int = (completedChapterCount * 100) / storySummaryViewModel.storySummary.chapterCount - binding.storyPercentage = storyPercentage + storySummaryViewModel.setStoryPercentage(storyPercentage) binding.storyProgressView.setStoryChapterDetails( storySummaryViewModel.storySummary.chapterCount, completedChapterCount, diff --git a/app/src/main/java/org/oppia/android/app/topic/practice/TopicPracticeFragment.kt b/app/src/main/java/org/oppia/android/app/topic/practice/TopicPracticeFragment.kt index b3425b8966a..09f37e63b0f 100644 --- a/app/src/main/java/org/oppia/android/app/topic/practice/TopicPracticeFragment.kt +++ b/app/src/main/java/org/oppia/android/app/topic/practice/TopicPracticeFragment.kt @@ -10,6 +10,7 @@ import org.oppia.android.app.topic.PROFILE_ID_ARGUMENT_KEY import org.oppia.android.app.topic.TOPIC_ID_ARGUMENT_KEY import javax.inject.Inject import org.oppia.android.app.fragment.FragmentComponentImpl +import org.oppia.android.util.extensions.getStringFromBundle /** Fragment that displays skills for topic practice mode. */ class TopicPracticeFragment : InjectableFragment() { @@ -49,7 +50,7 @@ class TopicPracticeFragment : InjectableFragment() { .getSerializable(SKILL_ID_LIST_ARGUMENT_KEY)!! as HashMap> } val internalProfileId = arguments?.getInt(PROFILE_ID_ARGUMENT_KEY, -1)!! - val topicId = checkNotNull(arguments?.getString(TOPIC_ID_ARGUMENT_KEY)) { + val topicId = checkNotNull(arguments?.getStringFromBundle(TOPIC_ID_ARGUMENT_KEY)) { "Expected topic ID to be included in arguments for TopicPracticeFragment." } return topicPracticeFragmentPresenter.handleCreateView( diff --git a/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerViewModel.kt b/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerViewModel.kt index a81352b4b55..bfdeb36344f 100644 --- a/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/topic/questionplayer/QuestionPlayerViewModel.kt @@ -3,6 +3,7 @@ package org.oppia.android.app.topic.questionplayer import androidx.databinding.ObservableBoolean import androidx.databinding.ObservableField import androidx.databinding.ObservableList +import org.oppia.android.app.R import org.oppia.android.app.model.UserAnswer import org.oppia.android.app.player.state.answerhandling.AnswerErrorCategory import org.oppia.android.app.player.state.answerhandling.InteractionAnswerHandler @@ -10,9 +11,12 @@ import org.oppia.android.app.player.state.itemviewmodel.StateItemViewModel import org.oppia.android.app.viewmodel.ObservableArrayList import org.oppia.android.app.viewmodel.ObservableViewModel import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler /** [ObservableViewModel] for the question player. */ -class QuestionPlayerViewModel @Inject constructor() : ObservableViewModel() { +class QuestionPlayerViewModel @Inject constructor( + private val resourceHandler: AppLanguageResourceHandler +) : ObservableViewModel() { val itemList: ObservableList = ObservableArrayList() val rightItemList: ObservableList = ObservableArrayList() @@ -50,6 +54,16 @@ class QuestionPlayerViewModel @Inject constructor() : ObservableViewModel() { ) ?: UserAnswer.getDefaultInstance() } + fun computeQuestionProgressText(): String { + return if (isAtEndOfSession.get()) { + resourceHandler.getStringInLocale(R.string.question_training_session_progress_finished) + } else { + resourceHandler.getStringInLocale( + R.string.question_training_session_progress, currentQuestion, questionCount + ) + } + } + private fun getPendingAnswerWithoutError( answerHandler: InteractionAnswerHandler? ): UserAnswer? { diff --git a/app/src/main/java/org/oppia/android/app/topic/revision/TopicRevisionFragment.kt b/app/src/main/java/org/oppia/android/app/topic/revision/TopicRevisionFragment.kt index 5310dab0de5..cf83b295edf 100755 --- a/app/src/main/java/org/oppia/android/app/topic/revision/TopicRevisionFragment.kt +++ b/app/src/main/java/org/oppia/android/app/topic/revision/TopicRevisionFragment.kt @@ -11,6 +11,7 @@ import org.oppia.android.app.topic.PROFILE_ID_ARGUMENT_KEY import org.oppia.android.app.topic.TOPIC_ID_ARGUMENT_KEY import javax.inject.Inject import org.oppia.android.app.fragment.FragmentComponentImpl +import org.oppia.android.util.extensions.getStringFromBundle /** Fragment that card for topic revision. */ class TopicRevisionFragment : InjectableFragment(), RevisionSubtopicSelector { @@ -42,7 +43,7 @@ class TopicRevisionFragment : InjectableFragment(), RevisionSubtopicSelector { savedInstanceState: Bundle? ): View? { val internalProfileId = arguments?.getInt(PROFILE_ID_ARGUMENT_KEY, -1)!! - val topicId = checkNotNull(arguments?.getString(TOPIC_ID_ARGUMENT_KEY)) { + val topicId = checkNotNull(arguments?.getStringFromBundle(TOPIC_ID_ARGUMENT_KEY)) { "Expected topic ID to be included in arguments for TopicRevisionFragment." } return topicReviewFragmentPresenter.handleCreateView( diff --git a/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardFragment.kt b/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardFragment.kt index 8381b41a1fd..c0fe5e6bce8 100755 --- a/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardFragment.kt +++ b/app/src/main/java/org/oppia/android/app/topic/revisioncard/RevisionCardFragment.kt @@ -8,6 +8,7 @@ import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableDialogFragment import javax.inject.Inject import org.oppia.android.app.fragment.FragmentComponentImpl +import org.oppia.android.util.extensions.getStringFromBundle /* Fragment that displays revision card */ class RevisionCardFragment : InjectableDialogFragment() { @@ -44,7 +45,7 @@ class RevisionCardFragment : InjectableDialogFragment() { "Expected arguments to be passed to StoryFragment" } val topicId = - checkNotNull(args.getString(TOPIC_ID_ARGUMENT_KEY)) { + checkNotNull(args.getStringFromBundle(TOPIC_ID_ARGUMENT_KEY)) { "Expected topicId to be passed to RevisionCardFragment" } val subtopicId = args.getInt(SUBTOPIC_ID_ARGUMENT_KEY, -1) diff --git a/app/src/main/java/org/oppia/android/app/translation/AppLanguageActivityInjector.kt b/app/src/main/java/org/oppia/android/app/translation/AppLanguageActivityInjector.kt index 9cba41ef4fe..06ce5622cce 100644 --- a/app/src/main/java/org/oppia/android/app/translation/AppLanguageActivityInjector.kt +++ b/app/src/main/java/org/oppia/android/app/translation/AppLanguageActivityInjector.kt @@ -2,4 +2,6 @@ package org.oppia.android.app.translation interface AppLanguageActivityInjector { fun getAppLanguageWatcherMixin(): AppLanguageWatcherMixin + + fun getAppLanguageResourceHandler(): AppLanguageResourceHandler } diff --git a/app/src/main/java/org/oppia/android/app/translation/AppLanguageActivityInjectorProvider.kt b/app/src/main/java/org/oppia/android/app/translation/AppLanguageActivityInjectorProvider.kt new file mode 100644 index 00000000000..ef6c95e9054 --- /dev/null +++ b/app/src/main/java/org/oppia/android/app/translation/AppLanguageActivityInjectorProvider.kt @@ -0,0 +1,5 @@ +package org.oppia.android.app.translation + +interface AppLanguageActivityInjectorProvider { + fun getAppLanguageActivityInjector(): AppLanguageActivityInjector +} diff --git a/app/src/main/java/org/oppia/android/app/translation/AppLanguageLocaleHandler.kt b/app/src/main/java/org/oppia/android/app/translation/AppLanguageLocaleHandler.kt index 0fe879543e0..540991b7391 100644 --- a/app/src/main/java/org/oppia/android/app/translation/AppLanguageLocaleHandler.kt +++ b/app/src/main/java/org/oppia/android/app/translation/AppLanguageLocaleHandler.kt @@ -4,7 +4,7 @@ import android.content.res.Configuration import javax.inject.Inject import javax.inject.Singleton import org.oppia.android.domain.locale.LocaleController -import org.oppia.android.domain.locale.OppiaLocale +import org.oppia.android.util.locale.OppiaLocale @Singleton class AppLanguageLocaleHandler @Inject constructor( diff --git a/app/src/main/java/org/oppia/android/app/translation/AppLanguageResourceHandler.kt b/app/src/main/java/org/oppia/android/app/translation/AppLanguageResourceHandler.kt index 23bbaf834bb..90f642b106e 100644 --- a/app/src/main/java/org/oppia/android/app/translation/AppLanguageResourceHandler.kt +++ b/app/src/main/java/org/oppia/android/app/translation/AppLanguageResourceHandler.kt @@ -1,10 +1,11 @@ package org.oppia.android.app.translation import androidx.annotation.ArrayRes +import androidx.annotation.PluralsRes import androidx.annotation.StringRes import androidx.appcompat.app.AppCompatActivity import javax.inject.Inject -import org.oppia.android.domain.locale.OppiaLocale +import org.oppia.android.util.locale.OppiaLocale class AppLanguageResourceHandler @Inject constructor( private val activity: AppCompatActivity, @@ -12,21 +13,42 @@ class AppLanguageResourceHandler @Inject constructor( ) { private val resources by lazy { activity.resources } + fun formatInLocale(format: String, vararg formatArgs: Any?): String { + return getDisplayLocale().run { format.formatInLocale(*formatArgs) } + } + + fun capitalizeForHumans(str: String): String { + return getDisplayLocale().run { str.capitalizeForHumans() } + } + fun getStringInLocale(@StringRes id: Int): String { -// ensureLocaleIsInitialized() return getDisplayLocale().run { resources.getStringInLocale(id) } } fun getStringInLocale(@StringRes id: Int, vararg formatArgs: Any?): String { -// ensureLocaleIsInitialized() return getDisplayLocale().run { resources.getStringInLocale(id, *formatArgs) } } fun getStringArrayInLocale(@ArrayRes id: Int): List { -// ensureLocaleIsInitialized() return getDisplayLocale().run { resources.getStringArrayInLocale(id) } } + fun getQuantityStringInLocale(@PluralsRes id: Int, quantity: Int): String { + return getDisplayLocale().run { resources.getQuantityStringInLocale(id, quantity) } + } + + fun getQuantityStringInLocale( + @PluralsRes id: Int, quantity: Int, vararg formatArgs: Any? + ): String { + return getDisplayLocale().run { resources.getQuantityStringInLocale(id, quantity, *formatArgs) } + } + + fun computeDateString(timestampMillis: Long): String = + getDisplayLocale().computeDateString(timestampMillis) + + fun computeDateTimeString(timestampMillis: Long): String = + getDisplayLocale().computeDateTimeString(timestampMillis) + private fun getDisplayLocale(): OppiaLocale.DisplayLocale = appLanguageLocaleHandler.getDisplayLocale() } diff --git a/app/src/main/java/org/oppia/android/app/translation/BUILD.bazel b/app/src/main/java/org/oppia/android/app/translation/BUILD.bazel index 9dcba626821..53ce523f08f 100644 --- a/app/src/main/java/org/oppia/android/app/translation/BUILD.bazel +++ b/app/src/main/java/org/oppia/android/app/translation/BUILD.bazel @@ -10,7 +10,6 @@ kt_android_library( srcs = [ "AppLanguageLocaleHandler.kt", ], - # visibility = ["//app:app_visibility"], deps = [ ":dagger", "//domain/src/main/java/org/oppia/android/domain/locale:locale_controller", @@ -26,8 +25,8 @@ kt_android_library( deps = [ ":app_language_locale_handler", ":dagger", - "//domain/src/main/java/org/oppia/android/domain/locale:oppia_locale", "//third_party:androidx_appcompat_appcompat", + "//utility/src/main/java/org/oppia/android/util/locale:oppia_locale", ], ) @@ -53,10 +52,25 @@ kt_android_library( "//app/src/main/java/org/oppia/android/app/activity:__pkg__", ], deps = [ + ":app_language_resource_handler", ":app_language_watcher_mixin", ], ) +kt_android_library( + name = "app_language_activity_injector_provider", + srcs = [ + "AppLanguageActivityInjectorProvider.kt", + ], + visibility = [ + "//app:__pkg__", + "//app/src/main/java/org/oppia/android/app/activity:__pkg__", + ], + deps = [ + ":app_language_activity_injector", + ], +) + kt_android_library( name = "app_language_application_injector", srcs = [ diff --git a/app/src/main/java/org/oppia/android/app/utility/RatioExtensions.kt b/app/src/main/java/org/oppia/android/app/utility/RatioExtensions.kt index 95c9d1fe5ac..4ac0d17fd5e 100644 --- a/app/src/main/java/org/oppia/android/app/utility/RatioExtensions.kt +++ b/app/src/main/java/org/oppia/android/app/utility/RatioExtensions.kt @@ -1,15 +1,15 @@ package org.oppia.android.app.utility -import android.content.Context import org.oppia.android.R import org.oppia.android.app.model.RatioExpression +import org.oppia.android.app.translation.AppLanguageResourceHandler /** * Returns an accessibly readable string representation of this [RatioExpression]. * E.g. [1, 2, 3] will yield to 1 to 2 to 3 */ -fun RatioExpression.toAccessibleAnswerString(context: Context): String { +fun RatioExpression.toAccessibleAnswerString(resourceHandler: AppLanguageResourceHandler): String { return ratioComponentList.joinToString( - context.getString(R.string.ratio_content_description_separator) + resourceHandler.getStringInLocale(R.string.ratio_content_description_separator) ) } diff --git a/app/src/main/java/org/oppia/android/app/utility/datetime/BUILD.bazel b/app/src/main/java/org/oppia/android/app/utility/datetime/BUILD.bazel index 2d8f423c5e3..c65b5547153 100644 --- a/app/src/main/java/org/oppia/android/app/utility/datetime/BUILD.bazel +++ b/app/src/main/java/org/oppia/android/app/utility/datetime/BUILD.bazel @@ -25,7 +25,9 @@ kt_android_library( deps = [ ":dagger", "//app:resources", + "//app/src/main/java/org/oppia/android/app/translation:app_language_resource_handler", "//third_party:javax_inject_javax_inject", + "//utility/src/main/java/org/oppia/android/util/locale:oppia_locale", "//utility/src/main/java/org/oppia/android/util/system:oppia_clock", ], ) diff --git a/app/src/main/java/org/oppia/android/app/utility/datetime/DateTimeUtil.kt b/app/src/main/java/org/oppia/android/app/utility/datetime/DateTimeUtil.kt index b8b8ca80df8..9af78b81393 100644 --- a/app/src/main/java/org/oppia/android/app/utility/datetime/DateTimeUtil.kt +++ b/app/src/main/java/org/oppia/android/app/utility/datetime/DateTimeUtil.kt @@ -1,29 +1,27 @@ package org.oppia.android.app.utility.datetime -import android.content.Context -import org.oppia.android.R -import org.oppia.android.util.system.OppiaClock -import java.util.Calendar import javax.inject.Inject -import javax.inject.Singleton +import org.oppia.android.R +import org.oppia.android.app.translation.AppLanguageResourceHandler +import org.oppia.android.util.locale.OppiaLocale /** Utility to manage date and time for user-facing strings. */ -@Singleton class DateTimeUtil @Inject constructor( - private val context: Context, - private val oppiaClock: OppiaClock + private val machineLocale: OppiaLocale.MachineLocale, + private val resourceHandler: AppLanguageResourceHandler ) { /** * Returns a user-readable string based on the time of day (to be concatenated as part of a * greeting for the user). */ fun getGreetingMessage(): String { - val calender = oppiaClock.getCurrentCalendar() - return when (calender.get(Calendar.HOUR_OF_DAY)) { - in 4..11 -> context.getString(R.string.home_screen_good_morning_greeting_fragment) - in 12..16 -> context.getString(R.string.home_screen_good_afternoon_greeting_fragment) - in 17 downTo 3 -> context.getString(R.string.home_screen_good_evening_greeting_fragment) - else -> context.getString(R.string.home_screen_good_evening_greeting_fragment) + return when (machineLocale.getCurrentTimeOfDay()) { + OppiaLocale.MachineLocale.TimeOfDay.MORNING -> + resourceHandler.getStringInLocale(R.string.home_screen_good_morning_greeting_fragment) + OppiaLocale.MachineLocale.TimeOfDay.AFTERNOON -> + resourceHandler.getStringInLocale(R.string.home_screen_good_afternoon_greeting_fragment) + OppiaLocale.MachineLocale.TimeOfDay.EVENING, null -> + resourceHandler.getStringInLocale(R.string.home_screen_good_evening_greeting_fragment) } } } diff --git a/app/src/main/java/org/oppia/android/app/walkthrough/end/WalkthroughFinalFragment.kt b/app/src/main/java/org/oppia/android/app/walkthrough/end/WalkthroughFinalFragment.kt index 25e5d070721..1e4e2b008ae 100644 --- a/app/src/main/java/org/oppia/android/app/walkthrough/end/WalkthroughFinalFragment.kt +++ b/app/src/main/java/org/oppia/android/app/walkthrough/end/WalkthroughFinalFragment.kt @@ -8,6 +8,7 @@ import android.view.ViewGroup import org.oppia.android.app.fragment.InjectableFragment import javax.inject.Inject import org.oppia.android.app.fragment.FragmentComponentImpl +import org.oppia.android.util.extensions.getStringFromBundle private const val KEY_TOPIC_ID_ARGUMENT = "TOPIC_ID" @@ -42,7 +43,7 @@ class WalkthroughFinalFragment : InjectableFragment() { "Expected arguments to be passed to WalkthroughFinalFragment" } val topicId = - checkNotNull(args.getString(KEY_TOPIC_ID_ARGUMENT)) { + checkNotNull(args.getStringFromBundle(KEY_TOPIC_ID_ARGUMENT)) { "Expected topicId to be passed to WalkthroughFinalFragment" } return walkthroughFinalFragmentPresenter.handleCreateView(inflater, container, topicId) diff --git a/app/src/main/java/org/oppia/android/app/walkthrough/end/WalkthroughFinalFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/walkthrough/end/WalkthroughFinalFragmentPresenter.kt index 9ffc15d0465..d88aef089c7 100644 --- a/app/src/main/java/org/oppia/android/app/walkthrough/end/WalkthroughFinalFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/walkthrough/end/WalkthroughFinalFragmentPresenter.kt @@ -19,6 +19,7 @@ import org.oppia.android.domain.topic.TopicController import org.oppia.android.util.data.AsyncResult import org.oppia.android.util.data.DataProviders.Companion.toLiveData import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler /** The presenter for [WalkthroughFinalFragment]. */ @FragmentScope @@ -26,7 +27,8 @@ class WalkthroughFinalFragmentPresenter @Inject constructor( private val activity: AppCompatActivity, private val fragment: Fragment, private val oppiaLogger: OppiaLogger, - private val topicController: TopicController + private val topicController: TopicController, + private val resourceHandler: AppLanguageResourceHandler ) : WalkthroughEndPageChanger { private lateinit var binding: WalkthroughFinalFragmentBinding private lateinit var walkthroughFinalViewModel: WalkthroughFinalViewModel @@ -74,7 +76,7 @@ class WalkthroughFinalFragmentPresenter @Inject constructor( private fun setTopicName() { if (::walkthroughFinalViewModel.isInitialized && ::topicName.isInitialized) { walkthroughFinalViewModel.topicTitle.set( - activity.getString( + resourceHandler.getStringInLocale( R.string.are_you_interested, topicName ) diff --git a/app/src/main/java/org/oppia/android/app/walkthrough/topiclist/WalkthroughTopicViewModel.kt b/app/src/main/java/org/oppia/android/app/walkthrough/topiclist/WalkthroughTopicViewModel.kt index 490e3971b75..e3fd0166f94 100644 --- a/app/src/main/java/org/oppia/android/app/walkthrough/topiclist/WalkthroughTopicViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/walkthrough/topiclist/WalkthroughTopicViewModel.kt @@ -14,13 +14,15 @@ import org.oppia.android.util.data.AsyncResult import org.oppia.android.util.data.DataProviders.Companion.toLiveData import org.oppia.android.util.parser.html.TopicHtmlParserEntityType import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler /** The ObservableViewModel for [WalkthroughTopicListFragment]. */ class WalkthroughTopicViewModel @Inject constructor( private val fragment: Fragment, private val topicListController: TopicListController, private val oppiaLogger: OppiaLogger, - @TopicHtmlParserEntityType private val topicEntityType: String + @TopicHtmlParserEntityType private val topicEntityType: String, + private val resourceHandler: AppLanguageResourceHandler ) : ObservableViewModel() { val walkthroughTopicViewModelLiveData: LiveData> by lazy { Transformations.map(topicListSummaryLiveData, ::processCompletedTopicList) @@ -57,7 +59,8 @@ class WalkthroughTopicViewModel @Inject constructor( WalkthroughTopicSummaryViewModel( topicEntityType, topic, - fragment as TopicSummaryClickListener + fragment as TopicSummaryClickListener, + resourceHandler ) } ) diff --git a/app/src/main/java/org/oppia/android/app/walkthrough/topiclist/topiclistviewmodel/WalkthroughTopicSummaryViewModel.kt b/app/src/main/java/org/oppia/android/app/walkthrough/topiclist/topiclistviewmodel/WalkthroughTopicSummaryViewModel.kt index baa7bc37eff..ecf67e458c0 100644 --- a/app/src/main/java/org/oppia/android/app/walkthrough/topiclist/topiclistviewmodel/WalkthroughTopicSummaryViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/walkthrough/topiclist/topiclistviewmodel/WalkthroughTopicSummaryViewModel.kt @@ -2,20 +2,20 @@ package org.oppia.android.app.walkthrough.topiclist.topiclistviewmodel import androidx.annotation.ColorInt import androidx.lifecycle.ViewModel +import org.oppia.android.R import org.oppia.android.app.home.topiclist.TopicSummaryClickListener import org.oppia.android.app.model.TopicSummary +import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.app.walkthrough.topiclist.WalkthroughTopicItemViewModel -/** The view model corresponding to topic summaries in the topic summary RecyclerView. */ - /** [ViewModel] corresponding to topic summaries in [WalkthroughTopicListFragment] RecyclerView.. */ class WalkthroughTopicSummaryViewModel( val topicEntityType: String, val topicSummary: TopicSummary, - private val topicSummaryClickListener: TopicSummaryClickListener + private val topicSummaryClickListener: TopicSummaryClickListener, + private val resourceHandler: AppLanguageResourceHandler ) : WalkthroughTopicItemViewModel() { val name: String = topicSummary.name - val totalChapterCount: Int = topicSummary.totalChapterCount @ColorInt val backgroundColor: Int = retrieveBackgroundColor() @@ -25,6 +25,12 @@ class WalkthroughTopicSummaryViewModel( topicSummaryClickListener.onTopicSummaryClicked(topicSummary) } + fun computeWalkthroughLessonCountText(): String { + return resourceHandler.getQuantityStringInLocale( + R.plurals.lesson_count, topicSummary.totalChapterCount, topicSummary.totalChapterCount + ) + } + @ColorInt private fun retrieveBackgroundColor(): Int { return (0xff000000L or topicSummary.topicThumbnail.backgroundColorRgb.toLong()).toInt() diff --git a/app/src/main/java/org/oppia/android/app/walkthrough/welcome/WalkthroughWelcomeFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/walkthrough/welcome/WalkthroughWelcomeFragmentPresenter.kt index 069a6a5b16e..151d7b45592 100644 --- a/app/src/main/java/org/oppia/android/app/walkthrough/welcome/WalkthroughWelcomeFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/walkthrough/welcome/WalkthroughWelcomeFragmentPresenter.kt @@ -22,6 +22,7 @@ import org.oppia.android.domain.profile.ProfileManagementController import org.oppia.android.util.data.AsyncResult import org.oppia.android.util.data.DataProviders.Companion.toLiveData import javax.inject.Inject +import org.oppia.android.app.translation.AppLanguageResourceHandler /** The presenter for [WalkthroughWelcomeFragment]. */ @FragmentScope @@ -29,7 +30,8 @@ class WalkthroughWelcomeFragmentPresenter @Inject constructor( private val activity: AppCompatActivity, private val fragment: Fragment, private val profileManagementController: ProfileManagementController, - private val oppiaLogger: OppiaLogger + private val oppiaLogger: OppiaLogger, + private val resourceHandler: AppLanguageResourceHandler ) : WalkthroughPageChanger { private lateinit var binding: WalkthroughWelcomeFragmentBinding private val routeToNextPage = activity as WalkthroughFragmentChangeListener @@ -97,7 +99,9 @@ class WalkthroughWelcomeFragmentPresenter @Inject constructor( private fun setProfileName() { if (::walkthroughWelcomeViewModel.isInitialized && ::profileName.isInitialized) { - walkthroughWelcomeViewModel.profileName.set(activity.getString(R.string.welcome, profileName)) + walkthroughWelcomeViewModel.profileName.set( + resourceHandler.getStringInLocale(R.string.welcome, profileName) + ) } } diff --git a/app/src/main/res/layout-land/hints_summary.xml b/app/src/main/res/layout-land/hints_summary.xml index 534fbc022c8..c1dcba67a30 100644 --- a/app/src/main/res/layout-land/hints_summary.xml +++ b/app/src/main/res/layout-land/hints_summary.xml @@ -82,7 +82,7 @@ android:layout_width="48dp" android:layout_height="48dp" android:layout_gravity="center_vertical" - android:contentDescription="@{@string/show_hide_hint_list(viewModel.hintsAndSolutionSummary)}" + android:contentDescription="@{viewModel.computeHintListDropDownIconContentDescription()}" android:padding="8dp" android:src="@drawable/ic_arrow_drop_down_black_24dp" app:isRotationAnimationClockwise="@{isListExpanded}" diff --git a/app/src/main/res/layout-land/lessons_chapter_view.xml b/app/src/main/res/layout-land/lessons_chapter_view.xml index 4260c5c9300..3b7897fb411 100644 --- a/app/src/main/res/layout-land/lessons_chapter_view.xml +++ b/app/src/main/res/layout-land/lessons_chapter_view.xml @@ -30,7 +30,7 @@ android:layout_height="16dp" android:layout_marginStart="16dp" android:layout_marginEnd="8dp" - android:contentDescription="@{viewModel.chapterPlayState == ChapterPlayState.COMPLETED?String.format(@string/chapter_in_progress, (viewModel.index + 1), viewModel.chapterName):String.format(@string/chapter_completed, (viewModel.index + 1), viewModel.chapterName)}" + android:contentDescription="@{viewModel.computeChapterPlayStateIconContentDescription()}" android:src="@{viewModel.chapterPlayState == ChapterPlayState.COMPLETED?@drawable/ic_check_24dp:@drawable/ic_pending_24dp}" android:visibility="@{(viewModel.chapterPlayState == ChapterPlayState.COMPLETED || viewModel.chapterPlayState == ChapterPlayState.IN_PROGRESS_SAVED)?View.VISIBLE : View.INVISIBLE}" /> @@ -41,7 +41,7 @@ android:layout_marginStart="4dp" android:fontFamily="sans-serif" android:importantForAccessibility="@{viewModel.chapterPlayState != ChapterPlayState.NOT_PLAYABLE_MISSING_PREREQUISITES ? View.IMPORTANT_FOR_ACCESSIBILITY_YES : View.IMPORTANT_FOR_ACCESSIBILITY_NO}" - android:text="@{String.format(@string/topic_play_chapter_index, (viewModel.index + 1))}" + android:text="@{viewModel.computePlayChapterIndexText()}" android:textColor="@{viewModel.chapterPlayState != ChapterPlayState.NOT_PLAYABLE_MISSING_PREREQUISITES ? @color/oppiaPrimaryText : @color/oppiaPrimaryText30}" android:textSize="14sp" /> diff --git a/app/src/main/res/layout-land/onboarding_fragment.xml b/app/src/main/res/layout-land/onboarding_fragment.xml index 7a926410e50..8d750098ad5 100644 --- a/app/src/main/res/layout-land/onboarding_fragment.xml +++ b/app/src/main/res/layout-land/onboarding_fragment.xml @@ -53,7 +53,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp" - android:contentDescription="@{String.format(@string/onboarding_slide_dots_content_description((viewModel.slideNumber + 1), viewModel.totalNumberOfSlides))}" + android:contentDescription="@{viewModel.slideDotsContainerContentDescription}" android:gravity="center" android:minHeight="48dp" android:orientation="horizontal" diff --git a/app/src/main/res/layout-land/ongoing_story_card.xml b/app/src/main/res/layout-land/ongoing_story_card.xml index 4ab3bbfc3a0..a5ef17f42c9 100755 --- a/app/src/main/res/layout-land/ongoing_story_card.xml +++ b/app/src/main/res/layout-land/ongoing_story_card.xml @@ -34,7 +34,7 @@ android:id="@+id/lesson_thumbnail" android:layout_width="0dp" android:layout_height="0dp" - android:contentDescription="@{@string/lesson_thumbnail_content_description(viewModel.ongoingStory.nextChapterName)}" + android:contentDescription="@{viewModel.computeLessonThumbnailContentDescription()}" android:importantForAccessibility="no" app:entityId="@{viewModel.ongoingStory.storyId}" app:entityType="@{viewModel.entityType}" diff --git a/app/src/main/res/layout-land/ongoing_topic_item.xml b/app/src/main/res/layout-land/ongoing_topic_item.xml index dc3b2734e19..82beb06af27 100644 --- a/app/src/main/res/layout-land/ongoing_topic_item.xml +++ b/app/src/main/res/layout-land/ongoing_topic_item.xml @@ -84,7 +84,7 @@ android:layout_marginEnd="8dp" android:fontFamily="sans-serif-light" android:paddingBottom="12dp" - android:text="@{@plurals/lesson_count(viewModel.topic.storyCount, viewModel.topic.storyCount)}" + android:text="@{viewModel.computeStoryCountText()}" android:textAlignment="viewStart" android:textColor="@color/white_80" android:textSize="14sp" diff --git a/app/src/main/res/layout-land/pin_password_activity.xml b/app/src/main/res/layout-land/pin_password_activity.xml index 79f0ca61cbe..b6887171bf0 100644 --- a/app/src/main/res/layout-land/pin_password_activity.xml +++ b/app/src/main/res/layout-land/pin_password_activity.xml @@ -27,7 +27,7 @@ android:fontFamily="sans-serif" android:gravity="center_horizontal" android:lines="1" - android:text="@{String.format(@string/pin_password_hello, viewModel.profile.name)}" + android:text="@{viewModel.helloText}" android:textColor="@color/oppiaPrimaryText" android:textSize="24sp" app:layout_constraintEnd_toEndOf="parent" diff --git a/app/src/main/res/layout-land/profile_progress_recently_played_story_card.xml b/app/src/main/res/layout-land/profile_progress_recently_played_story_card.xml index bc91b31ff58..5a9ae1a40c1 100755 --- a/app/src/main/res/layout-land/profile_progress_recently_played_story_card.xml +++ b/app/src/main/res/layout-land/profile_progress_recently_played_story_card.xml @@ -32,7 +32,7 @@ android:id="@+id/lesson_thumbnail" android:layout_width="0dp" android:layout_height="0dp" - android:contentDescription="@{@string/lesson_thumbnail_content_description(viewModel.promotedStory.nextChapterName)}" + android:contentDescription="@{viewModel.computeLessonThumbnailContentDescription()}" android:importantForAccessibility="no" app:entityId="@{viewModel.promotedStory.storyId}" app:entityType="@{viewModel.entityType}" diff --git a/app/src/main/res/layout-land/question_player_fragment.xml b/app/src/main/res/layout-land/question_player_fragment.xml index 1783cfd7699..eb9a116a174 100644 --- a/app/src/main/res/layout-land/question_player_fragment.xml +++ b/app/src/main/res/layout-land/question_player_fragment.xml @@ -112,7 +112,7 @@ android:layout_marginEnd="@dimen/question_player_progress_bar_text_margin_end" android:layout_marginBottom="@dimen/question_player_progress_bar_text_margin_bottom" android:fontFamily="sans-serif" - android:text="@{viewModel.isAtEndOfSession ? @string/question_training_session_progress_finished : @string/question_training_session_progress(viewModel.currentQuestion, viewModel.questionCount)}" + android:text="@{viewModel.computeQuestionProgressText()}" android:textColor="@color/oppiaPrimaryText" android:textSize="12sp" android:textStyle="italic" diff --git a/app/src/main/res/layout-land/solution_summary.xml b/app/src/main/res/layout-land/solution_summary.xml index 01b211ad726..30f6bd21aec 100644 --- a/app/src/main/res/layout-land/solution_summary.xml +++ b/app/src/main/res/layout-land/solution_summary.xml @@ -86,7 +86,7 @@ android:layout_height="48dp" android:padding="8dp" android:layout_gravity="center_vertical" - android:contentDescription="@{@string/show_hide_solution_list(viewModel.solutionSummary)}" + android:contentDescription="@string/show_hide_solution_list" android:src="@drawable/ic_arrow_drop_down_black_24dp" app:isRotationAnimationClockwise="@{isListExpanded}" app:rotationAnimationAngle="@{180f}" /> diff --git a/app/src/main/res/layout-land/story_chapter_view.xml b/app/src/main/res/layout-land/story_chapter_view.xml index de42e3bec98..716493dffb0 100644 --- a/app/src/main/res/layout-land/story_chapter_view.xml +++ b/app/src/main/res/layout-land/story_chapter_view.xml @@ -59,7 +59,7 @@ android:ellipsize="end" android:fontFamily="sans-serif-medium" android:maxLines="2" - android:text="@{String.format(@string/chapter_name, (viewModel.index + 1), viewModel.name)}" + android:text="@{viewModel.computeChapterTitleText()}" android:textColor="@color/oppiaPrimaryText" android:textSize="18sp" app:layout_constraintEnd_toEndOf="parent" diff --git a/app/src/main/res/layout-land/story_header_view.xml b/app/src/main/res/layout-land/story_header_view.xml index be2a2650762..b73ca2c64d3 100644 --- a/app/src/main/res/layout-land/story_header_view.xml +++ b/app/src/main/res/layout-land/story_header_view.xml @@ -18,7 +18,7 @@ android:layout_marginEnd="72dp" android:layout_marginBottom="8dp" android:fontFamily="sans-serif-medium" - android:text="@{@plurals/story_total_chapters(viewModel.totalChapters, viewModel.completedChapters, viewModel.totalChapters)}" + android:text="@{viewModel.computeStoryProgressChapterCompletedText()}" android:textColor="@color/oppiaPrimaryText" android:textSize="18sp" app:layout_constraintStart_toStartOf="parent" diff --git a/app/src/main/res/layout-land/topic_info_fragment.xml b/app/src/main/res/layout-land/topic_info_fragment.xml index 2bece9d9539..f42fd7673d2 100644 --- a/app/src/main/res/layout-land/topic_info_fragment.xml +++ b/app/src/main/res/layout-land/topic_info_fragment.xml @@ -48,7 +48,7 @@ android:layout_marginTop="8dp" android:layout_marginEnd="72dp" android:fontFamily="sans-serif" - android:text="@{@plurals/story_count(viewModel.topic.storyCount, viewModel.topic.storyCount)}" + android:text="@{viewModel.storyCountText}" android:textColor="@color/oppiaPrimaryText" android:textSize="16sp" app:layout_constraintEnd_toEndOf="parent" @@ -131,7 +131,7 @@ android:layout_marginStart="8dp" android:layout_marginEnd="72dp" android:fontFamily="sans-serif" - android:text="@{String.format(@string/topic_download_text, viewModel.topicSize)}" + android:text="@{viewModel.topicSizeText}" android:textColor="@color/oppiaPrimaryText" android:textSize="18sp" android:textStyle="italic" diff --git a/app/src/main/res/layout-land/topic_lessons_story_summary.xml b/app/src/main/res/layout-land/topic_lessons_story_summary.xml index 01e891c9f6a..09e92d066df 100644 --- a/app/src/main/res/layout-land/topic_lessons_story_summary.xml +++ b/app/src/main/res/layout-land/topic_lessons_story_summary.xml @@ -10,10 +10,6 @@ name="isListExpanded" type="Boolean" /> - - @@ -56,7 +52,7 @@ android:id="@+id/story_progress_container" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:contentDescription="@{@string/topic_story_progress_percentage(storyPercentage)}"> + android:contentDescription="@{viewModel.storyProgressPercentageText}"> + android:visibility="@{viewModel.storyPercentage != 0 ? View.VISIBLE : View.GONE}" /> + android:visibility="@{viewModel.storyPercentage != 0 ? View.VISIBLE : View.GONE}" /> @@ -120,7 +116,7 @@ android:layout_height="wrap_content" android:fontFamily="sans-serif" android:importantForAccessibility="no" - android:text="@{@plurals/chapter_count(viewModel.storySummary.chapterCount, viewModel.storySummary.chapterCount)}" + android:text="@{viewModel.computeChapterCountText()}" android:textColor="@color/oppiaPrimaryText" android:textSize="16sp" /> diff --git a/app/src/main/res/layout-land/topic_summary_view.xml b/app/src/main/res/layout-land/topic_summary_view.xml index fde7469f3da..40628f434b2 100755 --- a/app/src/main/res/layout-land/topic_summary_view.xml +++ b/app/src/main/res/layout-land/topic_summary_view.xml @@ -78,7 +78,7 @@ android:layout_marginEnd="8dp" android:fontFamily="sans-serif-light" android:paddingBottom="8dp" - android:text="@{@plurals/lesson_count(viewModel.totalChapterCount, viewModel.totalChapterCount)}" + android:text="@{viewModel.computeLessonCountText()}" android:textColor="@color/white_80" android:textSize="14sp" android:textStyle="italic" diff --git a/app/src/main/res/layout-sw600dp-land/hints_summary.xml b/app/src/main/res/layout-sw600dp-land/hints_summary.xml index dfdab1a5269..a9e5eb3639c 100644 --- a/app/src/main/res/layout-sw600dp-land/hints_summary.xml +++ b/app/src/main/res/layout-sw600dp-land/hints_summary.xml @@ -76,7 +76,7 @@ android:layout_width="48dp" android:layout_height="48dp" android:layout_gravity="center_vertical" - android:contentDescription="@{@string/show_hide_hint_list(viewModel.hintsAndSolutionSummary)}" + android:contentDescription="@{viewModel.computeHintListDropDownIconContentDescription()}" android:padding="8dp" android:src="@drawable/ic_arrow_drop_down_black_24dp" app:isRotationAnimationClockwise="@{isListExpanded}" diff --git a/app/src/main/res/layout-sw600dp-land/onboarding_fragment.xml b/app/src/main/res/layout-sw600dp-land/onboarding_fragment.xml index d77f3ff9291..1f018a5b7a7 100644 --- a/app/src/main/res/layout-sw600dp-land/onboarding_fragment.xml +++ b/app/src/main/res/layout-sw600dp-land/onboarding_fragment.xml @@ -54,7 +54,7 @@ android:layout_height="wrap_content" android:layout_marginTop="32dp" android:layout_marginBottom="16dp" - android:contentDescription="@{String.format(@string/onboarding_slide_dots_content_description((viewModel.slideNumber + 1), viewModel.totalNumberOfSlides))}" + android:contentDescription="@{viewModel.slideDotsContainerContentDescription}" android:gravity="center" android:minHeight="48dp" android:orientation="horizontal" diff --git a/app/src/main/res/layout-sw600dp-land/ongoing_story_card.xml b/app/src/main/res/layout-sw600dp-land/ongoing_story_card.xml index 4ab3bbfc3a0..a5ef17f42c9 100644 --- a/app/src/main/res/layout-sw600dp-land/ongoing_story_card.xml +++ b/app/src/main/res/layout-sw600dp-land/ongoing_story_card.xml @@ -34,7 +34,7 @@ android:id="@+id/lesson_thumbnail" android:layout_width="0dp" android:layout_height="0dp" - android:contentDescription="@{@string/lesson_thumbnail_content_description(viewModel.ongoingStory.nextChapterName)}" + android:contentDescription="@{viewModel.computeLessonThumbnailContentDescription()}" android:importantForAccessibility="no" app:entityId="@{viewModel.ongoingStory.storyId}" app:entityType="@{viewModel.entityType}" diff --git a/app/src/main/res/layout-sw600dp-land/ongoing_topic_item.xml b/app/src/main/res/layout-sw600dp-land/ongoing_topic_item.xml index e08faa9742f..d97d6fff086 100644 --- a/app/src/main/res/layout-sw600dp-land/ongoing_topic_item.xml +++ b/app/src/main/res/layout-sw600dp-land/ongoing_topic_item.xml @@ -81,7 +81,7 @@ android:layout_marginEnd="16dp" android:fontFamily="sans-serif-light" android:paddingBottom="12dp" - android:text="@{@plurals/lesson_count(viewModel.topic.storyCount, viewModel.topic.storyCount)}" + android:text="@{viewModel.computeStoryCountText()}" android:textAlignment="viewStart" android:textColor="@color/white_80" android:textSize="14sp" diff --git a/app/src/main/res/layout-sw600dp-land/pin_password_activity.xml b/app/src/main/res/layout-sw600dp-land/pin_password_activity.xml index e7a0de64dd3..ba58eab0eff 100644 --- a/app/src/main/res/layout-sw600dp-land/pin_password_activity.xml +++ b/app/src/main/res/layout-sw600dp-land/pin_password_activity.xml @@ -26,7 +26,7 @@ android:layout_marginTop="104dp" android:gravity="center_horizontal" android:lines="1" - android:text="@{String.format(@string/pin_password_hello, viewModel.profile.name)}" + android:text="@{viewModel.helloText}" android:textColor="@color/oppiaPrimaryText" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" diff --git a/app/src/main/res/layout-sw600dp-land/profile_progress_recently_played_story_card.xml b/app/src/main/res/layout-sw600dp-land/profile_progress_recently_played_story_card.xml index 340b93afaa0..43b562394af 100644 --- a/app/src/main/res/layout-sw600dp-land/profile_progress_recently_played_story_card.xml +++ b/app/src/main/res/layout-sw600dp-land/profile_progress_recently_played_story_card.xml @@ -32,7 +32,7 @@ android:id="@+id/lesson_thumbnail" android:layout_width="0dp" android:layout_height="0dp" - android:contentDescription="@{@string/lesson_thumbnail_content_description(viewModel.promotedStory.nextChapterName)}" + android:contentDescription="@{viewModel.computeLessonThumbnailContentDescription()}" android:importantForAccessibility="no" android:scaleType="fitCenter" app:entityId="@{viewModel.promotedStory.storyId}" diff --git a/app/src/main/res/layout-sw600dp-land/question_player_fragment.xml b/app/src/main/res/layout-sw600dp-land/question_player_fragment.xml index f3a66264526..5346bde3cc5 100644 --- a/app/src/main/res/layout-sw600dp-land/question_player_fragment.xml +++ b/app/src/main/res/layout-sw600dp-land/question_player_fragment.xml @@ -116,7 +116,7 @@ android:layout_marginEnd="@dimen/question_player_progress_bar_text_margin_end" android:layout_marginBottom="@dimen/question_player_progress_bar_text_margin_bottom" android:fontFamily="sans-serif" - android:text="@{viewModel.isAtEndOfSession ? @string/question_training_session_progress_finished : @string/question_training_session_progress(viewModel.currentQuestion, viewModel.questionCount)}" + android:text="@{viewModel.computeQuestionProgressText()}" android:textColor="@color/oppiaPrimaryText" android:textSize="12sp" android:textStyle="italic" diff --git a/app/src/main/res/layout-sw600dp-land/solution_summary.xml b/app/src/main/res/layout-sw600dp-land/solution_summary.xml index 23683cbedd6..7abebb3ba6a 100644 --- a/app/src/main/res/layout-sw600dp-land/solution_summary.xml +++ b/app/src/main/res/layout-sw600dp-land/solution_summary.xml @@ -76,7 +76,7 @@ android:layout_width="48dp" android:layout_height="48dp" android:layout_gravity="center_vertical" - android:contentDescription="@{@string/show_hide_solution_list(viewModel.solutionSummary)}" + android:contentDescription="@string/show_hide_solution_list" android:padding="8dp" android:src="@drawable/ic_arrow_drop_down_black_24dp" app:isRotationAnimationClockwise="@{isListExpanded}" diff --git a/app/src/main/res/layout-sw600dp-land/topic_fragment.xml b/app/src/main/res/layout-sw600dp-land/topic_fragment.xml index c5150419f65..eabb1fca6c1 100644 --- a/app/src/main/res/layout-sw600dp-land/topic_fragment.xml +++ b/app/src/main/res/layout-sw600dp-land/topic_fragment.xml @@ -48,7 +48,7 @@ android:requiresFadingEdge="horizontal" android:scrollHorizontally="true" android:singleLine="true" - android:text="@{String.format(@string/topic_name, viewModel.topicNameLiveData)}" + android:text="@{viewModel.topicToolbarTitleLiveData}" android:textColor="@color/white" android:textSize="20sp" /> diff --git a/app/src/main/res/layout-sw600dp-land/topic_info_fragment.xml b/app/src/main/res/layout-sw600dp-land/topic_info_fragment.xml index 29e245a6494..428ddefc4f8 100644 --- a/app/src/main/res/layout-sw600dp-land/topic_info_fragment.xml +++ b/app/src/main/res/layout-sw600dp-land/topic_info_fragment.xml @@ -47,7 +47,7 @@ android:layout_marginTop="8dp" android:layout_marginEnd="64dp" android:fontFamily="sans-serif" - android:text="@{@plurals/story_count(viewModel.topic.storyCount, viewModel.topic.storyCount)}" + android:text="@{viewModel.storyCountText}" android:textColor="@color/oppiaPrimaryText" android:textSize="16sp" app:layout_constraintEnd_toEndOf="parent" @@ -126,7 +126,7 @@ android:layout_marginStart="8dp" android:layout_marginEnd="32dp" android:fontFamily="sans-serif" - android:text="@{String.format(@string/topic_download_text, viewModel.topicSize)}" + android:text="@{viewModel.topicSizeText}" android:textColor="@color/oppiaPrimaryText" android:textSize="18sp" android:textStyle="italic" diff --git a/app/src/main/res/layout-sw600dp-land/topic_lessons_story_summary.xml b/app/src/main/res/layout-sw600dp-land/topic_lessons_story_summary.xml index 2169cff4410..c7b07e6e927 100644 --- a/app/src/main/res/layout-sw600dp-land/topic_lessons_story_summary.xml +++ b/app/src/main/res/layout-sw600dp-land/topic_lessons_story_summary.xml @@ -10,10 +10,6 @@ name="isListExpanded" type="Boolean" /> - - @@ -59,7 +55,7 @@ android:id="@+id/story_progress_container" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:contentDescription="@{@string/topic_story_progress_percentage(storyPercentage)}"> + android:contentDescription="@{viewModel.storyProgressPercentageText}"> + android:visibility="@{viewModel.storyPercentage != 0 ? View.VISIBLE : View.GONE}" /> + android:visibility="@{viewModel.storyPercentage != 0 ? View.VISIBLE : View.GONE}" /> @@ -123,7 +119,7 @@ android:layout_height="wrap_content" android:fontFamily="sans-serif" android:importantForAccessibility="no" - android:text="@{@plurals/chapter_count(viewModel.storySummary.chapterCount, viewModel.storySummary.chapterCount)}" + android:text="@{viewModel.computeChapterCountText()}" android:textColor="@color/oppiaPrimaryText" android:textSize="16sp" /> diff --git a/app/src/main/res/layout-sw600dp-land/topic_summary_view.xml b/app/src/main/res/layout-sw600dp-land/topic_summary_view.xml index e65849af4ea..654b63eff8f 100644 --- a/app/src/main/res/layout-sw600dp-land/topic_summary_view.xml +++ b/app/src/main/res/layout-sw600dp-land/topic_summary_view.xml @@ -78,7 +78,7 @@ android:layout_marginEnd="8dp" android:fontFamily="sans-serif-light" android:paddingBottom="12dp" - android:text="@{@plurals/lesson_count(viewModel.totalChapterCount, viewModel.totalChapterCount)}" + android:text="@{viewModel.computeLessonCountText()}" android:textColor="@color/white_80" android:textSize="14sp" android:textStyle="italic" diff --git a/app/src/main/res/layout-sw600dp-port/hints_summary.xml b/app/src/main/res/layout-sw600dp-port/hints_summary.xml index 0459822090f..28dcfdd4ffb 100644 --- a/app/src/main/res/layout-sw600dp-port/hints_summary.xml +++ b/app/src/main/res/layout-sw600dp-port/hints_summary.xml @@ -76,7 +76,7 @@ android:layout_width="48dp" android:layout_height="48dp" android:layout_gravity="center_vertical" - android:contentDescription="@{@string/show_hide_hint_list(viewModel.hintsAndSolutionSummary)}" + android:contentDescription="@{viewModel.computeHintListDropDownIconContentDescription()}" android:padding="8dp" android:src="@drawable/ic_arrow_drop_down_black_24dp" app:isRotationAnimationClockwise="@{isListExpanded}" diff --git a/app/src/main/res/layout-sw600dp-port/onboarding_fragment.xml b/app/src/main/res/layout-sw600dp-port/onboarding_fragment.xml index 0698acfe884..37cdf46fe28 100644 --- a/app/src/main/res/layout-sw600dp-port/onboarding_fragment.xml +++ b/app/src/main/res/layout-sw600dp-port/onboarding_fragment.xml @@ -53,7 +53,7 @@ android:layout_height="wrap_content" android:layout_marginTop="32dp" android:layout_marginBottom="16dp" - android:contentDescription="@{String.format(@string/onboarding_slide_dots_content_description((viewModel.slideNumber + 1), viewModel.totalNumberOfSlides))}" + android:contentDescription="@{viewModel.slideDotsContainerContentDescription}" android:gravity="center" android:minHeight="48dp" android:orientation="horizontal" diff --git a/app/src/main/res/layout-sw600dp-port/ongoing_story_card.xml b/app/src/main/res/layout-sw600dp-port/ongoing_story_card.xml index 4ab3bbfc3a0..a5ef17f42c9 100644 --- a/app/src/main/res/layout-sw600dp-port/ongoing_story_card.xml +++ b/app/src/main/res/layout-sw600dp-port/ongoing_story_card.xml @@ -34,7 +34,7 @@ android:id="@+id/lesson_thumbnail" android:layout_width="0dp" android:layout_height="0dp" - android:contentDescription="@{@string/lesson_thumbnail_content_description(viewModel.ongoingStory.nextChapterName)}" + android:contentDescription="@{viewModel.computeLessonThumbnailContentDescription()}" android:importantForAccessibility="no" app:entityId="@{viewModel.ongoingStory.storyId}" app:entityType="@{viewModel.entityType}" diff --git a/app/src/main/res/layout-sw600dp-port/ongoing_topic_item.xml b/app/src/main/res/layout-sw600dp-port/ongoing_topic_item.xml index 490e0f575f9..4837bae3d48 100644 --- a/app/src/main/res/layout-sw600dp-port/ongoing_topic_item.xml +++ b/app/src/main/res/layout-sw600dp-port/ongoing_topic_item.xml @@ -82,7 +82,7 @@ android:layout_marginEnd="8dp" android:fontFamily="sans-serif-light" android:paddingBottom="12dp" - android:text="@{@plurals/lesson_count(viewModel.topic.storyCount, viewModel.topic.storyCount)}" + android:text="@{viewModel.computeStoryCountText()}" android:textAlignment="viewStart" android:textColor="@color/white_80" android:textSize="14sp" diff --git a/app/src/main/res/layout-sw600dp-port/pin_password_activity.xml b/app/src/main/res/layout-sw600dp-port/pin_password_activity.xml index 840b85ab9a2..7a40fb505dd 100644 --- a/app/src/main/res/layout-sw600dp-port/pin_password_activity.xml +++ b/app/src/main/res/layout-sw600dp-port/pin_password_activity.xml @@ -26,7 +26,7 @@ android:layout_marginTop="216dp" android:gravity="center_horizontal" android:lines="1" - android:text="@{String.format(@string/pin_password_hello, viewModel.profile.name)}" + android:text="@{viewModel.helloText}" android:textColor="@color/oppiaPrimaryText" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" diff --git a/app/src/main/res/layout-sw600dp-port/profile_progress_recently_played_story_card.xml b/app/src/main/res/layout-sw600dp-port/profile_progress_recently_played_story_card.xml index e36e29aa8f2..013ed638ca8 100644 --- a/app/src/main/res/layout-sw600dp-port/profile_progress_recently_played_story_card.xml +++ b/app/src/main/res/layout-sw600dp-port/profile_progress_recently_played_story_card.xml @@ -32,7 +32,7 @@ android:id="@+id/lesson_thumbnail" android:layout_width="0dp" android:layout_height="0dp" - android:contentDescription="@{@string/lesson_thumbnail_content_description(viewModel.promotedStory.nextChapterName)}" + android:contentDescription="@{viewModel.computeLessonThumbnailContentDescription()}" android:importantForAccessibility="no" app:entityId="@{viewModel.promotedStory.storyId}" app:entityType="@{viewModel.entityType}" diff --git a/app/src/main/res/layout-sw600dp-port/question_player_fragment.xml b/app/src/main/res/layout-sw600dp-port/question_player_fragment.xml index ba3d6a3f7d5..f8da00ccb3d 100644 --- a/app/src/main/res/layout-sw600dp-port/question_player_fragment.xml +++ b/app/src/main/res/layout-sw600dp-port/question_player_fragment.xml @@ -116,7 +116,7 @@ android:layout_marginEnd="@dimen/question_player_progress_bar_text_margin_end" android:layout_marginBottom="@dimen/question_player_progress_bar_text_margin_bottom" android:fontFamily="sans-serif" - android:text="@{viewModel.isAtEndOfSession ? @string/question_training_session_progress_finished : @string/question_training_session_progress(viewModel.currentQuestion, viewModel.questionCount)}" + android:text="@{viewModel.computeQuestionProgressText()}" android:textColor="@color/oppiaPrimaryText" android:textSize="12sp" android:textStyle="italic" diff --git a/app/src/main/res/layout-sw600dp-port/solution_summary.xml b/app/src/main/res/layout-sw600dp-port/solution_summary.xml index 039ad4c39c7..47233633595 100644 --- a/app/src/main/res/layout-sw600dp-port/solution_summary.xml +++ b/app/src/main/res/layout-sw600dp-port/solution_summary.xml @@ -76,7 +76,7 @@ android:layout_width="48dp" android:layout_height="48dp" android:layout_gravity="center_vertical" - android:contentDescription="@{@string/show_hide_solution_list(viewModel.solutionSummary)}" + android:contentDescription="@string/show_hide_solution_list" android:padding="8dp" android:src="@drawable/ic_arrow_drop_down_black_24dp" app:isRotationAnimationClockwise="@{isListExpanded}" diff --git a/app/src/main/res/layout-sw600dp-port/topic_fragment.xml b/app/src/main/res/layout-sw600dp-port/topic_fragment.xml index 8c45246d28b..ee45ddce875 100644 --- a/app/src/main/res/layout-sw600dp-port/topic_fragment.xml +++ b/app/src/main/res/layout-sw600dp-port/topic_fragment.xml @@ -48,7 +48,7 @@ android:requiresFadingEdge="horizontal" android:scrollHorizontally="true" android:singleLine="true" - android:text="@{String.format(@string/topic_name, viewModel.topicNameLiveData)}" + android:text="@{viewModel.topicToolbarTitleLiveData}" android:textColor="@color/white" android:textSize="20sp" /> diff --git a/app/src/main/res/layout-sw600dp-port/topic_info_fragment.xml b/app/src/main/res/layout-sw600dp-port/topic_info_fragment.xml index c5ffe85d3a2..2aa1375f61a 100644 --- a/app/src/main/res/layout-sw600dp-port/topic_info_fragment.xml +++ b/app/src/main/res/layout-sw600dp-port/topic_info_fragment.xml @@ -66,7 +66,7 @@ android:layout_marginTop="8dp" android:layout_marginEnd="120dp" android:fontFamily="sans-serif" - android:text="@{@plurals/story_count(viewModel.topic.storyCount, viewModel.topic.storyCount)}" + android:text="@{viewModel.storyCountText}" android:textColor="@color/oppiaPrimaryText" android:textSize="16sp" app:layout_constraintEnd_toEndOf="parent" @@ -143,7 +143,7 @@ android:layout_marginStart="8dp" android:layout_marginEnd="32dp" android:fontFamily="sans-serif" - android:text="@{String.format(@string/topic_download_text, viewModel.topicSize)}" + android:text="@{viewModel.topicSizeText}" android:textColor="@color/oppiaPrimaryText" android:textSize="18sp" android:textStyle="italic" diff --git a/app/src/main/res/layout-sw600dp-port/topic_lessons_story_summary.xml b/app/src/main/res/layout-sw600dp-port/topic_lessons_story_summary.xml index c511b698a66..25b801e8054 100644 --- a/app/src/main/res/layout-sw600dp-port/topic_lessons_story_summary.xml +++ b/app/src/main/res/layout-sw600dp-port/topic_lessons_story_summary.xml @@ -10,10 +10,6 @@ name="isListExpanded" type="Boolean" /> - - @@ -59,7 +55,7 @@ android:id="@+id/story_progress_container" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:contentDescription="@{@string/topic_story_progress_percentage(storyPercentage)}"> + android:contentDescription="@{viewModel.storyProgressPercentageText}"> + android:visibility="@{viewModel.storyPercentage != 0 ? View.VISIBLE : View.GONE}" /> + android:visibility="@{viewModel.storyPercentage != 0 ? View.VISIBLE : View.GONE}" /> @@ -123,7 +119,7 @@ android:layout_height="wrap_content" android:fontFamily="sans-serif" android:importantForAccessibility="no" - android:text="@{@plurals/chapter_count(viewModel.storySummary.chapterCount, viewModel.storySummary.chapterCount)}" + android:text="@{viewModel.computeChapterCountText()}" android:textColor="@color/oppiaPrimaryText" android:textSize="16sp" /> diff --git a/app/src/main/res/layout-sw600dp-port/topic_summary_view.xml b/app/src/main/res/layout-sw600dp-port/topic_summary_view.xml index fde7469f3da..40628f434b2 100644 --- a/app/src/main/res/layout-sw600dp-port/topic_summary_view.xml +++ b/app/src/main/res/layout-sw600dp-port/topic_summary_view.xml @@ -78,7 +78,7 @@ android:layout_marginEnd="8dp" android:fontFamily="sans-serif-light" android:paddingBottom="8dp" - android:text="@{@plurals/lesson_count(viewModel.totalChapterCount, viewModel.totalChapterCount)}" + android:text="@{viewModel.computeLessonCountText()}" android:textColor="@color/white_80" android:textSize="14sp" android:textStyle="italic" diff --git a/app/src/main/res/layout-sw600dp/story_chapter_view.xml b/app/src/main/res/layout-sw600dp/story_chapter_view.xml index a9366db4bb9..8ea5b6867a4 100644 --- a/app/src/main/res/layout-sw600dp/story_chapter_view.xml +++ b/app/src/main/res/layout-sw600dp/story_chapter_view.xml @@ -94,7 +94,7 @@ android:ellipsize="end" android:fontFamily="sans-serif-medium" android:maxLines="2" - android:text="@{String.format(@string/chapter_name, (viewModel.index + 1), viewModel.name)}" + android:text="@{viewModel.computeChapterTitleText()}" android:textColor="@color/oppiaPrimaryText" android:textSize="18sp" app:layout_constraintEnd_toEndOf="parent" diff --git a/app/src/main/res/layout-sw600dp/story_header_view.xml b/app/src/main/res/layout-sw600dp/story_header_view.xml index 8f3c5ed2c6b..55bd96790d2 100644 --- a/app/src/main/res/layout-sw600dp/story_header_view.xml +++ b/app/src/main/res/layout-sw600dp/story_header_view.xml @@ -18,7 +18,7 @@ android:layout_marginEnd="32dp" android:layout_marginBottom="8dp" android:fontFamily="sans-serif-medium" - android:text="@{@plurals/story_total_chapters(viewModel.totalChapters, viewModel.completedChapters, viewModel.totalChapters)}" + android:text="@{viewModel.computeStoryProgressChapterCompletedText()}" android:textColor="@color/oppiaPrimaryText" android:textSize="18sp" app:layout_constraintStart_toStartOf="parent" diff --git a/app/src/main/res/layout/app_version_fragment.xml b/app/src/main/res/layout/app_version_fragment.xml index cd4d519f3f3..8e3661fa169 100644 --- a/app/src/main/res/layout/app_version_fragment.xml +++ b/app/src/main/res/layout/app_version_fragment.xml @@ -28,7 +28,7 @@ android:paddingTop="20dp" android:paddingEnd="@dimen/app_version_text_view_padding_end" android:paddingBottom="20dp" - android:text="@{@string/app_version_name(viewModel.versionName)}" + android:text="@{viewModel.computeVersionNameText()}" android:textColor="@color/oppiaPrimaryTextDark" android:textSize="16sp" app:layout_constraintEnd_toEndOf="parent" @@ -53,7 +53,7 @@ android:layout_marginTop="28dp" android:layout_marginEnd="@dimen/app_version_text_view_margin_end" android:fontFamily="sans-serif" - android:text="@{@string/app_last_update_date(viewModel.lastUpdateDate)}" + android:text="@{viewModel.computeLastUpdatedDateText()}" android:textColor="@color/oppiaSecondaryText" android:textSize="12sp" app:layout_constraintEnd_toEndOf="parent" diff --git a/app/src/main/res/layout/drag_drop_interaction_items.xml b/app/src/main/res/layout/drag_drop_interaction_items.xml index 13207925df2..2c7a8a1dd3f 100644 --- a/app/src/main/res/layout/drag_drop_interaction_items.xml +++ b/app/src/main/res/layout/drag_drop_interaction_items.xml @@ -41,7 +41,7 @@ android:padding="12dp" android:background="?attr/selectableItemBackground" android:clickable="true" - android:contentDescription="@{viewModel.itemIndex == 0 ? @string/up_button_disabled : @string/move_item_up_content_description(viewModel.itemIndex)}" + android:contentDescription="@{viewModel.computeDragDropMoveUpItemContentDescription()}" android:enabled="@{viewModel.itemIndex != 0}" android:focusable="true" android:onClick="@{(v) -> viewModel.handleUpMovement(adapter)}" @@ -58,7 +58,7 @@ android:padding="12dp" android:background="?attr/selectableItemBackground" android:clickable="true" - android:contentDescription="@{viewModel.itemIndex == viewModel.listSize-1 ? @string/down_button_disabled : @string/move_item_down_content_description(viewModel.itemIndex + 2)}" + android:contentDescription="@{viewModel.computeDragDropMoveDownItemContentDescription()}" android:enabled="@{viewModel.itemIndex != viewModel.listSize-1}" android:focusable="true" android:onClick="@{(v) -> viewModel.handleDownMovement(adapter)}" @@ -86,7 +86,7 @@ android:layout_height="48dp" android:background="?attr/selectableItemBackground" android:clickable="true" - android:contentDescription="@{@string/link_to_item_below(viewModel.itemIndex + 2)}" + android:contentDescription="@{viewModel.computeDragDropGroupItemContentDescription()}" android:enabled="@{viewModel.itemIndex != viewModel.listSize-1}" android:focusable="true" android:onClick="@{(v) -> viewModel.handleGrouping(adapter)}" @@ -103,7 +103,7 @@ android:layout_height="48dp" android:background="?attr/selectableItemBackground" android:clickable="true" - android:contentDescription="@{@string/unlink_items(viewModel.itemIndex + 1)}" + android:contentDescription="@{viewModel.computeDragDropUnlinkItemContentDescription()}" android:focusable="true" android:onClick="@{(v) -> viewModel.handleUnlinking(adapter)}" android:padding="16dp" diff --git a/app/src/main/res/layout/hints_summary.xml b/app/src/main/res/layout/hints_summary.xml index 3fcd09177d8..75db37f1e2b 100644 --- a/app/src/main/res/layout/hints_summary.xml +++ b/app/src/main/res/layout/hints_summary.xml @@ -82,7 +82,7 @@ android:layout_width="48dp" android:layout_height="48dp" android:layout_gravity="center_vertical" - android:contentDescription="@{@string/show_hide_hint_list(viewModel.hintsAndSolutionSummary)}" + android:contentDescription="@{viewModel.computeHintListDropDownIconContentDescription()}" android:padding="8dp" android:src="@drawable/ic_arrow_drop_down_black_24dp" app:isRotationAnimationClockwise="@{isListExpanded}" diff --git a/app/src/main/res/layout/lessons_chapter_view.xml b/app/src/main/res/layout/lessons_chapter_view.xml index b15ba3a26fe..3b7897fb411 100644 --- a/app/src/main/res/layout/lessons_chapter_view.xml +++ b/app/src/main/res/layout/lessons_chapter_view.xml @@ -30,7 +30,7 @@ android:layout_height="16dp" android:layout_marginStart="16dp" android:layout_marginEnd="8dp" - android:contentDescription="@{viewModel.chapterPlayState == ChapterPlayState.COMPLETED?String.format(@string/chapter_completed, (viewModel.index + 1), viewModel.chapterName):String.format(@string/chapter_in_progress, (viewModel.index + 1), viewModel.chapterName)}" + android:contentDescription="@{viewModel.computeChapterPlayStateIconContentDescription()}" android:src="@{viewModel.chapterPlayState == ChapterPlayState.COMPLETED?@drawable/ic_check_24dp:@drawable/ic_pending_24dp}" android:visibility="@{(viewModel.chapterPlayState == ChapterPlayState.COMPLETED || viewModel.chapterPlayState == ChapterPlayState.IN_PROGRESS_SAVED)?View.VISIBLE : View.INVISIBLE}" /> @@ -41,7 +41,7 @@ android:layout_marginStart="4dp" android:fontFamily="sans-serif" android:importantForAccessibility="@{viewModel.chapterPlayState != ChapterPlayState.NOT_PLAYABLE_MISSING_PREREQUISITES ? View.IMPORTANT_FOR_ACCESSIBILITY_YES : View.IMPORTANT_FOR_ACCESSIBILITY_NO}" - android:text="@{String.format(@string/topic_play_chapter_index, (viewModel.index + 1))}" + android:text="@{viewModel.computePlayChapterIndexText()}" android:textColor="@{viewModel.chapterPlayState != ChapterPlayState.NOT_PLAYABLE_MISSING_PREREQUISITES ? @color/oppiaPrimaryText : @color/oppiaPrimaryText30}" android:textSize="14sp" /> diff --git a/app/src/main/res/layout/nav_header_navigation_drawer.xml b/app/src/main/res/layout/nav_header_navigation_drawer.xml index a438771cd6b..c3249bc54cc 100644 --- a/app/src/main/res/layout/nav_header_navigation_drawer.xml +++ b/app/src/main/res/layout/nav_header_navigation_drawer.xml @@ -49,7 +49,7 @@ android:layout_marginTop="4dp" android:layout_marginBottom="8dp" android:fontFamily="sans-serif" - android:text="@{@plurals/completed_story_count(viewModel.completedStoryCount, viewModel.completedStoryCount).concat(@string/bar_separator).concat(@plurals/ongoing_topic_count(viewModel.ongoingTopicCount, viewModel.ongoingTopicCount))}" + android:text="@{viewModel.profileProgressText}" android:textColor="@color/white" android:textSize="14sp" /> diff --git a/app/src/main/res/layout/onboarding_fragment.xml b/app/src/main/res/layout/onboarding_fragment.xml index 39fb60392ca..6995674045b 100644 --- a/app/src/main/res/layout/onboarding_fragment.xml +++ b/app/src/main/res/layout/onboarding_fragment.xml @@ -54,7 +54,7 @@ android:layout_height="wrap_content" android:layout_marginTop="32dp" android:layout_marginBottom="16dp" - android:contentDescription="@{String.format(@string/onboarding_slide_dots_content_description((viewModel.slideNumber + 1), viewModel.totalNumberOfSlides))}" + android:contentDescription="@{viewModel.slideDotsContainerContentDescription}" android:gravity="center" android:minHeight="48dp" android:orientation="horizontal" diff --git a/app/src/main/res/layout/ongoing_story_card.xml b/app/src/main/res/layout/ongoing_story_card.xml index 4ab3bbfc3a0..a5ef17f42c9 100755 --- a/app/src/main/res/layout/ongoing_story_card.xml +++ b/app/src/main/res/layout/ongoing_story_card.xml @@ -34,7 +34,7 @@ android:id="@+id/lesson_thumbnail" android:layout_width="0dp" android:layout_height="0dp" - android:contentDescription="@{@string/lesson_thumbnail_content_description(viewModel.ongoingStory.nextChapterName)}" + android:contentDescription="@{viewModel.computeLessonThumbnailContentDescription()}" android:importantForAccessibility="no" app:entityId="@{viewModel.ongoingStory.storyId}" app:entityType="@{viewModel.entityType}" diff --git a/app/src/main/res/layout/ongoing_topic_item.xml b/app/src/main/res/layout/ongoing_topic_item.xml index 551fa515551..b511a49c554 100755 --- a/app/src/main/res/layout/ongoing_topic_item.xml +++ b/app/src/main/res/layout/ongoing_topic_item.xml @@ -83,7 +83,7 @@ android:layout_marginEnd="8dp" android:fontFamily="sans-serif-light" android:paddingBottom="12dp" - android:text="@{@plurals/lesson_count(viewModel.topic.storyCount, viewModel.topic.storyCount)}" + android:text="@{viewModel.computeStoryCountText()}" android:textAlignment="viewStart" android:textColor="@color/white_80" android:textSize="14sp" diff --git a/app/src/main/res/layout/pin_password_activity.xml b/app/src/main/res/layout/pin_password_activity.xml index 32d7d98b35f..2c9f6597890 100644 --- a/app/src/main/res/layout/pin_password_activity.xml +++ b/app/src/main/res/layout/pin_password_activity.xml @@ -26,7 +26,7 @@ android:fontFamily="sans-serif" android:gravity="center_horizontal" android:lines="1" - android:text="@{String.format(@string/pin_password_hello, viewModel.profile.name)}" + android:text="@{viewModel.helloText}" android:textColor="@color/oppiaPrimaryText" android:textSize="24sp" app:layout_constraintEnd_toEndOf="parent" diff --git a/app/src/main/res/layout/previous_responses_header_item.xml b/app/src/main/res/layout/previous_responses_header_item.xml index 2559e684437..dc52713c12f 100644 --- a/app/src/main/res/layout/previous_responses_header_item.xml +++ b/app/src/main/res/layout/previous_responses_header_item.xml @@ -57,7 +57,7 @@ android:gravity="center_vertical" android:paddingStart="8dp" android:paddingEnd="8dp" - android:text="@{@string/previous_responses_header(viewModel.previousAnswerCount)}" + android:text="@{viewModel.computePreviousResponsesHeaderText()}" android:textAllCaps="true" android:textColor="@color/mid_grey" android:textSize="14sp" diff --git a/app/src/main/res/layout/profile_progress_recently_played_story_card.xml b/app/src/main/res/layout/profile_progress_recently_played_story_card.xml index 8f6a327e737..e8f4c7e5d88 100755 --- a/app/src/main/res/layout/profile_progress_recently_played_story_card.xml +++ b/app/src/main/res/layout/profile_progress_recently_played_story_card.xml @@ -33,7 +33,7 @@ android:id="@+id/lesson_thumbnail" android:layout_width="0dp" android:layout_height="0dp" - android:contentDescription="@{@string/lesson_thumbnail_content_description(viewModel.promotedStory.nextChapterName)}" + android:contentDescription="@{viewModel.computeLessonThumbnailContentDescription()}" android:importantForAccessibility="no" app:entityId="@{viewModel.promotedStory.storyId}" app:entityType="@{viewModel.entityType}" diff --git a/app/src/main/res/layout/question_player_fragment.xml b/app/src/main/res/layout/question_player_fragment.xml index 120ec3f6b26..998f289ad76 100644 --- a/app/src/main/res/layout/question_player_fragment.xml +++ b/app/src/main/res/layout/question_player_fragment.xml @@ -148,7 +148,7 @@ android:layout_marginEnd="@dimen/question_player_progress_bar_text_margin_end" android:layout_marginBottom="@dimen/question_player_progress_bar_text_margin_bottom" android:fontFamily="sans-serif" - android:text="@{viewModel.isAtEndOfSession ? @string/question_training_session_progress_finished : @string/question_training_session_progress(viewModel.currentQuestion, viewModel.questionCount)}" + android:text="@{viewModel.computeQuestionProgressText()}" android:textColor="@color/oppiaPrimaryText" android:textSize="12sp" android:textStyle="italic" diff --git a/app/src/main/res/layout/reset_pin_dialog.xml b/app/src/main/res/layout/reset_pin_dialog.xml index 6f7f2dbf463..02e5b1db41f 100755 --- a/app/src/main/res/layout/reset_pin_dialog.xml +++ b/app/src/main/res/layout/reset_pin_dialog.xml @@ -22,7 +22,7 @@ android:layout_marginTop="32dp" android:layout_marginEnd="24dp" android:layout_marginBottom="20dp" - android:hint="@{@string/admin_settings_enter_user_new_pin(viewModel.name)}" + android:hint="@{viewModel.resetPinInputPinHintText}" app:errorMessage="@{viewModel.errorMessage}"> diff --git a/app/src/main/res/layout/story_chapter_view.xml b/app/src/main/res/layout/story_chapter_view.xml index a9366db4bb9..8ea5b6867a4 100644 --- a/app/src/main/res/layout/story_chapter_view.xml +++ b/app/src/main/res/layout/story_chapter_view.xml @@ -94,7 +94,7 @@ android:ellipsize="end" android:fontFamily="sans-serif-medium" android:maxLines="2" - android:text="@{String.format(@string/chapter_name, (viewModel.index + 1), viewModel.name)}" + android:text="@{viewModel.computeChapterTitleText()}" android:textColor="@color/oppiaPrimaryText" android:textSize="18sp" app:layout_constraintEnd_toEndOf="parent" diff --git a/app/src/main/res/layout/story_header_view.xml b/app/src/main/res/layout/story_header_view.xml index 8f3c5ed2c6b..55bd96790d2 100644 --- a/app/src/main/res/layout/story_header_view.xml +++ b/app/src/main/res/layout/story_header_view.xml @@ -18,7 +18,7 @@ android:layout_marginEnd="32dp" android:layout_marginBottom="8dp" android:fontFamily="sans-serif-medium" - android:text="@{@plurals/story_total_chapters(viewModel.totalChapters, viewModel.completedChapters, viewModel.totalChapters)}" + android:text="@{viewModel.computeStoryProgressChapterCompletedText()}" android:textColor="@color/oppiaPrimaryText" android:textSize="18sp" app:layout_constraintStart_toStartOf="parent" diff --git a/app/src/main/res/layout/submitted_answer_item.xml b/app/src/main/res/layout/submitted_answer_item.xml index cdc5bdc65fb..935dc0c97d4 100644 --- a/app/src/main/res/layout/submitted_answer_item.xml +++ b/app/src/main/res/layout/submitted_answer_item.xml @@ -8,14 +8,6 @@ - - - - @@ -55,9 +47,9 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:background="@drawable/submitted_answer_background" - android:contentDescription="@{viewModel.isCorrectAnswer() ? String.format(@string/correct_submitted_answer_with_append, accessibleAnswer.length() > 0 ? accessibleAnswer : submittedAnswer) : String.format(@string/incorrect_submitted_answer_with_append, accessibleAnswer.length() > 0 ? accessibleAnswer : submittedAnswer)}" + android:contentDescription="@{viewModel.submittedAnswerContentDescription}" android:padding="12dp" - android:text="@{submittedAnswer}" + android:text="@{viewModel.submittedAnswer}" android:textColor="@color/oppiaPrimaryText" android:textSize="16sp" android:visibility="gone" diff --git a/app/src/main/res/layout/topic_fragment.xml b/app/src/main/res/layout/topic_fragment.xml index 5458669fefe..bad3b2dcec7 100644 --- a/app/src/main/res/layout/topic_fragment.xml +++ b/app/src/main/res/layout/topic_fragment.xml @@ -48,7 +48,7 @@ android:requiresFadingEdge="horizontal" android:scrollHorizontally="true" android:singleLine="true" - android:text="@{String.format(@string/topic_name, viewModel.topicNameLiveData)}" + android:text="@{viewModel.topicToolbarTitleLiveData}" android:textColor="@color/white" android:textSize="20sp" /> diff --git a/app/src/main/res/layout/topic_info_fragment.xml b/app/src/main/res/layout/topic_info_fragment.xml index b01eed3fadc..b8ba29b610b 100644 --- a/app/src/main/res/layout/topic_info_fragment.xml +++ b/app/src/main/res/layout/topic_info_fragment.xml @@ -70,7 +70,7 @@ android:layout_marginTop="8dp" android:layout_marginEnd="32dp" android:fontFamily="sans-serif" - android:text="@{@plurals/story_count(viewModel.topic.storyCount, viewModel.topic.storyCount)}" + android:text="@{viewModel.storyCountText}" android:textColor="@color/oppiaPrimaryText" android:textSize="16sp" app:layout_constraintEnd_toEndOf="parent" @@ -152,7 +152,7 @@ android:layout_marginStart="8dp" android:layout_marginEnd="32dp" android:fontFamily="sans-serif" - android:text="@{String.format(@string/topic_download_text, viewModel.topicSize)}" + android:text="@{viewModel.topicSizeText}" android:textColor="@color/oppiaPrimaryText" android:textSize="18sp" android:textStyle="italic" diff --git a/app/src/main/res/layout/topic_lessons_story_summary.xml b/app/src/main/res/layout/topic_lessons_story_summary.xml index 1e9397f90fe..722c8bb94f6 100644 --- a/app/src/main/res/layout/topic_lessons_story_summary.xml +++ b/app/src/main/res/layout/topic_lessons_story_summary.xml @@ -10,10 +10,6 @@ name="isListExpanded" type="Boolean" /> - - @@ -56,7 +52,7 @@ android:id="@+id/story_progress_container" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:contentDescription="@{@string/topic_story_progress_percentage(storyPercentage)}"> + android:contentDescription="@{viewModel.storyProgressPercentageText}"> + android:visibility="@{viewModel.storyPercentage != 0 ? View.VISIBLE : View.GONE}" /> + android:visibility="@{viewModel.storyPercentage != 0 ? View.VISIBLE : View.GONE}" /> @@ -120,7 +116,7 @@ android:layout_height="wrap_content" android:fontFamily="sans-serif" android:importantForAccessibility="no" - android:text="@{@plurals/chapter_count(viewModel.storySummary.chapterCount, viewModel.storySummary.chapterCount)}" + android:text="@{viewModel.computeChapterCountText()}" android:textColor="@color/oppiaPrimaryText" android:textSize="16sp" /> diff --git a/app/src/main/res/layout/topic_summary_view.xml b/app/src/main/res/layout/topic_summary_view.xml index fde7469f3da..40628f434b2 100755 --- a/app/src/main/res/layout/topic_summary_view.xml +++ b/app/src/main/res/layout/topic_summary_view.xml @@ -78,7 +78,7 @@ android:layout_marginEnd="8dp" android:fontFamily="sans-serif-light" android:paddingBottom="8dp" - android:text="@{@plurals/lesson_count(viewModel.totalChapterCount, viewModel.totalChapterCount)}" + android:text="@{viewModel.computeLessonCountText()}" android:textColor="@color/white_80" android:textSize="14sp" android:textStyle="italic" diff --git a/app/src/main/res/layout/walkthrough_topic_summary_view.xml b/app/src/main/res/layout/walkthrough_topic_summary_view.xml index 1f74f88cbc1..6b35dfea8c6 100644 --- a/app/src/main/res/layout/walkthrough_topic_summary_view.xml +++ b/app/src/main/res/layout/walkthrough_topic_summary_view.xml @@ -78,7 +78,7 @@ android:ellipsize="end" android:fontFamily="sans-serif-light" android:lines="1" - android:text="@{@plurals/lesson_count(viewModel.totalChapterCount, viewModel.totalChapterCount)}" + android:text="@{viewModel.computeWalkthroughLessonCountText()}" android:textColor="@color/white_80" android:textSize="14sp" android:textStyle="italic" diff --git a/app/src/main/res/layout/welcome.xml b/app/src/main/res/layout/welcome.xml index da36b39f5a8..480e0e08bcc 100644 --- a/app/src/main/res/layout/welcome.xml +++ b/app/src/main/res/layout/welcome.xml @@ -38,11 +38,11 @@ android:textSize="24sp" /> 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..a2fc25480ef 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 @@ -180,7 +180,7 @@ class HomeActivityTest { scrollToPosition(position = 0) verifyExactTextOnHomeListItemAtPosition( itemPosition = 0, - targetViewId = R.id.profile_name_textview, + targetViewId = R.id.profile_name_text_view, stringToMatch = "Admin!" ) } @@ -194,7 +194,7 @@ class HomeActivityTest { scrollToPosition(position = 0) verifyExactTextOnHomeListItemAtPosition( itemPosition = 0, - targetViewId = R.id.profile_name_textview, + targetViewId = R.id.profile_name_text_view, stringToMatch = "Admin!" ) } @@ -887,7 +887,7 @@ class HomeActivityTest { atPositionOnView( R.id.home_recycler_view, 0, - R.id.profile_name_textview + R.id.profile_name_text_view ) ).check(matches(not(isEllipsized()))) } @@ -906,7 +906,7 @@ class HomeActivityTest { atPositionOnView( R.id.home_recycler_view, 0, - R.id.profile_name_textview + R.id.profile_name_text_view ) ).check(matches(not(isEllipsized()))) } @@ -924,7 +924,7 @@ class HomeActivityTest { atPositionOnView( R.id.home_recycler_view, 0, - R.id.profile_name_textview + R.id.profile_name_text_view ) ).check(matches(not(isEllipsized()))) } diff --git a/app/src/sharedTest/java/org/oppia/android/app/recyclerview/RecyclerViewMatcher.kt b/app/src/sharedTest/java/org/oppia/android/app/recyclerview/RecyclerViewMatcher.kt index 2b4f16f78e4..2ffe1674157 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/recyclerview/RecyclerViewMatcher.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/recyclerview/RecyclerViewMatcher.kt @@ -36,10 +36,7 @@ class RecyclerViewMatcher { idDescription = try { this.resources!!.getResourceName(recyclerViewId) } catch (var4: Resources.NotFoundException) { - String.format( - "%s (resource name not found)", - recyclerViewId - ) + "$recyclerViewId (resource name not found)" } } description.appendText("with id: $idDescription") diff --git a/domain/BUILD.bazel b/domain/BUILD.bazel index f95abe3ab1a..5c9148db39d 100755 --- a/domain/BUILD.bazel +++ b/domain/BUILD.bazel @@ -92,6 +92,7 @@ kt_android_library( "//third_party:androidx_work_work-runtime-ktx", "//utility/src/main/java/org/oppia/android/util/caching:topic_list_to_cache", "//utility/src/main/java/org/oppia/android/util/data:data_providers", + "//utility/src/main/java/org/oppia/android/util/extensions:bundle_extensions", "//utility/src/main/java/org/oppia/android/util/extensions:context_extensions", "//utility/src/main/java/org/oppia/android/util/logging:event_logger", "//utility/src/main/java/org/oppia/android/util/logging:log_uploader", diff --git a/domain/src/main/java/org/oppia/android/domain/classify/rules/textinput/TextInputContainsRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/android/domain/classify/rules/textinput/TextInputContainsRuleClassifierProvider.kt index 32b1c09bc04..c86e213dae1 100644 --- a/domain/src/main/java/org/oppia/android/domain/classify/rules/textinput/TextInputContainsRuleClassifierProvider.kt +++ b/domain/src/main/java/org/oppia/android/domain/classify/rules/textinput/TextInputContainsRuleClassifierProvider.kt @@ -6,8 +6,8 @@ import org.oppia.android.domain.classify.RuleClassifier import org.oppia.android.domain.classify.rules.GenericRuleClassifier import org.oppia.android.domain.classify.rules.RuleClassifierProvider import org.oppia.android.domain.util.normalizeWhitespace -import java.util.Locale import javax.inject.Inject +import org.oppia.android.util.locale.OppiaLocale /** * Provider for a classifier that determines whether an answer contains the rule's input per the @@ -17,7 +17,8 @@ import javax.inject.Inject */ // TODO(#1580): Re-restrict access using Bazel visibilities class TextInputContainsRuleClassifierProvider @Inject constructor( - private val classifierFactory: GenericRuleClassifier.Factory + private val classifierFactory: GenericRuleClassifier.Factory, + private val machineLocale: OppiaLocale.MachineLocale ) : RuleClassifierProvider, GenericRuleClassifier.MultiTypeSingleInputMatcher { @@ -31,9 +32,9 @@ class TextInputContainsRuleClassifierProvider @Inject constructor( } override fun matches(answer: String, input: TranslatableSetOfNormalizedString): Boolean { - val normalizedAnswer = answer.normalizeWhitespace().toLowerCase(Locale.getDefault()) + val normalizedAnswer = machineLocale.run { answer.normalizeWhitespace().toMachineLowerCase() } return input.normalizedStringsList.any { - normalizedAnswer.contains(it.normalizeWhitespace().toLowerCase(Locale.getDefault())) + normalizedAnswer.contains(machineLocale.run { it.normalizeWhitespace().toMachineLowerCase() }) } } } diff --git a/domain/src/main/java/org/oppia/android/domain/classify/rules/textinput/TextInputEqualsRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/android/domain/classify/rules/textinput/TextInputEqualsRuleClassifierProvider.kt index 9d8b7d6e9d9..d3dc539d844 100644 --- a/domain/src/main/java/org/oppia/android/domain/classify/rules/textinput/TextInputEqualsRuleClassifierProvider.kt +++ b/domain/src/main/java/org/oppia/android/domain/classify/rules/textinput/TextInputEqualsRuleClassifierProvider.kt @@ -7,6 +7,7 @@ import org.oppia.android.domain.classify.rules.GenericRuleClassifier import org.oppia.android.domain.classify.rules.RuleClassifierProvider import org.oppia.android.domain.util.normalizeWhitespace import javax.inject.Inject +import org.oppia.android.util.locale.OppiaLocale /** * Provider for a classifier that determines whether two strings are equal per the text input @@ -16,7 +17,8 @@ import javax.inject.Inject */ // TODO(#1580): Re-restrict access using Bazel visibilities class TextInputEqualsRuleClassifierProvider @Inject constructor( - private val classifierFactory: GenericRuleClassifier.Factory + private val classifierFactory: GenericRuleClassifier.Factory, + private val machineLocale: OppiaLocale.MachineLocale ) : RuleClassifierProvider, GenericRuleClassifier.MultiTypeSingleInputMatcher { @@ -32,7 +34,7 @@ class TextInputEqualsRuleClassifierProvider @Inject constructor( override fun matches(answer: String, input: TranslatableSetOfNormalizedString): Boolean { val normalizedAnswer = answer.normalizeWhitespace() return input.normalizedStringsList.any { - it.normalizeWhitespace().equals(normalizedAnswer, ignoreCase = true) + machineLocale.run { it.normalizeWhitespace().equalsIgnoreCase(normalizedAnswer) } } } } diff --git a/domain/src/main/java/org/oppia/android/domain/classify/rules/textinput/TextInputFuzzyEqualsRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/android/domain/classify/rules/textinput/TextInputFuzzyEqualsRuleClassifierProvider.kt index 1a0e2d34346..9414cc422a4 100644 --- a/domain/src/main/java/org/oppia/android/domain/classify/rules/textinput/TextInputFuzzyEqualsRuleClassifierProvider.kt +++ b/domain/src/main/java/org/oppia/android/domain/classify/rules/textinput/TextInputFuzzyEqualsRuleClassifierProvider.kt @@ -7,6 +7,7 @@ import org.oppia.android.domain.classify.rules.GenericRuleClassifier import org.oppia.android.domain.classify.rules.RuleClassifierProvider import org.oppia.android.domain.util.normalizeWhitespace import javax.inject.Inject +import org.oppia.android.util.locale.OppiaLocale /** * Provider for a classifier that determines whether two strings are fuzzily equal per the text @@ -16,7 +17,8 @@ import javax.inject.Inject */ // TODO(#1580): Re-restrict access using Bazel visibilities class TextInputFuzzyEqualsRuleClassifierProvider @Inject constructor( - private val classifierFactory: GenericRuleClassifier.Factory + private val classifierFactory: GenericRuleClassifier.Factory, + private val machineLocale: OppiaLocale.MachineLocale ) : RuleClassifierProvider, GenericRuleClassifier.MultiTypeSingleInputMatcher { @@ -34,8 +36,8 @@ class TextInputFuzzyEqualsRuleClassifierProvider @Inject constructor( } private fun hasEditDistanceEqualToOne(inputString: String, matchString: String): Boolean { - val lowerInput = inputString.normalizeWhitespace().toLowerCase() - val lowerMatch = matchString.normalizeWhitespace().toLowerCase() + val lowerInput = machineLocale.run { inputString.normalizeWhitespace().toMachineLowerCase() } + val lowerMatch = machineLocale.run { matchString.normalizeWhitespace().toMachineLowerCase() } if (lowerInput == lowerMatch) { return true } diff --git a/domain/src/main/java/org/oppia/android/domain/classify/rules/textinput/TextInputStartsWithRuleClassifierProvider.kt b/domain/src/main/java/org/oppia/android/domain/classify/rules/textinput/TextInputStartsWithRuleClassifierProvider.kt index 841e6b36375..de7e6c2ba56 100644 --- a/domain/src/main/java/org/oppia/android/domain/classify/rules/textinput/TextInputStartsWithRuleClassifierProvider.kt +++ b/domain/src/main/java/org/oppia/android/domain/classify/rules/textinput/TextInputStartsWithRuleClassifierProvider.kt @@ -6,8 +6,8 @@ import org.oppia.android.domain.classify.RuleClassifier import org.oppia.android.domain.classify.rules.GenericRuleClassifier import org.oppia.android.domain.classify.rules.RuleClassifierProvider import org.oppia.android.domain.util.normalizeWhitespace -import java.util.Locale import javax.inject.Inject +import org.oppia.android.util.locale.OppiaLocale /** * Provider for a classifier that determines whether an answer starts with the rule's input per the @@ -17,7 +17,8 @@ import javax.inject.Inject */ // TODO(#1580): Re-restrict access using Bazel visibilities class TextInputStartsWithRuleClassifierProvider @Inject constructor( - private val classifierFactory: GenericRuleClassifier.Factory + private val classifierFactory: GenericRuleClassifier.Factory, + private val machineLocale: OppiaLocale.MachineLocale ) : RuleClassifierProvider, GenericRuleClassifier.MultiTypeSingleInputMatcher { @@ -31,9 +32,9 @@ class TextInputStartsWithRuleClassifierProvider @Inject constructor( } override fun matches(answer: String, input: TranslatableSetOfNormalizedString): Boolean { - val normalizedAnswer = answer.normalizeWhitespace().toLowerCase(Locale.getDefault()) + val normalizedAnswer = machineLocale.run { answer.normalizeWhitespace().toMachineLowerCase() } return input.normalizedStringsList.any { - normalizedAnswer.startsWith(it.normalizeWhitespace().toLowerCase(Locale.getDefault())) + normalizedAnswer.contains(machineLocale.run { it.normalizeWhitespace().toMachineLowerCase() }) } } } diff --git a/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationRetriever.kt b/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationRetriever.kt index a6b057dd25d..17c6b5ece78 100644 --- a/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationRetriever.kt +++ b/domain/src/main/java/org/oppia/android/domain/exploration/ExplorationRetriever.kt @@ -8,6 +8,7 @@ import org.oppia.android.domain.util.StateRetriever import org.oppia.android.util.caching.AssetRepository import org.oppia.android.util.caching.LoadLessonProtosFromAssets import javax.inject.Inject +import org.oppia.android.domain.util.getStringFromObject // TODO(#59): Make this class inaccessible outside of the domain package except for tests. UI code should not be allowed // to depend on this utility. @@ -37,11 +38,11 @@ class ExplorationRetriever @Inject constructor( private fun loadExplorationFromAsset(explorationObject: JSONObject): Exploration { val innerExplorationObject = explorationObject.getJSONObject("exploration") return Exploration.newBuilder() - .setId(explorationObject.getString("exploration_id")) - .setTitle(innerExplorationObject.getString("title")) - .setLanguageCode(innerExplorationObject.getString("language_code")) - .setInitStateName(innerExplorationObject.getString("init_state_name")) - .setObjective(innerExplorationObject.getString("objective")) + .setId(explorationObject.getStringFromObject("exploration_id")) + .setTitle(innerExplorationObject.getStringFromObject("title")) + .setLanguageCode(innerExplorationObject.getStringFromObject("language_code")) + .setInitStateName(innerExplorationObject.getStringFromObject("init_state_name")) + .setObjective(innerExplorationObject.getStringFromObject("objective")) .putAllStates(createStatesFromJsonObject(innerExplorationObject.getJSONObject("states"))) .setVersion(explorationObject.getInt("version")) .build() diff --git a/domain/src/main/java/org/oppia/android/domain/onboarding/AppStartupStateController.kt b/domain/src/main/java/org/oppia/android/domain/onboarding/AppStartupStateController.kt index 99121402a4a..39e134f5c3e 100644 --- a/domain/src/main/java/org/oppia/android/domain/onboarding/AppStartupStateController.kt +++ b/domain/src/main/java/org/oppia/android/domain/onboarding/AppStartupStateController.kt @@ -13,6 +13,7 @@ import java.util.Date import java.util.Locale import javax.inject.Inject import javax.inject.Singleton +import org.oppia.android.util.extensions.getStringFromBundle private const val APP_STARTUP_STATE_DATA_PROVIDER_ID = "app_startup_state_data_provider_id" @@ -86,7 +87,7 @@ class AppStartupStateController @Inject constructor( "automatic_app_expiration_enabled", /* defaultValue= */ true ) ?: true return if (isAppExpirationEnabled) { - val expirationDateString = applicationMetadata?.getString("expiration_date") + val expirationDateString = applicationMetadata?.getStringFromBundle("expiration_date") val expirationDate = expirationDateString?.let { parseDate(it) } // Assume the app is in an expired state if something fails when comparing the date. expirationDate?.before(Date()) ?: true diff --git a/domain/src/main/java/org/oppia/android/domain/oppialogger/loguploader/LogUploadWorker.kt b/domain/src/main/java/org/oppia/android/domain/oppialogger/loguploader/LogUploadWorker.kt index 4496f71e854..b148c041cb0 100644 --- a/domain/src/main/java/org/oppia/android/domain/oppialogger/loguploader/LogUploadWorker.kt +++ b/domain/src/main/java/org/oppia/android/domain/oppialogger/loguploader/LogUploadWorker.kt @@ -17,6 +17,7 @@ import org.oppia.android.util.logging.EventLogger import org.oppia.android.util.logging.ExceptionLogger import org.oppia.android.util.threading.BackgroundDispatcher import javax.inject.Inject +import org.oppia.android.domain.util.getStringFromData /** Worker class that extracts log reports from the cache store and logs them to the remote service. */ class LogUploadWorker private constructor( @@ -41,7 +42,7 @@ class LogUploadWorker private constructor( override fun startWork(): ListenableFuture { val backgroundScope = CoroutineScope(backgroundDispatcher) val result = backgroundScope.async { - when (inputData.getString(WORKER_CASE_KEY)) { + when (inputData.getStringFromData(WORKER_CASE_KEY)) { EVENT_WORKER -> uploadEvents() EXCEPTION_WORKER -> uploadExceptions() else -> Result.failure() diff --git a/domain/src/main/java/org/oppia/android/domain/platformparameter/syncup/PlatformParameterSyncUpWorker.kt b/domain/src/main/java/org/oppia/android/domain/platformparameter/syncup/PlatformParameterSyncUpWorker.kt index ae7d0415f46..bdf64d471ff 100644 --- a/domain/src/main/java/org/oppia/android/domain/platformparameter/syncup/PlatformParameterSyncUpWorker.kt +++ b/domain/src/main/java/org/oppia/android/domain/platformparameter/syncup/PlatformParameterSyncUpWorker.kt @@ -20,6 +20,7 @@ import retrofit2.Response import java.lang.IllegalArgumentException import java.lang.IllegalStateException import javax.inject.Inject +import org.oppia.android.domain.util.getStringFromData /** Worker class that fetches and caches the latest platform parameters from the remote service. */ class PlatformParameterSyncUpWorker private constructor( @@ -54,7 +55,7 @@ class PlatformParameterSyncUpWorker private constructor( override fun startWork(): ListenableFuture { val backgroundScope = CoroutineScope(backgroundDispatcher) val result = backgroundScope.async { - when (inputData.getString(WORKER_TYPE_KEY)) { + when (inputData.getStringFromData(WORKER_TYPE_KEY)) { PLATFORM_PARAMETER_WORKER -> refreshPlatformParameters() else -> Result.failure() } diff --git a/domain/src/main/java/org/oppia/android/domain/profile/ProfileManagementController.kt b/domain/src/main/java/org/oppia/android/domain/profile/ProfileManagementController.kt index ce4761cfba4..3a610d683b1 100644 --- a/domain/src/main/java/org/oppia/android/domain/profile/ProfileManagementController.kt +++ b/domain/src/main/java/org/oppia/android/domain/profile/ProfileManagementController.kt @@ -28,9 +28,9 @@ import org.oppia.android.util.profile.DirectoryManagementUtil import org.oppia.android.util.system.OppiaClock import java.io.File import java.io.FileOutputStream -import java.util.Locale import javax.inject.Inject import javax.inject.Singleton +import org.oppia.android.util.locale.OppiaLocale private const val GET_PROFILES_PROVIDER_ID = "get_profiles_provider_id" private const val GET_PROFILE_PROVIDER_ID = "get_profile_provider_id" @@ -67,7 +67,8 @@ class ProfileManagementController @Inject constructor( private val context: Context, private val directoryManagementUtil: DirectoryManagementUtil, private val exceptionsController: ExceptionsController, - private val oppiaClock: OppiaClock + private val oppiaClock: OppiaClock, + private val machineLocale: OppiaLocale.MachineLocale ) { private var currentProfileId: Int = -1 private val profileDataStore = @@ -681,9 +682,9 @@ class ProfileManagementController @Inject constructor( } private fun isNameUnique(newName: String, profileDatabase: ProfileDatabase): Boolean { - val lowerCaseNewName = newName.toLowerCase(Locale.getDefault()) + val lowerCaseNewName = machineLocale.run { newName.toMachineLowerCase() } profileDatabase.profilesMap.values.forEach { - if (it.name.toLowerCase(Locale.getDefault()) == lowerCaseNewName) { + if (machineLocale.run { it.name.toMachineLowerCase() } == lowerCaseNewName) { return false } } diff --git a/domain/src/main/java/org/oppia/android/domain/question/QuestionRetriever.kt b/domain/src/main/java/org/oppia/android/domain/question/QuestionRetriever.kt index ebe595f3474..c848f10c234 100644 --- a/domain/src/main/java/org/oppia/android/domain/question/QuestionRetriever.kt +++ b/domain/src/main/java/org/oppia/android/domain/question/QuestionRetriever.kt @@ -6,6 +6,8 @@ import org.oppia.android.domain.util.JsonAssetRetriever import org.oppia.android.domain.util.StateRetriever import org.oppia.android.util.caching.LoadLessonProtosFromAssets import javax.inject.Inject +import org.oppia.android.domain.util.getStringFromArray +import org.oppia.android.domain.util.getStringFromObject // TODO(#1580): Restrict access using Bazel visibilities. /** Retriever for [Question] objects from the filesystem. */ @@ -33,7 +35,7 @@ class QuestionRetriever @Inject constructor( questionJsonObject.optJSONArray("linked_skill_ids") val linkedSkillIdList = mutableListOf() for (j in 0 until questionLinkedSkillsJsonArray.length()) { - linkedSkillIdList.add(questionLinkedSkillsJsonArray.getString(j)) + linkedSkillIdList.add(questionLinkedSkillsJsonArray.getStringFromArray(j)) } if (linkedSkillIdList.contains(skillId)) { questionsList.add(createQuestionFromJsonObject(questionJsonObject)) @@ -45,7 +47,7 @@ class QuestionRetriever @Inject constructor( private fun createQuestionFromJsonObject(questionJson: JSONObject): Question { return Question.newBuilder() - .setQuestionId(questionJson.getString("id")) + .setQuestionId(questionJson.getStringFromObject("id")) .setQuestionState( stateRetriever.createStateFromJson( "question", questionJson.getJSONObject("question_state_data") diff --git a/domain/src/main/java/org/oppia/android/domain/topic/ConceptCardRetriever.kt b/domain/src/main/java/org/oppia/android/domain/topic/ConceptCardRetriever.kt index 011d5914df6..7870c518edb 100644 --- a/domain/src/main/java/org/oppia/android/domain/topic/ConceptCardRetriever.kt +++ b/domain/src/main/java/org/oppia/android/domain/topic/ConceptCardRetriever.kt @@ -14,6 +14,7 @@ import org.oppia.android.domain.util.JsonAssetRetriever import org.oppia.android.util.caching.AssetRepository import org.oppia.android.util.caching.LoadLessonProtosFromAssets import javax.inject.Inject +import org.oppia.android.domain.util.getStringFromObject // TODO(#1580): Restrict access using Bazel visibilities. /** Retriever for [ConceptCard] objects from the filesystem. */ @@ -87,13 +88,13 @@ class ConceptCardRetriever @Inject constructor( } return ConceptCard.newBuilder() - .setSkillId(skillData.getString("id")) - .setSkillDescription(skillData.getString("description")) + .setSkillId(skillData.getStringFromObject("id")) + .setSkillDescription(skillData.getStringFromObject("description")) .setExplanation( SubtitledHtml.newBuilder() - .setHtml(skillContents.getJSONObject("explanation").getString("html")) + .setHtml(skillContents.getJSONObject("explanation").getStringFromObject("html")) .setContentId( - skillContents.getJSONObject("explanation").getString( + skillContents.getJSONObject("explanation").getStringFromObject( "content_id" ) ).build() @@ -133,8 +134,8 @@ class ConceptCardRetriever @Inject constructor( private fun createSubtitledHtml( subtitledHtmlJson: JSONObject ): SubtitledHtml = SubtitledHtml.newBuilder().apply { - contentId = subtitledHtmlJson.getString("content_id") - html = subtitledHtmlJson.getString("html") + contentId = subtitledHtmlJson.getStringFromObject("content_id") + html = subtitledHtmlJson.getStringFromObject("html") }.build() private fun createWrittenTranslationFromJson( diff --git a/domain/src/main/java/org/oppia/android/domain/topic/PrimeTopicAssetsControllerImpl.kt b/domain/src/main/java/org/oppia/android/domain/topic/PrimeTopicAssetsControllerImpl.kt index a356b026cee..3ef98b1b18f 100644 --- a/domain/src/main/java/org/oppia/android/domain/topic/PrimeTopicAssetsControllerImpl.kt +++ b/domain/src/main/java/org/oppia/android/domain/topic/PrimeTopicAssetsControllerImpl.kt @@ -60,6 +60,7 @@ import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger import javax.inject.Inject import javax.inject.Singleton +import org.oppia.android.util.locale.OppiaLocale private const val CUSTOM_IMG_TAG = "oppia-noninteractive-image" private const val REPLACE_IMG_TAG = "img" @@ -81,6 +82,7 @@ class PrimeTopicAssetsControllerImpl @Inject constructor( private val questionRetriever: QuestionRetriever, private val conceptCardRetriever: ConceptCardRetriever, private val revisionCardRetriever: RevisionCardRetriever, + private val machineLocale: OppiaLocale.MachineLocale, @DefaultGcsPrefix private val gcsPrefix: String, @DefaultResourceBucketName private val gcsResource: String, @QuestionResourceBucketName private val questionGcsResource: String, @@ -100,6 +102,7 @@ class PrimeTopicAssetsControllerImpl @Inject constructor( private val extraDispatcher = Executors.newFixedThreadPool( /* nThreads= */ 4 ).asCoroutineDispatcher() + // NOTE TO DEVELOPERS: Never do this. We should never hold activity references in singleton // objects, even as weak references. This is being done to keep priming code isolated so that it's // easier to remove after #169 is completed. @@ -499,7 +502,9 @@ class PrimeTopicAssetsControllerImpl @Inject constructor( entityId: String, imageFileName: String ): String { - val downloadUrlFile = String.format(template, entityType, entityId, imageFileName) + val downloadUrlFile = machineLocale.run { + template.formatForMachines(entityType, entityId, imageFileName) + } return "$gcsPrefix/$gcsBucket/$downloadUrlFile" } diff --git a/domain/src/main/java/org/oppia/android/domain/topic/RevisionCardRetriever.kt b/domain/src/main/java/org/oppia/android/domain/topic/RevisionCardRetriever.kt index a4edb3dcff3..1836cfa6766 100644 --- a/domain/src/main/java/org/oppia/android/domain/topic/RevisionCardRetriever.kt +++ b/domain/src/main/java/org/oppia/android/domain/topic/RevisionCardRetriever.kt @@ -7,6 +7,7 @@ import org.oppia.android.domain.util.JsonAssetRetriever import org.oppia.android.util.caching.AssetRepository import org.oppia.android.util.caching.LoadLessonProtosFromAssets import javax.inject.Inject +import org.oppia.android.domain.util.getStringFromObject // TODO(#1580): Restrict access using Bazel visibilities. /** Retriever for [RevisionCard] objects from the filesystem. */ @@ -38,14 +39,14 @@ class RevisionCardRetriever @Inject constructor( jsonAssetRetriever.loadJsonFromAsset(topicId + "_" + subtopicId + ".json") ?: return RevisionCard.getDefaultInstance() val subtopicData = subtopicJsonObject.getJSONObject("page_contents")!! - val subtopicTitle = subtopicJsonObject.getString("subtopic_title")!! + val subtopicTitle = subtopicJsonObject.getStringFromObject("subtopic_title")!! return RevisionCard.newBuilder() .setSubtopicTitle(subtopicTitle) .setPageContents( SubtitledHtml.newBuilder() - .setHtml(subtopicData.getJSONObject("subtitled_html").getString("html")) + .setHtml(subtopicData.getJSONObject("subtitled_html").getStringFromObject("html")) .setContentId( - subtopicData.getJSONObject("subtitled_html").getString( + subtopicData.getJSONObject("subtitled_html").getStringFromObject( "content_id" ) ) diff --git a/domain/src/main/java/org/oppia/android/domain/topic/TopicController.kt b/domain/src/main/java/org/oppia/android/domain/topic/TopicController.kt index 64ddd72d9d9..c7d1b22d5de 100755 --- a/domain/src/main/java/org/oppia/android/domain/topic/TopicController.kt +++ b/domain/src/main/java/org/oppia/android/domain/topic/TopicController.kt @@ -38,6 +38,7 @@ import org.oppia.android.util.data.DataProviders.Companion.combineWith import org.oppia.android.util.data.DataProviders.Companion.transformAsync import javax.inject.Inject import javax.inject.Singleton +import org.oppia.android.domain.util.getStringFromObject const val TEST_SKILL_ID_0 = "test_skill_id_0" const val TEST_SKILL_ID_1 = "test_skill_id_1" @@ -465,8 +466,8 @@ class TopicController @Inject constructor( } return Topic.newBuilder() .setTopicId(topicId) - .setName(topicData.getString("topic_name")) - .setDescription(topicData.getString("topic_description")) + .setName(topicData.getStringFromObject("topic_name")) + .setDescription(topicData.getStringFromObject("topic_description")) .addAllStory(storySummaryList) .setTopicThumbnail(createTopicThumbnailFromJson(topicData)) .setDiskSizeBytes(computeTopicSizeBytes(getJsonAssetFileNameList(topicId)).toLong()) @@ -642,12 +643,12 @@ class TopicController @Inject constructor( for (i in 0 until chapterData.length()) { val chapter = chapterData.getJSONObject(i) - val explorationId = chapter.getString("exploration_id") + val explorationId = chapter.getStringFromObject("exploration_id") chapterList.add( ChapterSummary.newBuilder() .setExplorationId(explorationId) - .setName(chapter.getString("title")) - .setSummary(chapter.getString("outline")) + .setName(chapter.getStringFromObject("title")) + .setSummary(chapter.getStringFromObject("outline")) .setChapterPlayState(ChapterPlayState.COMPLETION_STATUS_UNSPECIFIED) .setChapterThumbnail(createChapterThumbnail(chapter)) .build() diff --git a/domain/src/main/java/org/oppia/android/domain/topic/TopicListController.kt b/domain/src/main/java/org/oppia/android/domain/topic/TopicListController.kt index 626214e0a7d..c83622dfc47 100644 --- a/domain/src/main/java/org/oppia/android/domain/topic/TopicListController.kt +++ b/domain/src/main/java/org/oppia/android/domain/topic/TopicListController.kt @@ -36,6 +36,7 @@ import org.oppia.android.util.system.OppiaClock import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton +import org.oppia.android.domain.util.getStringFromObject private const val ONE_WEEK_IN_DAYS = 7 @@ -224,7 +225,7 @@ class TopicListController @Inject constructor( } return TopicSummary.newBuilder() .setTopicId(topicId) - .setName(jsonObject.getString("topic_name")) + .setName(jsonObject.getStringFromObject("topic_name")) .setVersion(jsonObject.optInt("version")) .setTotalChapterCount(totalChapterCount) .setTopicThumbnail(createTopicThumbnailFromJson(jsonObject)) @@ -251,7 +252,7 @@ class TopicListController @Inject constructor( } return UpcomingTopic.newBuilder().setTopicId(topicId) - .setName(jsonObject.getString("topic_name")) + .setName(jsonObject.getStringFromObject("topic_name")) .setVersion(jsonObject.optInt("version")) .setTopicPlayAvailability(topicPlayAvailability) .setLessonThumbnail(createTopicThumbnailFromJson(jsonObject)) 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..e4458dc4df0 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 @@ -11,6 +11,7 @@ kt_android_library( ], visibility = ["//domain:__subpackages__"], deps = [ + ":extensions", "//third_party:javax_inject_javax_inject", "//utility/src/main/java/org/oppia/android/util/caching:assets", ], @@ -22,12 +23,15 @@ kt_android_library( "FloatExtensions.kt", "FractionExtensions.kt", "InteractionObjectExtensions.kt", + "JsonExtensions.kt", "RatioExtensions.kt", "StringExtensions.kt", + "WorkDataExtensions.kt", ], visibility = ["//domain:__subpackages__"], deps = [ "//model:question_java_proto_lite", + "//third_party:androidx_work_work-runtime-ktx", ], ) @@ -38,6 +42,7 @@ kt_android_library( ], visibility = ["//domain:__subpackages__"], deps = [ + ":extensions", "//model:question_java_proto_lite", "//third_party:javax_inject_javax_inject", ], diff --git a/domain/src/main/java/org/oppia/android/domain/util/JsonAssetRetriever.kt b/domain/src/main/java/org/oppia/android/domain/util/JsonAssetRetriever.kt index 6bae4a984b4..da8cd9eeb9a 100644 --- a/domain/src/main/java/org/oppia/android/domain/util/JsonAssetRetriever.kt +++ b/domain/src/main/java/org/oppia/android/domain/util/JsonAssetRetriever.kt @@ -23,7 +23,7 @@ class JsonAssetRetriever @Inject constructor(private val assetRepository: AssetR fun getStringsFromJSONArray(jsonData: JSONArray): List { val stringList = mutableListOf() for (i in 0 until jsonData.length()) { - stringList.add(jsonData.getString(i)) + stringList.add(jsonData.getStringFromArray(i)) } return stringList } diff --git a/domain/src/main/java/org/oppia/android/domain/util/JsonExtensions.kt b/domain/src/main/java/org/oppia/android/domain/util/JsonExtensions.kt new file mode 100644 index 00000000000..c85caa5a505 --- /dev/null +++ b/domain/src/main/java/org/oppia/android/domain/util/JsonExtensions.kt @@ -0,0 +1,8 @@ +package org.oppia.android.domain.util + +import org.json.JSONArray +import org.json.JSONObject + +fun JSONArray.getStringFromArray(index: Int): String = getString(index) + +fun JSONObject.getStringFromObject(name: String): String = getString(name) diff --git a/domain/src/main/java/org/oppia/android/domain/util/StateRetriever.kt b/domain/src/main/java/org/oppia/android/domain/util/StateRetriever.kt index d17372dad78..2606580b9d8 100644 --- a/domain/src/main/java/org/oppia/android/domain/util/StateRetriever.kt +++ b/domain/src/main/java/org/oppia/android/domain/util/StateRetriever.kt @@ -53,11 +53,11 @@ class StateRetriever @Inject constructor() { // Creates an interaction from JSON private fun createInteractionFromJson(interactionJson: JSONObject): Interaction { return Interaction.newBuilder() - .setId(interactionJson.getString("id")) + .setId(interactionJson.getStringFromObject("id")) .addAllAnswerGroups( createAnswerGroupsFromJson( interactionJson.getJSONArray("answer_groups"), - interactionJson.getString("id") + interactionJson.getStringFromObject("id") ) ) .setDefaultOutcome( @@ -68,7 +68,7 @@ class StateRetriever @Inject constructor() { .putAllCustomizationArgs( createCustomizationArgsMapFromJson( interactionJson.getJSONObject("customization_args"), - interactionJson.getString("id") + interactionJson.getStringFromObject("id") ) ) .addAllHint( @@ -127,7 +127,7 @@ class StateRetriever @Inject constructor() { addAllRuleSpecs(ruleSpecsJson.map { convertToRuleSpec(it, interactionId) }) val misconceptionJson = if (answerGroupJson.isNull("tagged_skill_misconception_id")) null - else answerGroupJson.getString("tagged_skill_misconception_id") + else answerGroupJson.getStringFromObject("tagged_skill_misconception_id") if (!misconceptionJson.isNullOrEmpty()) { val misconceptionParts = misconceptionJson.split("-") taggedSkillMisconception = @@ -144,7 +144,7 @@ class StateRetriever @Inject constructor() { return Outcome.getDefaultInstance() } return Outcome.newBuilder() - .setDestStateName(outcomeJson.getString("dest")) + .setDestStateName(outcomeJson.getStringFromObject("dest")) .setFeedback(createFeedbackSubtitledHtml(outcomeJson)) .setLabelledAsCorrect(outcomeJson.getBoolean("labelled_as_correct")) .build() @@ -173,7 +173,7 @@ class StateRetriever @Inject constructor() { .build() } else { CorrectAnswer.newBuilder() - .setCorrectAnswer(containerObject.getString("correct_answer")) + .setCorrectAnswer(containerObject.getStringFromObject("correct_answer")) .build() } } @@ -185,8 +185,8 @@ class StateRetriever @Inject constructor() { private fun createFeedbackSubtitledHtml(containerObject: JSONObject): SubtitledHtml { val feedbackObject = containerObject.getJSONObject("feedback") return SubtitledHtml.newBuilder() - .setContentId(feedbackObject.getString("content_id")) - .setHtml(feedbackObject.getString("html")) + .setContentId(feedbackObject.getStringFromObject("content_id")) + .setHtml(feedbackObject.getStringFromObject("html")) .build() } @@ -212,14 +212,14 @@ class StateRetriever @Inject constructor() { private fun createVoiceoverFromJson(voiceoverJson: JSONObject): Voiceover = Voiceover.newBuilder().apply { needsUpdate = voiceoverJson.getBoolean("needs_update") - fileName = voiceoverJson.getString("filename") + fileName = voiceoverJson.getStringFromObject("filename") }.build() // Creates the list of rule spec objects from JSON private fun convertToRuleSpec(ruleSpecJson: JSONObject, interactionId: String): RuleSpec { val inputJsonObject = ruleSpecJson.getJSONObject("inputs") return RuleSpec.newBuilder().apply { - ruleType = ruleSpecJson.getString("rule_type") + ruleType = ruleSpecJson.getStringFromObject("rule_type") putAllInput( inputJsonObject.keys().asSequence().associateWith { inputName -> createExactInputFromJson(inputJsonObject, inputName, interactionId, ruleType) @@ -264,7 +264,7 @@ class StateRetriever @Inject constructor() { "DragAndDropSortInput" -> createExactInputForDragDropAndSort(inputJson, keyName, ruleType) "ImageClickInput" -> InteractionObject.newBuilder() - .setNormalizedString(inputJson.getString(keyName)) + .setNormalizedString(inputJson.getStringFromObject(keyName)) .build() "RatioExpressionInput" -> createExactInputForRatioExpressionInput(inputJson, keyName, ruleType) @@ -308,7 +308,7 @@ class StateRetriever @Inject constructor() { "x" -> InteractionObject.newBuilder() .setTranslatableHtmlContentId( - parseTranslatableContentId(inputJson.getString(keyName)) + parseTranslatableContentId(inputJson.getStringFromObject(keyName)) ) .build() "y" -> @@ -320,7 +320,7 @@ class StateRetriever @Inject constructor() { "HasElementXBeforeElementY" -> InteractionObject.newBuilder() .setTranslatableHtmlContentId( - parseTranslatableContentId(inputJson.getString(keyName)) + parseTranslatableContentId(inputJson.getStringFromObject(keyName)) ) .build() else -> @@ -357,10 +357,10 @@ class StateRetriever @Inject constructor() { private fun parseTranslatableSetOfNormalizedString( translatableSetOfStringsJson: JSONObject ): TranslatableSetOfNormalizedString = TranslatableSetOfNormalizedString.newBuilder().apply { - contentId = translatableSetOfStringsJson.getString("contentId") + contentId = translatableSetOfStringsJson.getStringFromObject("contentId") val strSet = translatableSetOfStringsJson.getJSONArray("normalizedStrSet") for (i in 0 until strSet.length()) { - addNormalizedStrings(strSet.getString(i)) + addNormalizedStrings(strSet.getStringFromArray(i)) } }.build() @@ -382,7 +382,7 @@ class StateRetriever @Inject constructor() { val setOfContentIdsBuilder = SetOfTranslatableHtmlContentIds.newBuilder() for (i in 0 until setOfContentIdsJson.length()) { setOfContentIdsBuilder.addContentIds( - parseTranslatableContentId(setOfContentIdsJson.getString(i)) + parseTranslatableContentId(setOfContentIdsJson.getStringFromArray(i)) ) } return setOfContentIdsBuilder.build() @@ -393,7 +393,7 @@ class StateRetriever @Inject constructor() { private fun parseNumberWithUnitsObject(numberWithUnitsAnswer: JSONObject): NumberWithUnits { val numberWithUnitsBuilder = NumberWithUnits.newBuilder() - when (numberWithUnitsAnswer.getString("type")) { + when (numberWithUnitsAnswer.getStringFromObject("type")) { "real" -> numberWithUnitsBuilder.real = numberWithUnitsAnswer.getDouble("real") "fraction" -> numberWithUnitsBuilder.fraction = @@ -404,7 +404,7 @@ class StateRetriever @Inject constructor() { val unit = unitsArray.getJSONObject(i) numberWithUnitsBuilder.addUnit( NumberUnit.newBuilder() - .setUnit(unit.getString("unit")) + .setUnit(unit.getStringFromObject("unit")) .setExponent(unit.getInt("exponent")) ) } @@ -577,8 +577,8 @@ class StateRetriever @Inject constructor() { private fun parseSubtitledHtml(subtitledHtmlJson: JSONObject): SubtitledHtml = SubtitledHtml.newBuilder().apply { - contentId = subtitledHtmlJson.getString("content_id") - html = subtitledHtmlJson.getString("html") + contentId = subtitledHtmlJson.getStringFromObject("content_id") + html = subtitledHtmlJson.getStringFromObject("html") }.build() private fun parseIntegerSchemaObject(value: Int): SchemaObject { @@ -607,8 +607,8 @@ class StateRetriever @Inject constructor() { private fun parseSubtitledUnicode(jsonObject: JSONObject): SchemaObject { val subtitledUnicodeBuilder = SubtitledUnicode.newBuilder() - subtitledUnicodeBuilder.contentId = jsonObject.getString("content_id") - subtitledUnicodeBuilder.unicodeStr = jsonObject.getString("unicode_str") + subtitledUnicodeBuilder.contentId = jsonObject.getStringFromObject("content_id") + subtitledUnicodeBuilder.unicodeStr = jsonObject.getStringFromObject("unicode_str") val schemaObjectBuilder = SchemaObject.newBuilder() schemaObjectBuilder.setSubtitledUnicode(subtitledUnicodeBuilder) return schemaObjectBuilder.build() @@ -617,7 +617,7 @@ class StateRetriever @Inject constructor() { private fun parseImageWithRegions(jsonObject: JSONObject): SchemaObject { val imageWithRegions = ImageWithRegions.newBuilder() .addAllLabelRegions(parseJsonToLabeledRegionsList(jsonObject.getJSONArray("labeledRegions"))) - .setImagePath(jsonObject.getString("imagePath")) + .setImagePath(jsonObject.getStringFromObject("imagePath")) .build() return SchemaObject.newBuilder().setCustomSchemaValue( @@ -635,7 +635,7 @@ class StateRetriever @Inject constructor() { private fun parseLabeledRegion(jsonObject: JSONObject): LabeledRegion { return LabeledRegion.newBuilder() - .setLabel(jsonObject.getString("label")) + .setLabel(jsonObject.getStringFromObject("label")) .setRegion(parseRegion(jsonObject.getJSONObject("region"))) .build() } diff --git a/domain/src/main/java/org/oppia/android/domain/util/WorkDataExtensions.kt b/domain/src/main/java/org/oppia/android/domain/util/WorkDataExtensions.kt new file mode 100644 index 00000000000..41fc6739db0 --- /dev/null +++ b/domain/src/main/java/org/oppia/android/domain/util/WorkDataExtensions.kt @@ -0,0 +1,5 @@ +package org.oppia.android.domain.util + +import androidx.work.Data + +fun Data.getStringFromData(key: String): String? = getString(key) diff --git a/scripts/assets/file_content_validation_checks.textproto b/scripts/assets/file_content_validation_checks.textproto index 27552f874d7..e8f036f6483 100644 --- a/scripts/assets/file_content_validation_checks.textproto +++ b/scripts/assets/file_content_validation_checks.textproto @@ -100,5 +100,73 @@ file_content_checks { file_path_regex: ".+?.xml" prohibited_content_regex: "" failure_message: "All strings outside strings.xml must be marked as not translatable, or moved to strings.xml." - exempted_file_name: "app/src/main/res/values/strings.xml" + exempted_file_patterns: "app/src/main/res/values.*?/strings\\.xml" +} +file_content_checks { + file_path_regex: ".+?.kt" + prohibited_content_regex: "(format|getString|getStringArray|getQuantityString|getQuantityText|toLowerCase|toUpperCase|capitalize|decapitalize|lowercase|uppercase)\\(" + failure_message: "String formatting and resource retrieval should go through AppLanguageResourceHandler, OppiaLocale.DisplayLocale, or OppiaLocale.MachineLocale depending on the context (see each class's documentation for details on when each should be used)." + exempted_file_name: "domain/src/main/java/org/oppia/android/domain/locale/DisplayLocaleImpl.kt" + exempted_file_name: "domain/src/main/java/org/oppia/android/domain/locale/MachineLocaleImpl.kt" + exempted_file_name: "domain/src/main/java/org/oppia/android/domain/locale/OppiaLocale.kt" + exempted_file_name: "domain/src/main/java/org/oppia/android/domain/util/JsonExtensions.kt" + exempted_file_name: "domain/src/main/java/org/oppia/android/domain/util/WorkDataExtensions.kt" + exempted_file_name: "domain/src/test/java/org/oppia/android/domain/onboarding/AppStartupStateControllerTest.kt" + exempted_file_name: "testing/src/main/java/org/oppia/android/testing/OppiaTestRunner.kt" + exempted_file_name: "testing/src/main/java/org/oppia/android/testing/time/FakeOppiaClock.kt" + exempted_file_name: "utility/src/main/java/org/oppia/android/util/extensions/BundleExtensions.kt" + exempted_file_patterns: "app/src/test/.+?Test\\.kt" + exempted_file_patterns: "app/src/sharedTest/.+?Test\\.kt" + exempted_file_patterns: "scripts/.+" +} +file_content_checks { + file_path_regex: ".+?.java" + prohibited_content_regex: "(format|getString|getStringArray|getQuantityString|getQuantityText|toLowerCase|toUpperCase|capitalize|decapitalize|lowercase|uppercase)\\(" + failure_message: "String formatting and resource retrieval should go through AppLanguageResourceHandler, OppiaLocale.DisplayLocale, or OppiaLocale.MachineLocale depending on the context (see each class's documentation for details on when each should be used)." +} +file_content_checks { + file_path_regex: ".+?.kt" + prohibited_content_regex: "ignoreCase\\s*?=" + failure_message: "Case-insensitive string operations should be performed using MachineLocale." + exempted_file_patterns: "testing/src/main/.+?.kt" + exempted_file_patterns: "scripts/.+" +} +file_content_checks { + file_path_regex: ".+?.xml" + prohibited_content_regex: "(format|getString|getStringArray)\\(" + failure_message: "String formatting and resource retrieval in layouts should go through AppLanguageResourceHandler." +} +file_content_checks { + file_path_regex: ".+?.xml" + prohibited_content_regex: "@string/[^\\s]+?\\(" + failure_message: "String formatting and quantity string building shouldn't be done directly through databinding. Instead, pass in AppLanguageResourceHandler from the view model or call a new function through the view model to compute the string. Both should use the handler's locale-safe formatting/quantity string methods." +} +file_content_checks { + file_path_regex: ".+?.xml" + prohibited_content_regex: "@plurals/[^\\s]+?\\(" + failure_message: "String plurals shouldn't be constructed directly through databinding. Instead, pass in AppLanguageResourceHandler from the view model or call a new function through the view model to compute the string. Both should use the handler's locale-safe formatting/quantity string methods." +} +file_content_checks { + file_path_regex: ".+?.kt" + prohibited_content_regex: "\\sActivity\\(" + failure_message: "Activity should never be subclassed. Use AppCompatActivity, instead." +} +file_content_checks { + file_path_regex: ".+?.kt" + prohibited_content_regex: "\\sAppCompatActivity\\(" + failure_message: "Never subclass AppCompatActivity directly. Instead, use InjectableAppCompatActivity." + exempted_file_name: "app/src/main/java/org/oppia/android/app/activity/InjectableAppCompatActivity.kt" + exempted_file_name: "app/src/main/java/org/oppia/android/app/splash/SplashActivity.kt" + exempted_file_patterns: "app/src/main/java/org/oppia/android/app/testing/.*?TestActivity.kt$" +} +file_content_checks { + file_path_regex: ".+?.kt" + prohibited_content_regex: "\\sDialogFragment\\(" + failure_message: "DialogFragment should never be subclassed. Use InjectableDialogFragment, instead." + exempted_file_name: "app/src/main/java/org/oppia/android/app/fragment/InjectableDialogFragment.kt" +} +file_content_checks { + file_path_regex: ".+?AndroidManifest.xml" + prohibited_content_regex: "android:configChanges" + failure_message: "Never explicitly handle configuration changes. Instead, use saved instance states for retaining state across rotations. For other types of configuration changes, follow up with the developer mailing list with how to proceed if you think this is a legitimate case." } diff --git a/scripts/src/java/org/oppia/android/scripts/proto/file_content_validation_checks.proto b/scripts/src/java/org/oppia/android/scripts/proto/file_content_validation_checks.proto index 8e439361a63..369918fe75b 100644 --- a/scripts/src/java/org/oppia/android/scripts/proto/file_content_validation_checks.proto +++ b/scripts/src/java/org/oppia/android/scripts/proto/file_content_validation_checks.proto @@ -24,4 +24,7 @@ message FileContentCheck { // Names of all the files which should be exempted for this check. repeated string exempted_file_name = 4; + + // Regex patterns for all file/file paths that should be exempted for this check. + repeated string exempted_file_patterns = 5; } diff --git a/scripts/src/java/org/oppia/android/scripts/regex/RegexPatternValidationCheck.kt b/scripts/src/java/org/oppia/android/scripts/regex/RegexPatternValidationCheck.kt index e3826a45d1f..c6ba0af0f16 100644 --- a/scripts/src/java/org/oppia/android/scripts/regex/RegexPatternValidationCheck.kt +++ b/scripts/src/java/org/oppia/android/scripts/regex/RegexPatternValidationCheck.kt @@ -152,7 +152,12 @@ private fun checkProhibitedContent( val matchedFiles = searchFiles.filter { file -> val fileRelativePath = file.toRelativeString(repoRoot) - val isExempted = fileRelativePath in fileContentCheck.exemptedFileNameList + val isFileExactExemption = fileRelativePath in fileContentCheck.exemptedFileNameList + val isFileMatchedExemption = fileContentCheck.exemptedFilePatternsList.any { pattern -> + pattern.toRegex().matches(fileRelativePath) + } + // TODO: add tests. + val isExempted = isFileExactExemption || isFileMatchedExemption return@filter if (!isExempted && filePathRegex.matches(fileRelativePath)) { file.useLines { lines -> lines.foldIndexed(initial = false) { lineIndex, isFailing, lineContent -> diff --git a/testing/src/main/java/org/oppia/android/testing/environment/TestEnvironmentConfig.kt b/testing/src/main/java/org/oppia/android/testing/environment/TestEnvironmentConfig.kt index 9b5c4c40172..5077b009c06 100644 --- a/testing/src/main/java/org/oppia/android/testing/environment/TestEnvironmentConfig.kt +++ b/testing/src/main/java/org/oppia/android/testing/environment/TestEnvironmentConfig.kt @@ -1,16 +1,18 @@ package org.oppia.android.testing.environment -import java.util.Locale import javax.inject.Inject +import org.oppia.android.util.locale.OppiaLocale /** Utility class that provides details on the local test environment configuration. */ -class TestEnvironmentConfig @Inject constructor() { +class TestEnvironmentConfig @Inject constructor( + private val machineLocale: OppiaLocale.MachineLocale +) { /** Returns whether the current runtime environment is being run with Bazel. */ fun isUsingBazel(): Boolean { // Some of the system properties are Bazel-specific; this is an easy hacky way to check if any // of them are set to indicate a Bazel environment. return System.getProperties().keys().asSequence().map { - it.toString().toLowerCase(Locale.getDefault()) + machineLocale.run { it.toString().toMachineLowerCase() } }.any { "bazel" in it } } } diff --git a/utility/BUILD.bazel b/utility/BUILD.bazel index 68333f33a93..94659b632f2 100644 --- a/utility/BUILD.bazel +++ b/utility/BUILD.bazel @@ -60,11 +60,11 @@ kt_android_library( "//utility/src/main/java/org/oppia/android/util/caching:assets", "//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/locale:oppia_locale", "//utility/src/main/java/org/oppia/android/util/logging:console_logger", "//utility/src/main/java/org/oppia/android/util/logging:exception_logger", "//utility/src/main/java/org/oppia/android/util/logging:prod_module", "//utility/src/main/java/org/oppia/android/util/system:oppia_clock", - "//utility/src/main/java/org/oppia/android/util/system:oppia_date_time_formatter", "//utility/src/main/java/org/oppia/android/util/system:prod_module", "//utility/src/main/java/org/oppia/android/util/threading:concurrent_collections", "//utility/src/main/java/org/oppia/android/util/threading:prod_module", diff --git a/utility/src/main/java/org/oppia/android/util/extensions/BundleExtensions.kt b/utility/src/main/java/org/oppia/android/util/extensions/BundleExtensions.kt index 652ef09481e..d0efa957d7f 100644 --- a/utility/src/main/java/org/oppia/android/util/extensions/BundleExtensions.kt +++ b/utility/src/main/java/org/oppia/android/util/extensions/BundleExtensions.kt @@ -42,3 +42,5 @@ fun Bundle.getProto(name: String, defaultValue: T): T { } } ?: defaultValue } + +fun Bundle.getStringFromBundle(key: String): String? = getString(key) diff --git a/utility/src/main/java/org/oppia/android/util/parser/image/UrlImageParser.kt b/utility/src/main/java/org/oppia/android/util/parser/image/UrlImageParser.kt index 3b39cde08df..dd5e2b7a21c 100644 --- a/utility/src/main/java/org/oppia/android/util/parser/image/UrlImageParser.kt +++ b/utility/src/main/java/org/oppia/android/util/parser/image/UrlImageParser.kt @@ -22,6 +22,7 @@ import org.oppia.android.util.parser.html.CustomHtmlContentHandler.ImageRetrieve import org.oppia.android.util.parser.svg.BlockPictureDrawable import javax.inject.Inject import kotlin.math.max +import org.oppia.android.util.locale.OppiaLocale // TODO(#169): Replace this with exploration asset downloader. @@ -36,7 +37,8 @@ class UrlImageParser private constructor( private val entityId: String, private val imageCenterAlign: Boolean, private val imageLoader: ImageLoader, - private val consoleLogger: ConsoleLogger + private val consoleLogger: ConsoleLogger, + private val machineLocale: OppiaLocale.MachineLocale ) : Html.ImageGetter, CustomHtmlContentHandler.ImageRetriever { override fun getDrawable(urlString: String): Drawable { // Only block images can be loaded through the standard ImageGetter. @@ -44,11 +46,13 @@ class UrlImageParser private constructor( } override fun loadDrawable(filename: String, type: ImageRetriever.Type): Drawable { - val imagePath = String.format(imageDownloadUrlTemplate, entityType, entityId, filename) + val imagePath = machineLocale.run { + imageDownloadUrlTemplate.formatForMachines(entityType, entityId, filename) + } val imageUrl = "$gcsPrefix/$gcsResourceName/$imagePath" val proxyDrawable = ProxyDrawable() // TODO(#1039): Introduce custom type OppiaImage for rendering Bitmap and Svg. - val isSvg = imageUrl.endsWith("svg", ignoreCase = true) + val isSvg = machineLocale.run { imageUrl.endsWithIgnoreCase("svg") } val adjustedType = if (type == ImageRetriever.Type.INLINE_TEXT_IMAGE && !isSvg) { // Treat non-svg in-line images as block, instead, since only SVG is supported. consoleLogger.w("UrlImageParser", "Forcing image $filename to block image") @@ -315,7 +319,8 @@ class UrlImageParser private constructor( @DefaultGcsPrefix private val gcsPrefix: String, @ImageDownloadUrlTemplate private val imageDownloadUrlTemplate: String, private val imageLoader: ImageLoader, - private val consoleLogger: ConsoleLogger + private val consoleLogger: ConsoleLogger, + private val machineLocale: OppiaLocale.MachineLocale ) { /** * Creates a new [UrlImageParser] based on the specified settings. @@ -348,7 +353,8 @@ class UrlImageParser private constructor( entityId, imageCenterAlign, imageLoader, - consoleLogger + consoleLogger, + machineLocale ) } } diff --git a/utility/src/main/java/org/oppia/android/util/system/BUILD.bazel b/utility/src/main/java/org/oppia/android/util/system/BUILD.bazel index 4de70d2aa54..76e15d8f17d 100644 --- a/utility/src/main/java/org/oppia/android/util/system/BUILD.bazel +++ b/utility/src/main/java/org/oppia/android/util/system/BUILD.bazel @@ -37,13 +37,24 @@ kt_android_library( ) kt_android_library( - name = "oppia_date_time_formatter", + name = "oppia_clock_injector", srcs = [ - "OppiaDateTimeFormatter.kt", + "OppiaClockInjector.kt", ], visibility = ["//:oppia_api_visibility"], deps = [ - "//third_party:javax_inject_javax_inject", + ":oppia_clock", + ], +) + +kt_android_library( + name = "oppia_clock_injector_provider", + srcs = [ + "OppiaClockInjectorProvider.kt", + ], + visibility = ["//:oppia_api_visibility"], + deps = [ + ":oppia_clock_injector", ], ) diff --git a/utility/src/main/java/org/oppia/android/util/system/OppiaClockInjector.kt b/utility/src/main/java/org/oppia/android/util/system/OppiaClockInjector.kt new file mode 100644 index 00000000000..71fcf93fc33 --- /dev/null +++ b/utility/src/main/java/org/oppia/android/util/system/OppiaClockInjector.kt @@ -0,0 +1,5 @@ +package org.oppia.android.util.system + +interface OppiaClockInjector { + fun getOppiaClock(): OppiaClock +} diff --git a/utility/src/main/java/org/oppia/android/util/system/OppiaClockInjectorProvider.kt b/utility/src/main/java/org/oppia/android/util/system/OppiaClockInjectorProvider.kt new file mode 100644 index 00000000000..ad716441815 --- /dev/null +++ b/utility/src/main/java/org/oppia/android/util/system/OppiaClockInjectorProvider.kt @@ -0,0 +1,5 @@ +package org.oppia.android.util.system + +interface OppiaClockInjectorProvider { + fun getOppiaClockInjector(): OppiaClockInjector +} diff --git a/utility/src/main/java/org/oppia/android/util/system/OppiaDateTimeFormatter.kt b/utility/src/main/java/org/oppia/android/util/system/OppiaDateTimeFormatter.kt deleted file mode 100644 index 34aba7d805d..00000000000 --- a/utility/src/main/java/org/oppia/android/util/system/OppiaDateTimeFormatter.kt +++ /dev/null @@ -1,46 +0,0 @@ -package org.oppia.android.util.system - -import java.text.SimpleDateFormat -import java.util.Calendar -import java.util.Date -import java.util.Locale -import javax.inject.Inject -import javax.inject.Singleton - -/** Utility to format date to text or parse text to date. */ -@Singleton -class OppiaDateTimeFormatter @Inject constructor() { - - companion object { - const val DD_MMM_YYYY = "dd MMMM yyyy" - const val DD_MMM_hh_mm_aa = "dd MMM hh:mm aa" - } - - fun formatDateFromDateString( - inputDateFormat: String, - timestamp: Long, - locale: Locale = Locale.getDefault() - ): String { - return try { - val sdf = SimpleDateFormat(inputDateFormat, locale) - val dateTime = Date(timestamp) - sdf.format(dateTime) - } catch (e: Exception) { - e.toString() - } - } - - fun currentDate(): Date { - val calendar = Calendar.getInstance() - return calendar.time - } - - fun checkAndConvertTimestampToMilliseconds(lastVisitedTimeStamp: Long): Long { - var timeStamp = lastVisitedTimeStamp - if (timeStamp < 1000000000000L) { - // If timestamp is given in seconds, convert that to milliseconds. - timeStamp *= 1000 - } - return timeStamp - } -} diff --git a/utility/src/test/java/org/oppia/android/util/parser/html/CustomHtmlContentHandlerTest.kt b/utility/src/test/java/org/oppia/android/util/parser/html/CustomHtmlContentHandlerTest.kt index 0d194022dbd..4d0a58af4a7 100644 --- a/utility/src/test/java/org/oppia/android/util/parser/html/CustomHtmlContentHandlerTest.kt +++ b/utility/src/test/java/org/oppia/android/util/parser/html/CustomHtmlContentHandlerTest.kt @@ -19,6 +19,7 @@ import org.robolectric.annotation.LooperMode import org.xml.sax.Attributes import org.xml.sax.helpers.AttributesImpl import kotlin.reflect.KClass +import org.oppia.android.domain.util.getStringFromObject /** Tests for [CustomHtmlContentHandler]. */ @RunWith(AndroidJUnit4::class) @@ -225,7 +226,7 @@ class CustomHtmlContentHandlerTest { assertThat(jsonObject).isNotNull() assertThat(jsonObject?.has("key")).isTrue() - assertThat(jsonObject?.getString("key")).isEqualTo("value with \\frac{1}{2}") + assertThat(jsonObject?.getStringFromObject("key")).isEqualTo("value with \\frac{1}{2}") } private fun Spannable.getSpansFromWholeString(spanClass: KClass): Array = diff --git a/utility/src/test/java/org/oppia/android/util/system/OppiaDateTimeFormatterTest.kt b/utility/src/test/java/org/oppia/android/util/system/OppiaDateTimeFormatterTest.kt index 9a1f778630a..c41efa77383 100644 --- a/utility/src/test/java/org/oppia/android/util/system/OppiaDateTimeFormatterTest.kt +++ b/utility/src/test/java/org/oppia/android/util/system/OppiaDateTimeFormatterTest.kt @@ -30,6 +30,8 @@ private const val TIMESTAMP_IN_SECONDS = 1586774460L @Config(manifest = Config.NONE) class OppiaDateTimeFormatterTest { + // TODO: convert to tests for TextViewBindingAdapters? + @Inject lateinit var oppiaDateTimeFormatter: OppiaDateTimeFormatter From 583a05c5c68b82bbefdd854bd6d63f084ca1faca Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Thu, 9 Sep 2021 22:05:30 -0700 Subject: [PATCH 23/93] 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). --- .../domain/locale/AndroidLocaleProfile.kt | 1 + .../oppia/android/domain/locale/BUILD.bazel | 22 ++------- .../domain/locale/DisplayLocaleImpl.kt | 26 ++++++++--- .../android/domain/locale/LocaleController.kt | 22 +++++---- .../android/domain/translation/BUILD.bazel | 2 +- .../translation/TranslationController.kt | 2 +- .../org/oppia/android/util/locale/BUILD.bazel | 45 +++++++++++++++++++ .../android/util}/locale/MachineLocaleImpl.kt | 8 +++- .../util/locale/MachineLocaleModule.kt | 10 +++++ .../oppia/android/util}/locale/OppiaLocale.kt | 28 ++++++++---- 10 files changed, 117 insertions(+), 49 deletions(-) create mode 100644 utility/src/main/java/org/oppia/android/util/locale/BUILD.bazel rename {domain/src/main/java/org/oppia/android/domain => utility/src/main/java/org/oppia/android/util}/locale/MachineLocaleImpl.kt (92%) create mode 100644 utility/src/main/java/org/oppia/android/util/locale/MachineLocaleModule.kt rename {domain/src/main/java/org/oppia/android/domain => utility/src/main/java/org/oppia/android/util}/locale/OppiaLocale.kt (81%) diff --git a/domain/src/main/java/org/oppia/android/domain/locale/AndroidLocaleProfile.kt b/domain/src/main/java/org/oppia/android/domain/locale/AndroidLocaleProfile.kt index 20911659969..770ffa40a10 100644 --- a/domain/src/main/java/org/oppia/android/domain/locale/AndroidLocaleProfile.kt +++ b/domain/src/main/java/org/oppia/android/domain/locale/AndroidLocaleProfile.kt @@ -1,6 +1,7 @@ package org.oppia.android.domain.locale import java.util.Locale +import org.oppia.android.util.locale.OppiaLocale data class AndroidLocaleProfile(val languageCode: String, val regionCode: String) { fun matches( 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 8c0c52a35b4..a7a90566cf6 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 @@ -5,18 +5,6 @@ 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 = "oppia_locale", - srcs = [ - "OppiaLocale.kt", - ], - visibility = ["//:oppia_api_visibility"], - deps = [ - "//model:languages_java_proto_lite", - "//third_party:androidx_annotation_annotation", - ], -) - kt_android_library( name = "locale_controller", srcs = [ @@ -25,27 +13,25 @@ kt_android_library( visibility = ["//:oppia_api_visibility"], deps = [ ":dagger", - ":impl", + ":display_locale_impl", ":language_config_retriever", - ":oppia_locale", "//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 = "impl", + name = "display_locale_impl", srcs = [ "AndroidLocaleProfile.kt", "DisplayLocaleImpl.kt", - "MachineLocaleImpl.kt", ], deps = [ - ":oppia_locale", "//utility/src/main/java/org/oppia/android/util/data:data_providers", - "//utility/src/main/java/org/oppia/android/util/system:oppia_clock", + "//utility/src/main/java/org/oppia/android/util/locale:oppia_locale", ], ) 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 index a45f2bc4005..4a68d3c4620 100644 --- a/domain/src/main/java/org/oppia/android/domain/locale/DisplayLocaleImpl.kt +++ b/domain/src/main/java/org/oppia/android/domain/locale/DisplayLocaleImpl.kt @@ -7,17 +7,17 @@ import android.text.BidiFormatter import androidx.annotation.ArrayRes import androidx.annotation.StringRes import java.text.DateFormat +import java.util.Date import java.util.Locale import java.util.Objects 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.system.OppiaClock +import org.oppia.android.util.locale.OppiaLocale // TODO(#3766): Restrict to be 'internal'. class DisplayLocaleImpl( - private val oppiaClock: OppiaClock, localeContext: OppiaLocaleContext, private val machineLocale: MachineLocale ): OppiaLocale.DisplayLocale(localeContext) { @@ -39,18 +39,22 @@ class DisplayLocaleImpl( configuration.setLocale(formattingLocale) } - override fun getCurrentDateString(): String = dateFormat.format(oppiaClock.getCurrentDate()) + override fun computeDateString(timestampMillis: Long): String = + dateFormat.format(Date(timestampMillis)) - override fun getCurrentTimeString(): String = timeFormat.format(oppiaClock.getCurrentDate()) + override fun computeTimeString(timestampMillis: Long): String = + timeFormat.format(Date(timestampMillis)) - override fun getCurrentDateTimeString(): String = - dateTimeFormat.format(oppiaClock.getCurrentDate()) + override fun computeDateTimeString(timestampMillis: Long): String = + dateTimeFormat.format(Date(timestampMillis)) override fun String.formatInLocale(vararg args: Any?): String = format(formattingLocale, *args.map { arg -> if (arg is CharSequence) bidiFormatter.unicodeWrap(arg) else arg }.toTypedArray()) + override fun String.capitalizeForHumans(): String = capitalize(formattingLocale) + override fun Resources.getStringInLocale(@StringRes id: Int): String = getString(id) override fun Resources.getStringInLocale(@StringRes id: Int, vararg formatArgs: Any?): String = @@ -59,6 +63,16 @@ class DisplayLocaleImpl( 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.getQuantityStringInLocale( + id: Int, quantity: Int, vararg formatArgs: Any? + ): String = getQuantityStringInLocale(id, quantity).formatInLocale(*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 { diff --git a/domain/src/main/java/org/oppia/android/domain/locale/LocaleController.kt b/domain/src/main/java/org/oppia/android/domain/locale/LocaleController.kt index 8bbc0aae8a3..e947febcaad 100644 --- a/domain/src/main/java/org/oppia/android/domain/locale/LocaleController.kt +++ b/domain/src/main/java/org/oppia/android/domain/locale/LocaleController.kt @@ -22,13 +22,14 @@ import org.oppia.android.app.model.OppiaLocaleContext.LanguageUsageMode.AUDIO_TR 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.domain.locale.OppiaLocale.ContentLocale -import org.oppia.android.domain.locale.OppiaLocale.DisplayLocale -import org.oppia.android.domain.locale.OppiaLocale.MachineLocale +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 org.oppia.android.util.data.AsyncDataSubscriptionManager import org.oppia.android.util.data.AsyncResult import org.oppia.android.util.data.DataProviders import org.oppia.android.util.data.DataProviders.Companion.transformAsync +import org.oppia.android.util.locale.OppiaLocale import org.oppia.android.util.system.OppiaClock // TODO: document how notifications work (everything is rooted from changing Locale). @@ -45,14 +46,13 @@ class LocaleController @Inject constructor( private val languageConfigRetriever: LanguageConfigRetriever, private val oppiaLogger: OppiaLogger, private val asyncDataSubscriptionManager: AsyncDataSubscriptionManager, - private val oppiaClock: OppiaClock + private val oppiaClock: OppiaClock, + private val machineLocale: MachineLocale ) { private val definitionsLock = ReentrantLock() private lateinit var supportedLanguages: SupportedLanguages private lateinit var supportedRegions: SupportedRegions - private val machineLocaleImpl: MachineLocale by lazy { MachineLocaleImpl(oppiaClock) } - // TODO: explain what this is & how/when to use it. fun getLikelyDefaultAppStringLocaleContext(): OppiaLocaleContext { return OppiaLocaleContext.newBuilder().apply { @@ -82,7 +82,7 @@ class LocaleController @Inject constructor( // TODO: this should also work in cases when the process dies. fun reconstituteDisplayLocale(oppiaLocaleContext: OppiaLocaleContext): DisplayLocale { - return DisplayLocaleImpl(oppiaClock, oppiaLocaleContext, machineLocaleImpl) + return DisplayLocaleImpl(oppiaLocaleContext, machineLocale) } // TODO: document @@ -114,8 +114,6 @@ class LocaleController @Inject constructor( } } - fun getMachineLocale(): MachineLocale = machineLocaleImpl - // TODO: document only matches to app language definitions. fun retrieveSystemLanguage(): DataProvider { val providerId = SYSTEM_LANGUAGE_DATA_PROVIDER_ID @@ -222,7 +220,7 @@ class LocaleController @Inject constructor( } return when (usageMode) { - APP_STRINGS -> DisplayLocaleImpl(oppiaClock, localeContext, machineLocaleImpl) + APP_STRINGS -> DisplayLocaleImpl(localeContext, machineLocale) CONTENT_STRINGS, AUDIO_TRANSLATIONS -> ContentLocale(localeContext) USAGE_MODE_UNSPECIFIED, UNRECOGNIZED -> null } @@ -281,7 +279,7 @@ class LocaleController @Inject constructor( // language. If a language is unknown, return a definition that attempts to be interoperable // with Android. return definitions.languageDefinitionsList.find { - machineLocaleImpl.run { + machineLocale.run { languageCode.equalsIgnoreCase(it.retrieveAppLanguageCode()) } } @@ -294,7 +292,7 @@ class LocaleController @Inject constructor( // 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 { - machineLocaleImpl.run { + machineLocale.run { it.regionId.ietfRegionTag.equalsIgnoreCase(countryCode) } } ?: RegionSupportDefinition.newBuilder().apply { 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 index b719b6ec57f..b03063bfffd 100644 --- a/domain/src/main/java/org/oppia/android/domain/translation/BUILD.bazel +++ b/domain/src/main/java/org/oppia/android/domain/translation/BUILD.bazel @@ -13,7 +13,6 @@ kt_android_library( visibility = ["//:oppia_api_visibility"], deps = [ "//domain/src/main/java/org/oppia/android/domain/locale:locale_controller", - "//domain/src/main/java/org/oppia/android/domain/locale:oppia_locale", "//model:languages_java_proto_lite", "//model:profile_java_proto_lite", "//model:subtitled_html_java_proto_lite", @@ -22,6 +21,7 @@ kt_android_library( "//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", ], ) 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 index 0147cf85503..4d8e750d919 100644 --- a/domain/src/main/java/org/oppia/android/domain/translation/TranslationController.kt +++ b/domain/src/main/java/org/oppia/android/domain/translation/TranslationController.kt @@ -11,7 +11,7 @@ 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.OppiaLocale +import org.oppia.android.util.locale.OppiaLocale import org.oppia.android.domain.locale.LocaleController import org.oppia.android.util.data.AsyncDataSubscriptionManager import org.oppia.android.util.data.AsyncResult 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..3f1eb97a711 --- /dev/null +++ b/utility/src/main/java/org/oppia/android/util/locale/BUILD.bazel @@ -0,0 +1,45 @@ +""" +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 = "oppia_locale", + srcs = [ + "OppiaLocale.kt", + ], + visibility = ["//:oppia_api_visibility"], + deps = [ + "//model:languages_java_proto_lite", + "//third_party:androidx_annotation_annotation", + ], +) + +kt_android_library( + name = "machine_locale_impl", + srcs = [ + "MachineLocaleImpl.kt", + ], + deps = [ + ":dagger", + ":oppia_locale", + "//utility/src/main/java/org/oppia/android/util/system:oppia_clock", + ], +) + +kt_android_library( + name = "prod_module", + srcs = [ + "MachineLocaleModule.kt", + ], + visibility = ["//:oppia_prod_module_visibility"], + deps = [ + ":dagger", + ":machine_locale_impl", + ":oppia_locale", + ], +) + +dagger_rules() diff --git a/domain/src/main/java/org/oppia/android/domain/locale/MachineLocaleImpl.kt b/utility/src/main/java/org/oppia/android/util/locale/MachineLocaleImpl.kt similarity index 92% rename from domain/src/main/java/org/oppia/android/domain/locale/MachineLocaleImpl.kt rename to utility/src/main/java/org/oppia/android/util/locale/MachineLocaleImpl.kt index fbc8f84dcff..4289c207500 100644 --- a/domain/src/main/java/org/oppia/android/domain/locale/MachineLocaleImpl.kt +++ b/utility/src/main/java/org/oppia/android/util/locale/MachineLocaleImpl.kt @@ -1,10 +1,11 @@ -package org.oppia.android.domain.locale +package org.oppia.android.util.locale import java.text.ParseException import java.text.SimpleDateFormat import java.util.Calendar import java.util.Date import java.util.Locale +import javax.inject.Inject import org.oppia.android.app.model.LanguageSupportDefinition import org.oppia.android.app.model.OppiaLanguage import org.oppia.android.app.model.OppiaLocaleContext @@ -14,7 +15,7 @@ import org.oppia.android.util.system.OppiaClock // TODO: documentation. Explain that US locale is always used for machine-readable strings. // TODO(#3766): Restrict to be 'internal'. -class MachineLocaleImpl( +class MachineLocaleImpl @Inject constructor( private val oppiaClock: OppiaClock ): OppiaLocale.MachineLocale(machineLocaleContext) { private val parsableDateFormat by lazy { SimpleDateFormat("yyyy-MM-dd", machineAndroidLocale) } @@ -30,6 +31,9 @@ class MachineLocaleImpl( 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() diff --git a/utility/src/main/java/org/oppia/android/util/locale/MachineLocaleModule.kt b/utility/src/main/java/org/oppia/android/util/locale/MachineLocaleModule.kt new file mode 100644 index 00000000000..3f76397387e --- /dev/null +++ b/utility/src/main/java/org/oppia/android/util/locale/MachineLocaleModule.kt @@ -0,0 +1,10 @@ +package org.oppia.android.util.locale + +import dagger.Binds +import dagger.Module + +@Module +interface MachineLocaleModule { + @Binds + fun bindMachineLocale(impl: MachineLocaleImpl): OppiaLocale.MachineLocale +} diff --git a/domain/src/main/java/org/oppia/android/domain/locale/OppiaLocale.kt b/utility/src/main/java/org/oppia/android/util/locale/OppiaLocale.kt similarity index 81% rename from domain/src/main/java/org/oppia/android/domain/locale/OppiaLocale.kt rename to utility/src/main/java/org/oppia/android/util/locale/OppiaLocale.kt index ecddf56cd42..5ff0183d162 100644 --- a/domain/src/main/java/org/oppia/android/domain/locale/OppiaLocale.kt +++ b/utility/src/main/java/org/oppia/android/util/locale/OppiaLocale.kt @@ -1,11 +1,9 @@ -package org.oppia.android.domain.locale +package org.oppia.android.util.locale -import android.content.Context -import android.content.res.Configuration import android.content.res.Resources import androidx.annotation.ArrayRes +import androidx.annotation.PluralsRes import androidx.annotation.StringRes -import java.util.Locale import org.oppia.android.app.model.LanguageSupportDefinition.LanguageId import org.oppia.android.app.model.OppiaLanguage import org.oppia.android.app.model.OppiaLocaleContext @@ -40,7 +38,7 @@ sealed class OppiaLocale { fun getCurrentRegion(): OppiaRegion = localeContext.regionDefinition.region - // TODO: documentation (https://developer.android.com/reference/java/util/Locale). + // TODO: documentation (https://developer.android.com/reference/java/util/Locale). Document this is available everywhere in the app. abstract class MachineLocale(override val localeContext: OppiaLocaleContext): OppiaLocale() { abstract fun String.formatForMachines(vararg args: Any?): String @@ -52,7 +50,8 @@ sealed class OppiaLocale { abstract fun String.decapitalizeForMachines(): String - // TODO: regex to block ignoreCase. + abstract fun String.endsWithIgnoreCase(suffix: String): Boolean + abstract fun String?.equalsIgnoreCase(other: String?): Boolean // TODO: documentation. See below. @@ -73,17 +72,20 @@ sealed class OppiaLocale { } } + // TODO: document that this is generally only available via the domain layer. abstract class DisplayLocale(override val localeContext: OppiaLocaleContext): OppiaLocale() { - abstract fun getCurrentDateString(): String + abstract fun computeDateString(timestampMillis: Long): String - abstract fun getCurrentTimeString(): String + abstract fun computeTimeString(timestampMillis: Long): String - abstract fun getCurrentDateTimeString(): String + abstract fun computeDateTimeString(timestampMillis: Long): String // TODO: mention bidi wrapping (only applied to strings) & machine readable args // TODO: document that receiver is the format (unlike String.format()). abstract fun String.formatInLocale(vararg args: Any?): String + abstract fun String.capitalizeForHumans(): String + abstract fun Resources.getStringInLocale(@StringRes id: Int): String abstract fun Resources.getStringInLocale(@StringRes id: Int, vararg formatArgs: Any?): String @@ -91,6 +93,14 @@ sealed class OppiaLocale { // TODO: for this & others, document that they won't necessarily follow the locale of this object // (they actually depend on the locale specified in Resources). abstract fun Resources.getStringArrayInLocale(@ArrayRes id: Int): List + + abstract fun Resources.getQuantityStringInLocale(@PluralsRes id: Int, quantity: Int): String + + abstract fun Resources.getQuantityStringInLocale( + @PluralsRes id: Int, quantity: Int, vararg formatArgs: Any? + ): String + + abstract fun Resources.getQuantityTextInLocale(@PluralsRes id: Int, quantity: Int): CharSequence } data class ContentLocale(override val localeContext: OppiaLocaleContext): OppiaLocale() From 7b4b28285a6b3dda948d76a616a24e530c9687e7 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Tue, 14 Sep 2021 11:45:46 -0700 Subject: [PATCH 24/93] Introduce support for content localization. This includes a bunch of stuff that'll be described in more detail in the PR description, but it essentially: - Adds support for displaying content in explorations, questions, concept cards, and revision cards in a non-English language - Adds support for submitting non-English answers - Updates test structures to validate everything exception questions is working for localization --- .gitignore | 1 + app/BUILD.bazel | 2 + .../HintsAndSolutionDialogFragment.kt | 15 +- ...HintsAndSolutionDialogFragmentPresenter.kt | 8 +- .../app/hintsandsolution/HintsViewModel.kt | 24 +- .../RecentlyPlayedFragmentPresenter.kt | 4 +- .../player/exploration/ExplorationActivity.kt | 10 +- ...tionExplorationManagerFragmentPresenter.kt | 2 +- ...tsAndSolutionExplorationManagerListener.kt | 3 +- .../player/state/SelectionInteractionView.kt | 24 +- .../player/state/StateFragmentPresenter.kt | 5 +- .../state/StatePlayerRecyclerViewAssembler.kt | 66 +- .../ContinueInteractionViewModel.kt | 19 +- .../DragAndDropSortInteractionViewModel.kt | 34 +- .../FractionInteractionViewModel.kt | 25 +- ...mageRegionSelectionInteractionViewModel.kt | 17 +- .../InteractionViewModelFactory.kt | 4 +- .../InteractionViewModelModule.kt | 69 +- .../itemviewmodel/NumericInputViewModel.kt | 16 +- ...atioExpressionInputInteractionViewModel.kt | 28 +- .../SelectionInteractionViewModel.kt | 37 +- .../state/itemviewmodel/TextInputViewModel.kt | 24 +- .../testing/StateFragmentTestActivity.kt | 10 +- .../oppia/android/app/shim/ViewBindingShim.kt | 7 +- .../android/app/shim/ViewBindingShimImpl.kt | 17 +- .../StoryChapterSummaryViewModel.kt | 4 +- ...onceptCardFragmentTestActivityPresenter.kt | 5 +- .../InputInteractionViewTestActivity.kt | 18 +- .../android/app/testing/TopicTestActivity.kt | 3 +- .../app/testing/TopicTestActivityForStory.kt | 3 +- .../oppia/android/app/topic/TopicActivity.kt | 3 +- .../topic/conceptcard/ConceptCardFragment.kt | 28 +- .../ConceptCardFragmentPresenter.kt | 23 +- .../topic/conceptcard/ConceptCardViewModel.kt | 23 +- .../lessons/TopicLessonsFragmentPresenter.kt | 7 +- ...olutionQuestionManagerFragmentPresenter.kt | 3 +- ...HintsAndSolutionQuestionManagerListener.kt | 3 +- .../questionplayer/QuestionPlayerActivity.kt | 42 +- .../QuestionPlayerActivityPresenter.kt | 13 +- .../questionplayer/QuestionPlayerFragment.kt | 27 +- .../QuestionPlayerFragmentPresenter.kt | 7 +- .../RevisionCardActivityPresenter.kt | 31 +- .../revisioncard/RevisionCardFragment.kt | 33 +- .../RevisionCardFragmentPresenter.kt | 24 +- .../revisioncard/RevisionCardViewModel.kt | 29 +- .../main/res/layout/concept_card_fragment.xml | 2 +- .../res/layout/selection_interaction_item.xml | 1 + build_flavors.bzl | 3 + .../languages/supported_languages.textproto | 7 +- domain/BUILD.bazel | 5 + domain/src/main/assets/13.json | 278 +++-- domain/src/main/assets/13.textproto | 144 +++ domain/src/main/assets/questions.textproto | 5 +- domain/src/main/assets/skills.json | 308 ++++- domain/src/main/assets/skills.textproto | 361 ++++++ domain/src/main/assets/test_exp_id_2.json | 1076 ++++++++++++++--- .../src/main/assets/test_exp_id_2.textproto | 906 +++++++++++++- domain/src/main/assets/test_topic_id_0.json | 23 +- .../src/main/assets/test_topic_id_0.textproto | 1 + domain/src/main/assets/test_topic_id_0_1.json | 33 + .../main/assets/test_topic_id_0_1.textproto | 33 + .../AnswerClassificationController.kt | 26 +- .../android/domain/classify/RuleClassifier.kt | 9 +- .../classify/rules/GenericRuleClassifier.kt | 122 +- ...asElementXAtPositionYClassifierProvider.kt | 8 +- ...lementXBeforeElementYClassifierProvider.kt | 8 +- ...nputIsEqualToOrderingClassifierProvider.kt | 6 +- ...emAtIncorrectPositionClassifierProvider.kt | 6 +- ...enominatorEqualToRuleClassifierProvider.kt | 5 +- ...artExactlyEqualToRuleClassifierProvider.kt | 5 +- ...ntegerPartEqualToRuleClassifierProvider.kt | 5 +- ...sNoFractionalPartRuleClassifierProvider.kt | 5 +- ...sNumeratorEqualToRuleClassifierProvider.kt | 5 +- ...AndInSimplestFormRuleClassifierProvider.kt | 5 +- ...putIsEquivalentToRuleClassifierProvider.kt | 5 +- ...tIsExactlyEqualToRuleClassifierProvider.kt | 5 +- ...nputIsGreaterThanRuleClassifierProvider.kt | 5 +- ...onInputIsLessThanRuleClassifierProvider.kt | 5 +- ...ckInputIsInRegionRuleClassifierProvider.kt | 5 +- ...tainsAtLeastOneOfRuleClassifierProvider.kt | 6 +- ...ntainAtLeastOneOfRuleClassifierProvider.kt | 6 +- ...ectionInputEqualsRuleClassifierProvider.kt | 6 +- ...tIsProperSubsetOfRuleClassifierProvider.kt | 6 +- ...ChoiceInputEqualsRuleClassifierProvider.kt | 7 +- ...ithUnitsIsEqualToRuleClassifierProvider.kt | 6 +- ...itsIsEquivalentToRuleClassifierProvider.kt | 6 +- ...umericInputEqualsRuleClassifierProvider.kt | 7 +- ...aterThanOrEqualToRuleClassifierProvider.kt | 7 +- ...nputIsGreaterThanRuleClassifierProvider.kt | 7 +- ...nclusivelyBetweenRuleClassifierProvider.kt | 8 +- ...LessThanOrEqualToRuleClassifierProvider.kt | 7 +- ...icInputIsLessThanRuleClassifierProvider.kt | 7 +- ...IsWithinToleranceRuleClassifierProvider.kt | 8 +- .../RatioInputEqualsRuleClassifierProvider.kt | 8 +- ...sNumberOfTermsEqualToClassifierProvider.kt | 7 +- ...InputIsEquivalentRuleClassifierProvider.kt | 8 +- ...TextInputContainsRuleClassifierProvider.kt | 13 +- .../TextInputEqualsRuleClassifierProvider.kt | 13 +- ...tInputFuzzyEqualsRuleClassifierProvider.kt | 13 +- ...xtInputStartsWithRuleClassifierProvider.kt | 13 +- .../ExplorationProgressController.kt | 74 +- .../domain/locale/AndroidLocaleProfile.kt | 6 + .../oppia/android/domain/locale/BUILD.bazel | 2 +- .../android/domain/locale/LocaleController.kt | 37 +- .../android/domain/oppialogger/BUILD.bazel | 40 + .../domain/oppialogger/analytics/BUILD.bazel | 23 + .../domain/oppialogger/exceptions/BUILD.bazel | 52 + .../oppialogger/loguploader/BUILD.bazel | 63 + .../question/QuestionAssessmentProgress.kt | 2 + .../QuestionAssessmentProgressController.kt | 72 +- .../question/QuestionTrainingController.kt | 7 +- .../domain/topic/ConceptCardRetriever.kt | 62 +- .../domain/topic/RevisionCardRetriever.kt | 44 + .../android/domain/topic/TopicController.kt | 61 +- .../android/domain/translation/BUILD.bazel | 1 + .../translation/TranslationController.kt | 80 +- .../android/domain/util/StateRetriever.kt | 44 + model/src/main/proto/exploration.proto | 9 + model/src/main/proto/topic.proto | 17 + model/src/main/proto/translation.proto | 20 +- oppia_android_application.bzl | 2 +- utility/BUILD.bazel | 1 - .../util/data/AsyncDataSubscriptionManager.kt | 182 ++- .../org/oppia/android/util/data/BUILD.bazel | 1 - .../oppia/android/util/threading/BUILD.bazel | 8 - .../util/threading/ConcurrentCollections.kt | 36 - 126 files changed, 4469 insertions(+), 872 deletions(-) create mode 100644 domain/src/main/assets/test_topic_id_0_1.json create mode 100644 domain/src/main/assets/test_topic_id_0_1.textproto create mode 100644 domain/src/main/java/org/oppia/android/domain/oppialogger/BUILD.bazel create mode 100644 domain/src/main/java/org/oppia/android/domain/oppialogger/analytics/BUILD.bazel create mode 100644 domain/src/main/java/org/oppia/android/domain/oppialogger/exceptions/BUILD.bazel create mode 100644 domain/src/main/java/org/oppia/android/domain/oppialogger/loguploader/BUILD.bazel delete mode 100644 utility/src/main/java/org/oppia/android/util/threading/ConcurrentCollections.kt diff --git a/.gitignore b/.gitignore index f9250e37d80..6d3b741a2f7 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,4 @@ config/oppia-dev-workflow-remote-cache-credentials.json bazel-* .bazelproject .aswb +*.pb diff --git a/app/BUILD.bazel b/app/BUILD.bazel index eed4086d6d9..10d04768ccb 100644 --- a/app/BUILD.bazel +++ b/app/BUILD.bazel @@ -708,6 +708,8 @@ kt_android_library( "//app/src/main/java/org/oppia/android/app/shim:prod_modules", "//data/src/main/java/org/oppia/android/data/backends/gae:network_config_prod_module", "//data/src/main/java/org/oppia/android/data/backends/gae:prod_module", + "//domain/src/main/java/org/oppia/android/domain/oppialogger/exceptions:logger_module", + "//domain/src/main/java/org/oppia/android/domain/oppialogger/loguploader:worker_module", "//model:arguments_java_proto_lite", "//third_party:androidx_databinding_databinding-adapters", "//third_party:androidx_databinding_databinding-common", diff --git a/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragment.kt b/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragment.kt index 51792d75b42..98eda9f07fd 100644 --- a/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragment.kt +++ b/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragment.kt @@ -13,6 +13,7 @@ import org.oppia.android.util.extensions.getProto import org.oppia.android.util.extensions.putProto import javax.inject.Inject import org.oppia.android.app.fragment.FragmentComponentImpl +import org.oppia.android.app.model.WrittenTranslationContext import org.oppia.android.util.extensions.getStringFromBundle private const val CURRENT_EXPANDED_LIST_INDEX_SAVED_KEY = @@ -44,25 +45,32 @@ class HintsAndSolutionDialogFragment : internal const val ID_ARGUMENT_KEY = "HintsAndSolutionDialogFragment.id" internal const val STATE_KEY = "HintsAndSolutionDialogFragment.state" internal const val HELP_INDEX_KEY = "HintsAndSolutionDialogFragment.help_index" + internal const val WRITTEN_TRANSLATION_CONTEXT_KEY = + "HintsAndSolutionDialogFragment.written_translation_context" /** * Creates a new instance of a DialogFragment to display hints and solution * - * @param id Used in ExplorationController/QuestionAssessmentProgressController to get current state data. + * @param id Used in ExplorationController/QuestionAssessmentProgressController to get current + * state data. * @param state the [State] being viewed by the learner * @param helpIndex the [HelpIndex] corresponding to the current hints/solution configuration + * @param writtenTranslationContext the [WrittenTranslationContext] needed to translate the + * hints/solution * @return [HintsAndSolutionDialogFragment]: DialogFragment */ fun newInstance( id: String, state: State, - helpIndex: HelpIndex + helpIndex: HelpIndex, + writtenTranslationContext: WrittenTranslationContext ): HintsAndSolutionDialogFragment { return HintsAndSolutionDialogFragment().apply { arguments = Bundle().apply { putString(ID_ARGUMENT_KEY, id) putProto(STATE_KEY, state) putProto(HELP_INDEX_KEY, helpIndex) + putProto(WRITTEN_TRANSLATION_CONTEXT_KEY, writtenTranslationContext) } } } @@ -107,12 +115,15 @@ class HintsAndSolutionDialogFragment : val state = args.getProto(STATE_KEY, State.getDefaultInstance()) val helpIndex = args.getProto(HELP_INDEX_KEY, HelpIndex.getDefaultInstance()) + val writtenTranslationContext = + args.getProto(WRITTEN_TRANSLATION_CONTEXT_KEY, WrittenTranslationContext.getDefaultInstance()) return hintsAndSolutionDialogFragmentPresenter.handleCreateView( inflater, container, state, helpIndex, + writtenTranslationContext, id, currentExpandedHintListIndex, this as ExpandedHintListIndexListener, diff --git a/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragmentPresenter.kt index eae991d530f..2c4bdc5a974 100644 --- a/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsAndSolutionDialogFragmentPresenter.kt @@ -24,6 +24,7 @@ import org.oppia.android.util.parser.html.HtmlParser import java.lang.IllegalStateException import java.util.Locale import javax.inject.Inject +import org.oppia.android.app.model.WrittenTranslationContext import org.oppia.android.app.translation.AppLanguageResourceHandler const val TAG_REVEAL_SOLUTION_DIALOG = "REVEAL_SOLUTION_DIALOG" @@ -48,6 +49,7 @@ class HintsAndSolutionDialogFragmentPresenter @Inject constructor( private lateinit var binding: HintsAndSolutionFragmentBinding private lateinit var state: State private lateinit var helpIndex: HelpIndex + private lateinit var writtenTranslationContext: WrittenTranslationContext private lateinit var itemList: List private lateinit var bindingAdapter: BindableAdapter @@ -64,6 +66,7 @@ class HintsAndSolutionDialogFragmentPresenter @Inject constructor( container: ViewGroup?, state: State, helpIndex: HelpIndex, + writtenTranslationContext: WrittenTranslationContext, id: String?, currentExpandedHintListIndex: Int?, expandedHintListIndexListener: ExpandedHintListIndexListener, @@ -94,6 +97,7 @@ class HintsAndSolutionDialogFragmentPresenter @Inject constructor( this.state = state this.helpIndex = helpIndex + this.writtenTranslationContext = writtenTranslationContext // The newAvailableHintIndex received here is coming from state player but in this // implementation hints/solutions are shown on every even index and on every odd index we show a // divider. The relative index therefore needs to be doubled to account for the divider. @@ -138,7 +142,9 @@ class HintsAndSolutionDialogFragmentPresenter @Inject constructor( private fun loadHintsAndSolution(state: State) { // Check if hints are available for this state. if (state.interaction.hintList.isNotEmpty()) { - viewModel.initialize(helpIndex, state.interaction.hintList, state.interaction.solution) + viewModel.initialize( + helpIndex, state.interaction.hintList, state.interaction.solution, writtenTranslationContext + ) itemList = viewModel.processHintList() diff --git a/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsViewModel.kt b/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsViewModel.kt index 63a67707346..8a2c503b15c 100644 --- a/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/hintsandsolution/HintsViewModel.kt @@ -10,7 +10,9 @@ import org.oppia.android.app.model.Solution import org.oppia.android.domain.hintsandsolution.isHintRevealed import org.oppia.android.domain.hintsandsolution.isSolutionRevealed import javax.inject.Inject +import org.oppia.android.app.model.WrittenTranslationContext import org.oppia.android.app.translation.AppLanguageResourceHandler +import org.oppia.android.domain.translation.TranslationController /** * RecyclerView items are 2 times of (No. of Hints + Solution), @@ -24,7 +26,8 @@ private const val DEFAULT_HINT_AND_SOLUTION_SUMMARY = "" /** [ViewModel] for Hints in [HintsAndSolutionDialogFragment]. */ @FragmentScope class HintsViewModel @Inject constructor( - private val resourceHandler: AppLanguageResourceHandler + private val resourceHandler: AppLanguageResourceHandler, + private val translationController: TranslationController ) : HintsAndSolutionItemViewModel() { val newAvailableHintIndex = ObservableField(-1) @@ -39,13 +42,20 @@ class HintsViewModel @Inject constructor( private lateinit var hintList: List private lateinit var solution: Solution private lateinit var helpIndex: HelpIndex + private lateinit var writtenTranslationContext: WrittenTranslationContext val itemList: MutableList = ArrayList() /** Initializes the view model to display hints and a solution. */ - fun initialize(helpIndex: HelpIndex, hintList: List, solution: Solution) { + fun initialize( + helpIndex: HelpIndex, + hintList: List, + solution: Solution, + writtenTranslationContext: WrittenTranslationContext + ) { this.helpIndex = helpIndex this.hintList = hintList this.solution = solution + this.writtenTranslationContext = writtenTranslationContext } fun processHintList(): List { @@ -93,9 +103,11 @@ class HintsViewModel @Inject constructor( } private fun addHintToList(hintIndex: Int, hint: Hint) { - val hintsViewModel = HintsViewModel(resourceHandler) + val hintsViewModel = HintsViewModel(resourceHandler, translationController) hintsViewModel.title.set(hint.hintContent.contentId) - hintsViewModel.hintsAndSolutionSummary.set(hint.hintContent.html) + val hintContentHtml = + translationController.extractString(hint.hintContent, writtenTranslationContext) + hintsViewModel.hintsAndSolutionSummary.set(hintContentHtml) hintsViewModel.isHintRevealed.set(helpIndex.isHintRevealed(hintIndex, hintList)) itemList.add(hintsViewModel) addDividerItem() @@ -109,7 +121,9 @@ class HintsViewModel @Inject constructor( solutionViewModel.denominator.set(solution.correctAnswer.denominator) solutionViewModel.wholeNumber.set(solution.correctAnswer.wholeNumber) solutionViewModel.isNegative.set(solution.correctAnswer.isNegative) - solutionViewModel.solutionSummary.set(solution.explanation.html) + val explanationHtml = + translationController.extractString(solution.explanation, writtenTranslationContext) + solutionViewModel.solutionSummary.set(explanationHtml) solutionViewModel.isSolutionRevealed.set(helpIndex.isSolutionRevealed()) itemList.add(solutionViewModel) addDividerItem() diff --git a/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedFragmentPresenter.kt index 5a08159296a..04f9d6c306a 100755 --- a/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/home/recentlyplayed/RecentlyPlayedFragmentPresenter.kt @@ -241,7 +241,9 @@ class RecentlyPlayedFragmentPresenter @Inject constructor( if (promotedStory.chapterPlayState == ChapterPlayState.IN_PROGRESS_SAVED) { val explorationCheckpointLiveData = explorationCheckpointController.retrieveExplorationCheckpoint( - ProfileId.getDefaultInstance(), + ProfileId.newBuilder().apply { + internalId = internalProfileId + }.build(), promotedStory.explorationId ).toLiveData() diff --git a/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivity.kt b/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivity.kt index c3ee8c8048f..83f2661c244 100755 --- a/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivity.kt +++ b/app/src/main/java/org/oppia/android/app/player/exploration/ExplorationActivity.kt @@ -21,6 +21,7 @@ import org.oppia.android.app.player.stopplaying.StopStatePlayingSessionWithSaved import org.oppia.android.app.topic.conceptcard.ConceptCardListener import javax.inject.Inject import org.oppia.android.app.activity.ActivityComponentImpl +import org.oppia.android.app.model.WrittenTranslationContext const val TAG_HINTS_AND_SOLUTION_DIALOG = "HINTS_AND_SOLUTION_DIALOG" @@ -45,6 +46,7 @@ class ExplorationActivity : private lateinit var storyId: String private lateinit var explorationId: String private lateinit var state: State + private lateinit var writtenTranslationContext: WrittenTranslationContext private var backflowScreen: Int? = null private var isCheckpointingEnabled: Boolean = false @@ -165,7 +167,8 @@ class ExplorationActivity : val hintsAndSolutionDialogFragment = HintsAndSolutionDialogFragment.newInstance( explorationId, state, - helpIndex + helpIndex, + writtenTranslationContext ) hintsAndSolutionDialogFragment.showNow(supportFragmentManager, TAG_HINTS_AND_SOLUTION_DIALOG) } @@ -179,8 +182,11 @@ class ExplorationActivity : explorationActivityPresenter.loadExplorationFragment(readingTextSize) } - override fun onExplorationStateLoaded(state: State) { + override fun onExplorationStateLoaded( + state: State, writtenTranslationContext: WrittenTranslationContext + ) { this.state = state + this.writtenTranslationContext = writtenTranslationContext } override fun dismissConceptCard() = explorationActivityPresenter.dismissConceptCard() diff --git a/app/src/main/java/org/oppia/android/app/player/exploration/HintsAndSolutionExplorationManagerFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/player/exploration/HintsAndSolutionExplorationManagerFragmentPresenter.kt index 7b71557a0bd..a8460e014ff 100644 --- a/app/src/main/java/org/oppia/android/app/player/exploration/HintsAndSolutionExplorationManagerFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/player/exploration/HintsAndSolutionExplorationManagerFragmentPresenter.kt @@ -57,7 +57,7 @@ class HintsAndSolutionExplorationManagerFragmentPresenter @Inject constructor( // Check if hints are available for this state. if (ephemeralState.state.interaction.hintList.size != 0) { (activity as HintsAndSolutionExplorationManagerListener).onExplorationStateLoaded( - ephemeralState.state + ephemeralState.state, ephemeralState.writtenTranslationContext ) } } diff --git a/app/src/main/java/org/oppia/android/app/player/exploration/HintsAndSolutionExplorationManagerListener.kt b/app/src/main/java/org/oppia/android/app/player/exploration/HintsAndSolutionExplorationManagerListener.kt index 9b671eb020e..c728304b65e 100644 --- a/app/src/main/java/org/oppia/android/app/player/exploration/HintsAndSolutionExplorationManagerListener.kt +++ b/app/src/main/java/org/oppia/android/app/player/exploration/HintsAndSolutionExplorationManagerListener.kt @@ -1,8 +1,9 @@ package org.oppia.android.app.player.exploration import org.oppia.android.app.model.State +import org.oppia.android.app.model.WrittenTranslationContext /** Listener for fetching current exploration state data. */ interface HintsAndSolutionExplorationManagerListener { - fun onExplorationStateLoaded(state: State) + fun onExplorationStateLoaded(state: State, writtenTranslationContext: WrittenTranslationContext) } diff --git a/app/src/main/java/org/oppia/android/app/player/state/SelectionInteractionView.kt b/app/src/main/java/org/oppia/android/app/player/state/SelectionInteractionView.kt index b0147626397..a07d8fb7e71 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/SelectionInteractionView.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/SelectionInteractionView.kt @@ -16,6 +16,7 @@ import org.oppia.android.util.gcsresource.DefaultResourceBucketName import org.oppia.android.util.parser.html.ExplorationHtmlParserEntityType import org.oppia.android.util.parser.html.HtmlParser import javax.inject.Inject +import org.oppia.android.app.model.WrittenTranslationContext import org.oppia.android.app.view.ViewComponentImpl /** @@ -45,6 +46,7 @@ class SelectionInteractionView @JvmOverloads constructor( lateinit var bindingInterface: ViewBindingShim private lateinit var entityId: String + private lateinit var writtenTranslationContext: WrittenTranslationContext override fun onAttachedToWindow() { super.onAttachedToWindow() @@ -69,6 +71,15 @@ class SelectionInteractionView @JvmOverloads constructor( this.entityId = entityId } + /** + * Sets the [WrittenTranslationContext] used to translate strings in this view. + * + * This must be called during view initialization. + */ + fun setWrittenTranslationContext(writtenTranslationContext: WrittenTranslationContext) { + this.writtenTranslationContext = writtenTranslationContext + } + private fun createAdapter(): BindableAdapter { return when (selectionItemInputType) { SelectionItemInputType.CHECKBOXES -> @@ -89,7 +100,8 @@ class SelectionInteractionView @JvmOverloads constructor( htmlParserFactory, resourceBucketName, entityType, - entityId + entityId, + writtenTranslationContext ) } ) @@ -112,7 +124,8 @@ class SelectionInteractionView @JvmOverloads constructor( htmlParserFactory, resourceBucketName, entityType, - entityId + entityId, + writtenTranslationContext ) } ) @@ -127,3 +140,10 @@ fun setEntityId( selectionInteractionView: SelectionInteractionView, entityId: String ) = selectionInteractionView.setEntityId(entityId) + +/** Sets the translation context for a specific [SelectionInteractionView] via data-binding. */ +@BindingAdapter("writtenTranslationContext") +fun setWrittenTranslationContext( + selectionInteractionView: SelectionInteractionView, + writtenTranslationContext: WrittenTranslationContext +) = selectionInteractionView.setWrittenTranslationContext(writtenTranslationContext) diff --git a/app/src/main/java/org/oppia/android/app/player/state/StateFragmentPresenter.kt b/app/src/main/java/org/oppia/android/app/player/state/StateFragmentPresenter.kt index 30a57cce8c5..3f95148f744 100755 --- a/app/src/main/java/org/oppia/android/app/player/state/StateFragmentPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/StateFragmentPresenter.kt @@ -112,7 +112,7 @@ class StateFragmentPresenter @Inject constructor( /* attachToRoot= */ false ) recyclerViewAssembler = createRecyclerViewAssembler( - assemblerBuilderFactory.create(resourceBucketName, entityType), + assemblerBuilderFactory.create(resourceBucketName, entityType, profileId), binding.congratulationsTextView, binding.congratulationsTextConfettiView, binding.fullScreenConfettiView @@ -274,8 +274,7 @@ class StateFragmentPresenter @Inject constructor( private fun subscribeToCurrentState() { ephemeralStateLiveData.observe( - fragment, - Observer { result -> + fragment, { result -> processEphemeralStateResult(result) } ) diff --git a/app/src/main/java/org/oppia/android/app/player/state/StatePlayerRecyclerViewAssembler.kt b/app/src/main/java/org/oppia/android/app/player/state/StatePlayerRecyclerViewAssembler.kt index d7db45f3be0..e1fb47031fa 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/StatePlayerRecyclerViewAssembler.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/StatePlayerRecyclerViewAssembler.kt @@ -86,7 +86,10 @@ import org.oppia.android.databinding.TextInputInteractionItemBinding import org.oppia.android.util.parser.html.HtmlParser import org.oppia.android.util.threading.BackgroundDispatcher import javax.inject.Inject +import org.oppia.android.app.model.ProfileId +import org.oppia.android.app.model.WrittenTranslationContext import org.oppia.android.app.translation.AppLanguageResourceHandler +import org.oppia.android.domain.translation.TranslationController private typealias AudioUiManagerRetriever = () -> AudioUiManager? @@ -120,6 +123,7 @@ class StatePlayerRecyclerViewAssembler private constructor( val rhsAdapter: BindableAdapter, private val playerFeatureSet: PlayerFeatureSet, private val fragment: Fragment, + private val profileId: ProfileId, private val context: Context, private val congratulationsTextView: TextView?, private val congratulationsTextConfettiView: KonfettiView?, @@ -135,7 +139,8 @@ class StatePlayerRecyclerViewAssembler private constructor( String, @JvmSuppressWildcards InteractionViewModelFactory>, backgroundCoroutineDispatcher: CoroutineDispatcher, private val hasConversationView: Boolean, - private val resourceHandler: AppLanguageResourceHandler + private val resourceHandler: AppLanguageResourceHandler, + private val translationController: TranslationController ) : HtmlParser.CustomOppiaTagActionListener { /** * A list of view models corresponding to past view models that are hidden by default. These are @@ -176,7 +181,7 @@ class StatePlayerRecyclerViewAssembler private constructor( override fun onConceptCardLinkClicked(view: View, skillId: String) { ConceptCardFragment - .newInstance(skillId) + .newInstance(skillId, profileId) .showNow(fragment.childFragmentManager, CONCEPT_CARD_DIALOG_FRAGMENT_TAG) } @@ -213,7 +218,8 @@ class StatePlayerRecyclerViewAssembler private constructor( extraInteractionPendingItemList, ephemeralState.pendingState.wrongAnswerList, /* isCorrectAnswer= */ false, - gcsEntityId + gcsEntityId, + ephemeralState.writtenTranslationContext ) if (playerFeatureSet.interactionSupport) { val interactionItemList = @@ -222,7 +228,8 @@ class StatePlayerRecyclerViewAssembler private constructor( interactionItemList, interaction, hasPreviousState, - gcsEntityId + gcsEntityId, + ephemeralState.writtenTranslationContext ) } } else if (ephemeralState.stateTypeCase == StateTypeCase.COMPLETED_STATE) { @@ -242,7 +249,8 @@ class StatePlayerRecyclerViewAssembler private constructor( extraInteractionPendingItemList, ephemeralState.completedState.answerList, /* isCorrectAnswer= */ true, - gcsEntityId + gcsEntityId, + ephemeralState.writtenTranslationContext ) } @@ -295,7 +303,8 @@ class StatePlayerRecyclerViewAssembler private constructor( pendingItemList: MutableList, interaction: Interaction, hasPreviousButton: Boolean, - gcsEntityId: String + gcsEntityId: String, + writtenTranslationContext: WrittenTranslationContext ) { val interactionViewModelFactory = interactionViewModelFactoryMap.getValue(interaction.id) pendingItemList += interactionViewModelFactory( @@ -305,7 +314,8 @@ class StatePlayerRecyclerViewAssembler private constructor( fragment as InteractionAnswerReceiver, fragment as InteractionAnswerErrorOrAvailabilityCheckReceiver, hasPreviousButton, - isSplitView.get()!! + isSplitView.get()!!, + writtenTranslationContext ) } @@ -314,9 +324,12 @@ class StatePlayerRecyclerViewAssembler private constructor( ephemeralState: EphemeralState, gcsEntityId: String ) { - val contentSubtitledHtml: SubtitledHtml = ephemeralState.state.content + val contentSubtitledHtml = + translationController.extractString( + ephemeralState.state.content, ephemeralState.writtenTranslationContext + ) pendingItemList += ContentViewModel( - contentSubtitledHtml.html, + contentSubtitledHtml, gcsEntityId, hasConversationView, isSplitView.get()!!, @@ -329,7 +342,8 @@ class StatePlayerRecyclerViewAssembler private constructor( rightPendingItemList: MutableList, answersAndResponses: List, isCorrectAnswer: Boolean, - gcsEntityId: String + gcsEntityId: String, + writtenTranslationContext: WrittenTranslationContext ) { if (answersAndResponses.size > 1) { if (playerFeatureSet.wrongAnswerCollapsing) { @@ -364,7 +378,8 @@ class StatePlayerRecyclerViewAssembler private constructor( if (playerFeatureSet.feedbackSupport) { createFeedbackItem( answerAndResponse.feedback, - gcsEntityId + gcsEntityId, + writtenTranslationContext )?.let { viewModel -> if (showPreviousAnswers) { pendingItemList += viewModel @@ -391,7 +406,7 @@ class StatePlayerRecyclerViewAssembler private constructor( } } if (playerFeatureSet.feedbackSupport) { - createFeedbackItem(answerAndResponse.feedback, gcsEntityId)?.let( + createFeedbackItem(answerAndResponse.feedback, gcsEntityId, writtenTranslationContext)?.let( pendingItemList::add ) } @@ -539,12 +554,14 @@ class StatePlayerRecyclerViewAssembler private constructor( private fun createFeedbackItem( feedback: SubtitledHtml, - gcsEntityId: String + gcsEntityId: String, + writtenTranslationContext: WrittenTranslationContext ): FeedbackViewModel? { // Only show feedback if there's some to show. - if (feedback.html.isNotEmpty()) { + val feedbackHtml = translationController.extractString(feedback, writtenTranslationContext) + if (feedbackHtml.isNotEmpty()) { return FeedbackViewModel( - feedback.html, + feedbackHtml, gcsEntityId, hasConversationView, isSplitView.get()!!, @@ -849,10 +866,12 @@ class StatePlayerRecyclerViewAssembler private constructor( private val resourceBucketName: String, private val entityType: String, private val fragment: Fragment, + private val profileId: ProfileId, private val context: Context, private val interactionViewModelFactoryMap: Map, private val backgroundCoroutineDispatcher: CoroutineDispatcher, - private val resourceHandler: AppLanguageResourceHandler + private val resourceHandler: AppLanguageResourceHandler, + private val translationController: TranslationController ) { private val adapterBuilder = BindableAdapter.MultiTypeBuilder.newBuilder( StateItemViewModel::viewType @@ -1300,6 +1319,7 @@ class StatePlayerRecyclerViewAssembler private constructor( /* rhsAdapter= */ adapterBuilder.build(), playerFeatureSet, fragment, + profileId, context, congratulationsTextView, congratulationsTextConfettiView, @@ -1314,7 +1334,8 @@ class StatePlayerRecyclerViewAssembler private constructor( interactionViewModelFactoryMap, backgroundCoroutineDispatcher, hasConversationView, - resourceHandler + resourceHandler, + translationController ) if (playerFeatureSet.conceptCardSupport) { customTagListener.proxyListener = assembler @@ -1330,22 +1351,25 @@ class StatePlayerRecyclerViewAssembler private constructor( private val interactionViewModelFactoryMap: Map< String, @JvmSuppressWildcards InteractionViewModelFactory>, @BackgroundDispatcher private val backgroundCoroutineDispatcher: CoroutineDispatcher, - private val resourceHandler: AppLanguageResourceHandler + private val resourceHandler: AppLanguageResourceHandler, + private val translationController: TranslationController ) { /** * Returns a new [Builder] for the specified GCS resource bucket information for loading - * assets. + * assets, and the current logged in [ProfileId]. */ - fun create(resourceBucketName: String, entityType: String): Builder { + fun create(resourceBucketName: String, entityType: String, profileId: ProfileId): Builder { return Builder( htmlParserFactory, resourceBucketName, entityType, fragment, + profileId, context, interactionViewModelFactoryMap, backgroundCoroutineDispatcher, - resourceHandler + resourceHandler, + translationController ) } } diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/ContinueInteractionViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/ContinueInteractionViewModel.kt index a59acca203a..dbabbbd32ee 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/ContinueInteractionViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/ContinueInteractionViewModel.kt @@ -2,6 +2,7 @@ package org.oppia.android.app.player.state.itemviewmodel import org.oppia.android.app.model.InteractionObject import org.oppia.android.app.model.UserAnswer +import org.oppia.android.app.model.WrittenTranslationContext import org.oppia.android.app.player.state.answerhandling.InteractionAnswerHandler import org.oppia.android.app.player.state.answerhandling.InteractionAnswerReceiver import org.oppia.android.app.player.state.listener.PreviousNavigationButtonListener @@ -21,21 +22,21 @@ class ContinueInteractionViewModel( val hasConversationView: Boolean, val hasPreviousButton: Boolean, val previousNavigationButtonListener: PreviousNavigationButtonListener, - val isSplitView: Boolean + val isSplitView: Boolean, + private val writtenTranslationContext: WrittenTranslationContext ) : StateItemViewModel(ViewType.CONTINUE_INTERACTION), InteractionAnswerHandler { override fun isExplicitAnswerSubmissionRequired(): Boolean = false override fun isAutoNavigating(): Boolean = true - override fun getPendingAnswer(): UserAnswer { - return UserAnswer.newBuilder() - .setAnswer( - InteractionObject.newBuilder().setNormalizedString(DEFAULT_CONTINUE_INTERACTION_TEXT_ANSWER) - ) - .setPlainAnswer(DEFAULT_CONTINUE_INTERACTION_TEXT_ANSWER) - .build() - } + override fun getPendingAnswer(): UserAnswer = UserAnswer.newBuilder().apply { + answer = InteractionObject.newBuilder().apply { + normalizedString = DEFAULT_CONTINUE_INTERACTION_TEXT_ANSWER + }.build() + plainAnswer = DEFAULT_CONTINUE_INTERACTION_TEXT_ANSWER + this.writtenTranslationContext = this@ContinueInteractionViewModel.writtenTranslationContext + }.build() fun handleButtonClicked() { interactionAnswerReceiver.onAnswerReadyForSubmission(getPendingAnswer()) diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/DragAndDropSortInteractionViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/DragAndDropSortInteractionViewModel.kt index fd8cb16eec7..6eb8dee40bc 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/DragAndDropSortInteractionViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/DragAndDropSortInteractionViewModel.kt @@ -12,12 +12,14 @@ import org.oppia.android.app.model.StringList import org.oppia.android.app.model.SubtitledHtml import org.oppia.android.app.model.TranslatableHtmlContentId import org.oppia.android.app.model.UserAnswer +import org.oppia.android.app.model.WrittenTranslationContext import org.oppia.android.app.player.state.answerhandling.InteractionAnswerErrorOrAvailabilityCheckReceiver import org.oppia.android.app.player.state.answerhandling.InteractionAnswerHandler import org.oppia.android.app.recyclerview.BindableAdapter import org.oppia.android.app.recyclerview.OnDragEndedListener import org.oppia.android.app.recyclerview.OnItemDragListener import org.oppia.android.app.translation.AppLanguageResourceHandler +import org.oppia.android.domain.translation.TranslationController /** [StateItemViewModel] for drag drop & sort choice list. */ class DragAndDropSortInteractionViewModel( @@ -26,7 +28,9 @@ class DragAndDropSortInteractionViewModel( interaction: Interaction, private val interactionAnswerErrorOrAvailabilityCheckReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, // ktlint-disable max-line-length val isSplitView: Boolean, - private val resourceHandler: AppLanguageResourceHandler + private val writtenTranslationContext: WrittenTranslationContext, + private val resourceHandler: AppLanguageResourceHandler, + private val translationController: TranslationController ) : StateItemViewModel(ViewType.DRAG_DROP_SORT_INTERACTION), InteractionAnswerHandler, OnItemDragListener, @@ -44,7 +48,9 @@ class DragAndDropSortInteractionViewModel( private val contentIdHtmlMap: Map = choiceSubtitledHtmls.associate { subtitledHtml -> - subtitledHtml.contentId to subtitledHtml.html + val translatedHtml = + translationController.extractString(subtitledHtml, writtenTranslationContext) + subtitledHtml.contentId to translatedHtml } private val _choiceItems: MutableList = @@ -107,21 +113,19 @@ class DragAndDropSortInteractionViewModel( (adapter as BindableAdapter<*>).setDataUnchecked(_choiceItems) } - override fun getPendingAnswer(): UserAnswer { - val userAnswerBuilder = UserAnswer.newBuilder() + override fun getPendingAnswer(): UserAnswer = UserAnswer.newBuilder().apply { val selectedLists = _choiceItems.map { it.htmlContent } val userStringLists = _choiceItems.map { it.computeStringList() } - userAnswerBuilder.listOfHtmlAnswers = convertItemsToAnswer(userStringLists) - userAnswerBuilder.answer = - InteractionObject.newBuilder().apply { - listOfSetsOfTranslatableHtmlContentIds = - ListOfSetsOfTranslatableHtmlContentIds.newBuilder().apply { - _choiceItems.map { } - addAllContentIdLists(selectedLists) - }.build() - }.build() - return userAnswerBuilder.build() - } + listOfHtmlAnswers = convertItemsToAnswer(userStringLists) + answer = InteractionObject.newBuilder().apply { + listOfSetsOfTranslatableHtmlContentIds = + ListOfSetsOfTranslatableHtmlContentIds.newBuilder().apply { + addAllContentIdLists(selectedLists) + }.build() + }.build() + this.writtenTranslationContext = + this@DragAndDropSortInteractionViewModel.writtenTranslationContext + }.build() /** Returns an HTML list containing all of the HTML string elements as items in the list. */ private fun convertItemsToAnswer(htmlItems: List): ListOfSetsOfHtmlStrings { diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/FractionInteractionViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/FractionInteractionViewModel.kt index aae7fe57621..f69276a6829 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/FractionInteractionViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/FractionInteractionViewModel.kt @@ -8,11 +8,13 @@ import org.oppia.android.R import org.oppia.android.app.model.Interaction import org.oppia.android.app.model.InteractionObject import org.oppia.android.app.model.UserAnswer +import org.oppia.android.app.model.WrittenTranslationContext import org.oppia.android.app.parser.StringToFractionParser import org.oppia.android.app.player.state.answerhandling.AnswerErrorCategory import org.oppia.android.app.player.state.answerhandling.InteractionAnswerErrorOrAvailabilityCheckReceiver import org.oppia.android.app.player.state.answerhandling.InteractionAnswerHandler import org.oppia.android.app.translation.AppLanguageResourceHandler +import org.oppia.android.domain.translation.TranslationController /** [StateItemViewModel] for the fraction input interaction. */ class FractionInteractionViewModel( @@ -20,7 +22,9 @@ class FractionInteractionViewModel( val hasConversationView: Boolean, val isSplitView: Boolean, private val errorOrAvailabilityCheckReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, - private val resourceHandler: AppLanguageResourceHandler + private val writtenTranslationContext: WrittenTranslationContext, + private val resourceHandler: AppLanguageResourceHandler, + private val translationController: TranslationController ) : StateItemViewModel(ViewType.FRACTION_INPUT_INTERACTION), InteractionAnswerHandler { private var pendingAnswerError: String? = null var answerText: CharSequence = "" @@ -44,17 +48,16 @@ class FractionInteractionViewModel( isAnswerAvailable.addOnPropertyChangedCallback(callback) } - override fun getPendingAnswer(): UserAnswer { - val userAnswerBuilder = UserAnswer.newBuilder() + override fun getPendingAnswer(): UserAnswer = UserAnswer.newBuilder().apply { if (answerText.isNotEmpty()) { val answerTextString = answerText.toString() - userAnswerBuilder.answer = InteractionObject.newBuilder() - .setFraction(stringToFractionParser.parseFractionFromString(answerTextString)) - .build() - userAnswerBuilder.plainAnswer = answerTextString + answer = InteractionObject.newBuilder().apply { + fraction = stringToFractionParser.parseFractionFromString(answerTextString) + }.build() + plainAnswer = answerTextString + this.writtenTranslationContext = this@FractionInteractionViewModel.writtenTranslationContext } - return userAnswerBuilder.build() - } + }.build() /** It checks the pending error for the current fraction input, and correspondingly updates the error string based on the specified error category. */ override fun checkPendingAnswerError(category: AnswerErrorCategory): String? { @@ -95,7 +98,9 @@ class FractionInteractionViewModel( private fun deriveHintText(interaction: Interaction): CharSequence { val customPlaceholder = - interaction.customizationArgsMap["customPlaceholder"]?.subtitledUnicode?.unicodeStr ?: "" + interaction.customizationArgsMap["customPlaceholder"]?.subtitledUnicode?.let { unicode -> + translationController.extractString(unicode, writtenTranslationContext) + } ?: "" val allowNonzeroIntegerPart = interaction.customizationArgsMap["allowNonzeroIntegerPart"]?.boolValue ?: true return when { diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/ImageRegionSelectionInteractionViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/ImageRegionSelectionInteractionViewModel.kt index e368def3377..d4e7a071a88 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/ImageRegionSelectionInteractionViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/ImageRegionSelectionInteractionViewModel.kt @@ -9,6 +9,7 @@ import org.oppia.android.app.model.ImageWithRegions import org.oppia.android.app.model.Interaction import org.oppia.android.app.model.InteractionObject import org.oppia.android.app.model.UserAnswer +import org.oppia.android.app.model.WrittenTranslationContext import org.oppia.android.app.player.state.answerhandling.InteractionAnswerErrorOrAvailabilityCheckReceiver import org.oppia.android.app.player.state.answerhandling.InteractionAnswerHandler import org.oppia.android.app.translation.AppLanguageResourceHandler @@ -24,6 +25,7 @@ class ImageRegionSelectionInteractionViewModel( interaction: Interaction, private val errorOrAvailabilityCheckReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, val isSplitView: Boolean, + private val writtenTranslationContext: WrittenTranslationContext, private val resourceHandler: AppLanguageResourceHandler ) : StateItemViewModel(ViewType.IMAGE_REGION_SELECTION_INTERACTION), InteractionAnswerHandler, @@ -67,17 +69,18 @@ class ImageRegionSelectionInteractionViewModel( } } - override fun getPendingAnswer(): UserAnswer { - val userAnswerBuilder = UserAnswer.newBuilder() + override fun getPendingAnswer(): UserAnswer = UserAnswer.newBuilder().apply { val answerTextString = answerText.toString() - userAnswerBuilder.answer = - InteractionObject.newBuilder().setClickOnImage(parseClickOnImage(answerTextString)).build() - userAnswerBuilder.plainAnswer = resourceHandler.getStringInLocale( + answer = InteractionObject.newBuilder().apply { + clickOnImage = parseClickOnImage(answerTextString) + }.build() + plainAnswer = resourceHandler.getStringInLocale( R.string.image_interaction_answer_text, answerTextString ) - return userAnswerBuilder.build() - } + this.writtenTranslationContext = + this@ImageRegionSelectionInteractionViewModel.writtenTranslationContext + }.build() private fun parseClickOnImage(answerTextString: String): ClickOnImage { val region = selectableRegions.find { it.label == answerTextString } diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/InteractionViewModelFactory.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/InteractionViewModelFactory.kt index 58196c76705..2ad060e78e3 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/InteractionViewModelFactory.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/InteractionViewModelFactory.kt @@ -1,6 +1,7 @@ package org.oppia.android.app.player.state.itemviewmodel import org.oppia.android.app.model.Interaction +import org.oppia.android.app.model.WrittenTranslationContext import org.oppia.android.app.player.state.answerhandling.InteractionAnswerErrorOrAvailabilityCheckReceiver import org.oppia.android.app.player.state.answerhandling.InteractionAnswerReceiver @@ -16,5 +17,6 @@ typealias InteractionViewModelFactory = ( interactionAnswerReceiver: InteractionAnswerReceiver, interactionAnswerErrorOrAvailabilityCheckReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, // ktlint-disable max-line-length hasPreviousButton: Boolean, - isSplitView: Boolean + isSplitView: Boolean, + writtenTranslationContext: WrittenTranslationContext ) -> StateItemViewModel diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/InteractionViewModelModule.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/InteractionViewModelModule.kt index 88330e331d2..d006739b1bf 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/InteractionViewModelModule.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/InteractionViewModelModule.kt @@ -7,6 +7,7 @@ import dagger.multibindings.IntoMap import dagger.multibindings.StringKey import org.oppia.android.app.player.state.listener.PreviousNavigationButtonListener import org.oppia.android.app.translation.AppLanguageResourceHandler +import org.oppia.android.domain.translation.TranslationController /** * Module to provide interaction view model-specific dependencies for interactions that should be @@ -25,13 +26,14 @@ class InteractionViewModelModule { @StringKey("Continue") fun provideContinueInteractionViewModelFactory(fragment: Fragment): InteractionViewModelFactory { return { _, hasConversationView, _, interactionAnswerReceiver, _, hasPreviousButton, - isSplitView -> + isSplitView, writtenTranslationContext -> ContinueInteractionViewModel( interactionAnswerReceiver, hasConversationView, hasPreviousButton, fragment as PreviousNavigationButtonListener, - isSplitView + isSplitView, + writtenTranslationContext ) } } @@ -39,16 +41,20 @@ class InteractionViewModelModule { @Provides @IntoMap @StringKey("MultipleChoiceInput") - fun provideMultipleChoiceInputViewModelFactory(): InteractionViewModelFactory { + fun provideMultipleChoiceInputViewModelFactory( + translationController: TranslationController + ): InteractionViewModelFactory { return { entityId, hasConversationView, interaction, interactionAnswerReceiver, - interactionAnswerErrorReceiver, _, isSplitView -> + interactionAnswerErrorReceiver, _, isSplitView, writtenTranslationContext -> SelectionInteractionViewModel( entityId, hasConversationView, interaction, interactionAnswerReceiver, interactionAnswerErrorReceiver, - isSplitView + isSplitView, + writtenTranslationContext, + translationController ) } } @@ -56,16 +62,20 @@ class InteractionViewModelModule { @Provides @IntoMap @StringKey("ItemSelectionInput") - fun provideItemSelectionInputViewModelFactory(): InteractionViewModelFactory { + fun provideItemSelectionInputViewModelFactory( + translationController: TranslationController + ): InteractionViewModelFactory { return { entityId, hasConversationView, interaction, interactionAnswerReceiver, - interactionAnswerErrorReceiver, _, isSplitView -> + interactionAnswerErrorReceiver, _, isSplitView, writtenTranslationContext -> SelectionInteractionViewModel( entityId, hasConversationView, interaction, interactionAnswerReceiver, interactionAnswerErrorReceiver, - isSplitView + isSplitView, + writtenTranslationContext, + translationController ) } } @@ -74,16 +84,19 @@ class InteractionViewModelModule { @IntoMap @StringKey("FractionInput") fun provideFractionInputViewModelFactory( - resourceHandler: AppLanguageResourceHandler + resourceHandler: AppLanguageResourceHandler, + translationController: TranslationController ): InteractionViewModelFactory { return { _, hasConversationView, interaction, _, interactionAnswerErrorReceiver, _, - isSplitView -> + isSplitView, writtenTranslationContext -> FractionInteractionViewModel( interaction, hasConversationView, isSplitView, interactionAnswerErrorReceiver, - resourceHandler + writtenTranslationContext, + resourceHandler, + translationController ) } } @@ -94,11 +107,13 @@ class InteractionViewModelModule { fun provideNumericInputViewModelFactory( resourceHandler: AppLanguageResourceHandler ): InteractionViewModelFactory { - return { _, hasConversationView, _, _, interactionAnswerErrorReceiver, _, isSplitView -> + return { _, hasConversationView, _, _, interactionAnswerErrorReceiver, _, isSplitView, + writtenTranslationContext -> NumericInputViewModel( hasConversationView, interactionAnswerErrorReceiver, isSplitView, + writtenTranslationContext, resourceHandler ) } @@ -107,11 +122,14 @@ class InteractionViewModelModule { @Provides @IntoMap @StringKey("TextInput") - fun provideTextInputViewModelFactory(): InteractionViewModelFactory { + fun provideTextInputViewModelFactory( + translationController: TranslationController + ): InteractionViewModelFactory { return { _, hasConversationView, interaction, _, interactionAnswerErrorReceiver, _, - isSplitView -> + isSplitView, writtenTranslationContext -> TextInputViewModel( - interaction, hasConversationView, interactionAnswerErrorReceiver, isSplitView + interaction, hasConversationView, interactionAnswerErrorReceiver, isSplitView, + writtenTranslationContext, translationController ) } } @@ -120,13 +138,14 @@ class InteractionViewModelModule { @IntoMap @StringKey("DragAndDropSortInput") fun provideDragAndDropSortInputViewModelFactory( - resourceHandler: AppLanguageResourceHandler + resourceHandler: AppLanguageResourceHandler, + translationController: TranslationController ): InteractionViewModelFactory { return { entityId, hasConversationView, interaction, _, interactionAnswerErrorReceiver, _, - isSplitView -> + isSplitView, writtenTranslationContext -> DragAndDropSortInteractionViewModel( entityId, hasConversationView, interaction, interactionAnswerErrorReceiver, isSplitView, - resourceHandler + writtenTranslationContext, resourceHandler, translationController ) } } @@ -137,13 +156,15 @@ class InteractionViewModelModule { fun provideImageClickInputViewModelFactory( resourceHandler: AppLanguageResourceHandler ): InteractionViewModelFactory { - return { entityId, hasConversationView, interaction, _, answerErrorReceiver, _, isSplitView -> + return { entityId, hasConversationView, interaction, _, answerErrorReceiver, _, isSplitView, + writtenTranslationContext -> ImageRegionSelectionInteractionViewModel( entityId, hasConversationView, interaction, answerErrorReceiver, isSplitView, + writtenTranslationContext, resourceHandler ) } @@ -153,15 +174,19 @@ class InteractionViewModelModule { @IntoMap @StringKey("RatioExpressionInput") fun provideRatioExpressionInputViewModelFactory( - resourceHandler: AppLanguageResourceHandler + resourceHandler: AppLanguageResourceHandler, + translationController: TranslationController ): InteractionViewModelFactory { - return { _, hasConversationView, interaction, _, answerErrorReceiver, _, isSplitView -> + return { _, hasConversationView, interaction, _, answerErrorReceiver, _, isSplitView, + writtenTranslationContext -> RatioExpressionInputInteractionViewModel( interaction, hasConversationView, isSplitView, answerErrorReceiver, - resourceHandler + writtenTranslationContext, + resourceHandler, + translationController ) } } diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/NumericInputViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/NumericInputViewModel.kt index 01bc68eca04..4c34453e937 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/NumericInputViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/NumericInputViewModel.kt @@ -6,6 +6,7 @@ import androidx.databinding.Observable import androidx.databinding.ObservableField import org.oppia.android.app.model.InteractionObject import org.oppia.android.app.model.UserAnswer +import org.oppia.android.app.model.WrittenTranslationContext import org.oppia.android.app.parser.StringToNumberParser import org.oppia.android.app.player.state.answerhandling.AnswerErrorCategory import org.oppia.android.app.player.state.answerhandling.InteractionAnswerErrorOrAvailabilityCheckReceiver @@ -17,6 +18,7 @@ class NumericInputViewModel( val hasConversationView: Boolean, private val interactionAnswerErrorOrAvailabilityCheckReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, // ktlint-disable max-line-length val isSplitView: Boolean, + private val writtenTranslationContext: WrittenTranslationContext, private val resourceHandler: AppLanguageResourceHandler ) : StateItemViewModel(ViewType.NUMERIC_INPUT_INTERACTION), InteractionAnswerHandler { var answerText: CharSequence = "" @@ -74,14 +76,14 @@ class NumericInputViewModel( } } - override fun getPendingAnswer(): UserAnswer { - val userAnswerBuilder = UserAnswer.newBuilder() + override fun getPendingAnswer(): UserAnswer = UserAnswer.newBuilder().apply { if (answerText.isNotEmpty()) { val answerTextString = answerText.toString() - userAnswerBuilder.answer = - InteractionObject.newBuilder().setReal(answerTextString.toDouble()).build() - userAnswerBuilder.plainAnswer = answerTextString + answer = InteractionObject.newBuilder().apply { + real = answerTextString.toDouble() + }.build() + plainAnswer = answerTextString + this.writtenTranslationContext = this@NumericInputViewModel.writtenTranslationContext } - return userAnswerBuilder.build() - } + }.build() } diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/RatioExpressionInputInteractionViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/RatioExpressionInputInteractionViewModel.kt index 768ff05f560..a237384db11 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/RatioExpressionInputInteractionViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/RatioExpressionInputInteractionViewModel.kt @@ -8,12 +8,14 @@ import org.oppia.android.R import org.oppia.android.app.model.Interaction import org.oppia.android.app.model.InteractionObject import org.oppia.android.app.model.UserAnswer +import org.oppia.android.app.model.WrittenTranslationContext import org.oppia.android.app.parser.StringToRatioParser import org.oppia.android.app.player.state.answerhandling.AnswerErrorCategory import org.oppia.android.app.player.state.answerhandling.InteractionAnswerErrorOrAvailabilityCheckReceiver import org.oppia.android.app.player.state.answerhandling.InteractionAnswerHandler import org.oppia.android.app.translation.AppLanguageResourceHandler import org.oppia.android.app.utility.toAccessibleAnswerString +import org.oppia.android.domain.translation.TranslationController import org.oppia.android.domain.util.toAnswerString /** [StateItemViewModel] for the ratio expression input interaction. */ @@ -22,7 +24,9 @@ class RatioExpressionInputInteractionViewModel( val hasConversationView: Boolean, val isSplitView: Boolean, private val errorOrAvailabilityCheckReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, - private val resourceHandler: AppLanguageResourceHandler + private val writtenTranslationContext: WrittenTranslationContext, + private val resourceHandler: AppLanguageResourceHandler, + private val translationController: TranslationController ) : StateItemViewModel(ViewType.RATIO_EXPRESSION_INPUT_INTERACTION), InteractionAnswerHandler { private var pendingAnswerError: String? = null var answerText: CharSequence = "" @@ -48,18 +52,18 @@ class RatioExpressionInputInteractionViewModel( isAnswerAvailable.addOnPropertyChangedCallback(callback) } - override fun getPendingAnswer(): UserAnswer { - val userAnswerBuilder = UserAnswer.newBuilder() + override fun getPendingAnswer(): UserAnswer = UserAnswer.newBuilder().apply { if (answerText.isNotEmpty()) { val ratioAnswer = stringToRatioParser.parseRatioOrThrow(answerText.toString()) - userAnswerBuilder.answer = InteractionObject.newBuilder() - .setRatioExpression(ratioAnswer) - .build() - userAnswerBuilder.plainAnswer = ratioAnswer.toAnswerString() - userAnswerBuilder.contentDescription = ratioAnswer.toAccessibleAnswerString(resourceHandler) + answer = InteractionObject.newBuilder().apply { + ratioExpression = ratioAnswer + }.build() + plainAnswer = ratioAnswer.toAnswerString() + contentDescription = ratioAnswer.toAccessibleAnswerString(resourceHandler) + this.writtenTranslationContext = + this@RatioExpressionInputInteractionViewModel.writtenTranslationContext } - return userAnswerBuilder.build() - } + }.build() /** It checks the pending error for the current ratio input, and correspondingly updates the error string based on the specified error category. */ override fun checkPendingAnswerError(category: AnswerErrorCategory): String? { @@ -102,7 +106,9 @@ class RatioExpressionInputInteractionViewModel( private fun deriveHintText(interaction: Interaction): CharSequence { val placeholder = - interaction.customizationArgsMap["placeholder"]?.subtitledUnicode?.unicodeStr ?: "" + interaction.customizationArgsMap["placeholder"]?.subtitledUnicode?.let { unicode -> + translationController.extractString(unicode, writtenTranslationContext) + } ?: "" return when { placeholder.isNotEmpty() -> placeholder else -> resourceHandler.getStringInLocale(R.string.ratio_default_hint_text) diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/SelectionInteractionViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/SelectionInteractionViewModel.kt index 9a26bac0524..1254f9fbfb5 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/SelectionInteractionViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/SelectionInteractionViewModel.kt @@ -9,10 +9,12 @@ import org.oppia.android.app.model.SetOfTranslatableHtmlContentIds import org.oppia.android.app.model.SubtitledHtml import org.oppia.android.app.model.TranslatableHtmlContentId import org.oppia.android.app.model.UserAnswer +import org.oppia.android.app.model.WrittenTranslationContext import org.oppia.android.app.player.state.answerhandling.InteractionAnswerErrorOrAvailabilityCheckReceiver import org.oppia.android.app.player.state.answerhandling.InteractionAnswerHandler import org.oppia.android.app.player.state.answerhandling.InteractionAnswerReceiver import org.oppia.android.app.viewmodel.ObservableArrayList +import org.oppia.android.domain.translation.TranslationController /** Corresponds to the type of input that should be used for an item selection interaction view. */ enum class SelectionItemInputType { @@ -27,7 +29,9 @@ class SelectionInteractionViewModel( interaction: Interaction, private val interactionAnswerReceiver: InteractionAnswerReceiver, private val interactionAnswerErrorOrAvailabilityCheckReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, // ktlint-disable max-line-length - val isSplitView: Boolean + val isSplitView: Boolean, + val writtenTranslationContext: WrittenTranslationContext, + private val translationController: TranslationController ) : StateItemViewModel(ViewType.SELECTION_INTERACTION), InteractionAnswerHandler { private val interactionId: String = interaction.id @@ -71,11 +75,14 @@ class SelectionInteractionViewModel( return maxAllowableSelectionCount > 1 } - override fun getPendingAnswer(): UserAnswer { - val userAnswerBuilder = UserAnswer.newBuilder() + override fun getPendingAnswer(): UserAnswer = UserAnswer.newBuilder().apply { + val translationContext = this@SelectionInteractionViewModel.writtenTranslationContext val selectedItemSubtitledHtmls = selectedItems.map(choiceItems::get).map { it.htmlContent } + val itemHtmls = selectedItemSubtitledHtmls.map { subtitledHtml -> + translationController.extractString(subtitledHtml, translationContext) + } if (interactionId == "ItemSelectionInput") { - userAnswerBuilder.answer = InteractionObject.newBuilder().apply { + answer = InteractionObject.newBuilder().apply { setOfTranslatableHtmlContentIds = SetOfTranslatableHtmlContentIds.newBuilder().apply { addAllContentIds( selectedItemSubtitledHtmls.map { subtitledHtml -> @@ -86,23 +93,23 @@ class SelectionInteractionViewModel( ) }.build() }.build() - userAnswerBuilder.htmlAnswer = convertSelectedItemsToHtmlString(selectedItemSubtitledHtmls) + htmlAnswer = convertSelectedItemsToHtmlString(itemHtmls) } else if (selectedItems.size == 1) { - userAnswerBuilder.answer = - InteractionObject.newBuilder().setNonNegativeInt(selectedItems.first()).build() - userAnswerBuilder.htmlAnswer = convertSelectedItemsToHtmlString(selectedItemSubtitledHtmls) + answer = InteractionObject.newBuilder().apply { + nonNegativeInt = selectedItems.first() + }.build() + htmlAnswer = convertSelectedItemsToHtmlString(itemHtmls) } - return userAnswerBuilder.build() - } + writtenTranslationContext = translationContext + }.build() /** Returns an HTML list containing all of the HTML string elements as items in the list. */ - private fun convertSelectedItemsToHtmlString(subtitledHtmls: Collection): String { - return when (subtitledHtmls.size) { + private fun convertSelectedItemsToHtmlString(itemHtmls: Collection): String { + return when (itemHtmls.size) { 0 -> "" - 1 -> subtitledHtmls.first().html + 1 -> itemHtmls.first() else -> { - val htmlList = subtitledHtmls.map { it.html } - "
  • ${htmlList.joinToString(separator = "
  • ")}
" + "
  • ${itemHtmls.joinToString(separator = "
  • ")}
" } } } diff --git a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/TextInputViewModel.kt b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/TextInputViewModel.kt index db5e2711d60..266a7122d16 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/TextInputViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/itemviewmodel/TextInputViewModel.kt @@ -7,15 +7,19 @@ import androidx.databinding.ObservableField import org.oppia.android.app.model.Interaction import org.oppia.android.app.model.InteractionObject import org.oppia.android.app.model.UserAnswer +import org.oppia.android.app.model.WrittenTranslationContext import org.oppia.android.app.player.state.answerhandling.InteractionAnswerErrorOrAvailabilityCheckReceiver import org.oppia.android.app.player.state.answerhandling.InteractionAnswerHandler +import org.oppia.android.domain.translation.TranslationController /** [StateItemViewModel] for the text input interaction. */ class TextInputViewModel( interaction: Interaction, val hasConversationView: Boolean, private val interactionAnswerErrorOrAvailabilityCheckReceiver: InteractionAnswerErrorOrAvailabilityCheckReceiver, // ktlint-disable max-line-length - val isSplitView: Boolean + val isSplitView: Boolean, + private val writtenTranslationContext: WrittenTranslationContext, + private val translationController: TranslationController ) : StateItemViewModel(ViewType.TEXT_INPUT_INTERACTION), InteractionAnswerHandler { var answerText: CharSequence = "" val hintText: CharSequence = deriveHintText(interaction) @@ -53,19 +57,21 @@ class TextInputViewModel( } } - override fun getPendingAnswer(): UserAnswer { - val userAnswerBuilder = UserAnswer.newBuilder() + override fun getPendingAnswer(): UserAnswer = UserAnswer.newBuilder().apply { if (answerText.isNotEmpty()) { val answerTextString = answerText.toString() - userAnswerBuilder.answer = - InteractionObject.newBuilder().setNormalizedString(answerTextString).build() - userAnswerBuilder.plainAnswer = answerTextString + answer = InteractionObject.newBuilder().apply { + normalizedString = answerTextString + }.build() + plainAnswer = answerTextString + writtenTranslationContext = this@TextInputViewModel.writtenTranslationContext } - return userAnswerBuilder.build() - } + }.build() private fun deriveHintText(interaction: Interaction): CharSequence { // The default placeholder for text input is empty. - return interaction.customizationArgsMap["placeholder"]?.subtitledUnicode?.unicodeStr ?: "" + return interaction.customizationArgsMap["placeholder"]?.subtitledUnicode?.let { unicode -> + translationController.extractString(unicode, writtenTranslationContext) + } ?: "" } } diff --git a/app/src/main/java/org/oppia/android/app/player/state/testing/StateFragmentTestActivity.kt b/app/src/main/java/org/oppia/android/app/player/state/testing/StateFragmentTestActivity.kt index d26a0c9cc93..6347d2174ca 100644 --- a/app/src/main/java/org/oppia/android/app/player/state/testing/StateFragmentTestActivity.kt +++ b/app/src/main/java/org/oppia/android/app/player/state/testing/StateFragmentTestActivity.kt @@ -18,6 +18,7 @@ import org.oppia.android.app.player.state.listener.StateKeyboardButtonListener import org.oppia.android.app.player.stopplaying.StopStatePlayingSessionWithSavedProgressListener import javax.inject.Inject import org.oppia.android.app.activity.ActivityComponentImpl +import org.oppia.android.app.model.WrittenTranslationContext internal const val TEST_ACTIVITY_PROFILE_ID_EXTRA_KEY = "StateFragmentTestActivity.test_activity_profile_id" @@ -44,6 +45,7 @@ class StateFragmentTestActivity : @Inject lateinit var stateFragmentTestActivityPresenter: StateFragmentTestActivityPresenter private lateinit var state: State + private lateinit var writtenTranslationContext: WrittenTranslationContext override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -106,7 +108,8 @@ class StateFragmentTestActivity : HintsAndSolutionDialogFragment.newInstance( explorationId, state, - helpIndex + helpIndex, + writtenTranslationContext ) hintsAndSolutionFragment.showNow(supportFragmentManager, TAG_HINTS_AND_SOLUTION_DIALOG) } @@ -120,8 +123,11 @@ class StateFragmentTestActivity : stateFragmentTestActivityPresenter.revealSolution() } - override fun onExplorationStateLoaded(state: State) { + override fun onExplorationStateLoaded( + state: State, writtenTranslationContext: WrittenTranslationContext + ) { this.state = state + this.writtenTranslationContext = writtenTranslationContext } private fun getHintsAndSolution(): HintsAndSolutionDialogFragment? { diff --git a/app/src/main/java/org/oppia/android/app/shim/ViewBindingShim.kt b/app/src/main/java/org/oppia/android/app/shim/ViewBindingShim.kt index 4356120a55f..cb57334b06d 100644 --- a/app/src/main/java/org/oppia/android/app/shim/ViewBindingShim.kt +++ b/app/src/main/java/org/oppia/android/app/shim/ViewBindingShim.kt @@ -9,6 +9,7 @@ import android.widget.LinearLayout import androidx.recyclerview.widget.RecyclerView import org.oppia.android.app.home.promotedlist.ComingSoonTopicsViewModel import org.oppia.android.app.home.promotedlist.PromotedStoryViewModel +import org.oppia.android.app.model.WrittenTranslationContext import org.oppia.android.app.player.state.itemviewmodel.DragDropInteractionContentViewModel import org.oppia.android.app.player.state.itemviewmodel.SelectionInteractionContentViewModel import org.oppia.android.util.parser.html.HtmlParser @@ -139,7 +140,8 @@ interface ViewBindingShim { htmlParserFactory: HtmlParser.Factory, resourceBucketName: String, entityType: String, - entityId: String + entityId: String, + writtenTranslationContext: WrittenTranslationContext ) /** @@ -162,6 +164,7 @@ interface ViewBindingShim { htmlParserFactory: HtmlParser.Factory, resourceBucketName: String, entityType: String, - entityId: String + entityId: String, + writtenTranslationContext: WrittenTranslationContext ) } diff --git a/app/src/main/java/org/oppia/android/app/shim/ViewBindingShimImpl.kt b/app/src/main/java/org/oppia/android/app/shim/ViewBindingShimImpl.kt index a1545be5de6..b2b2a313f7f 100644 --- a/app/src/main/java/org/oppia/android/app/shim/ViewBindingShimImpl.kt +++ b/app/src/main/java/org/oppia/android/app/shim/ViewBindingShimImpl.kt @@ -21,6 +21,8 @@ import org.oppia.android.databinding.MultipleChoiceInteractionItemsBinding import org.oppia.android.databinding.PromotedStoryCardBinding import org.oppia.android.util.parser.html.HtmlParser import javax.inject.Inject +import org.oppia.android.app.model.WrittenTranslationContext +import org.oppia.android.domain.translation.TranslationController /** * Creates bindings for Views in order to avoid View files directly depending on Binding files. @@ -31,7 +33,9 @@ import javax.inject.Inject * View once Gradle has been removed. */ // TODO(#1619): Remove file post-Gradle -class ViewBindingShimImpl @Inject constructor() : ViewBindingShim { +class ViewBindingShimImpl @Inject constructor( + private val translationController: TranslationController +) : ViewBindingShim { override fun providePromotedStoryCardInflatedView( inflater: LayoutInflater, @@ -89,7 +93,8 @@ class ViewBindingShimImpl @Inject constructor() : ViewBindingShim { htmlParserFactory: HtmlParser.Factory, resourceBucketName: String, entityType: String, - entityId: String + entityId: String, + writtenTranslationContext: WrittenTranslationContext ) { val binding = DataBindingUtil.findBinding(view)!! @@ -100,7 +105,7 @@ class ViewBindingShimImpl @Inject constructor() : ViewBindingShim { entityId, false ).parseOppiaHtml( - viewModel.htmlContent.html, + translationController.extractString(viewModel.htmlContent, writtenTranslationContext), binding.itemSelectionContentsTextView ) binding.viewModel = viewModel @@ -124,7 +129,8 @@ class ViewBindingShimImpl @Inject constructor() : ViewBindingShim { htmlParserFactory: HtmlParser.Factory, resourceBucketName: String, entityType: String, - entityId: String + entityId: String, + writtenTranslationContext: WrittenTranslationContext ) { val binding = DataBindingUtil.findBinding(view)!! @@ -132,7 +138,8 @@ class ViewBindingShimImpl @Inject constructor() : ViewBindingShim { htmlParserFactory.create( resourceBucketName, entityType, entityId, /* imageCenterAlign= */ false ).parseOppiaHtml( - viewModel.htmlContent.html, binding.multipleChoiceContentTextView + translationController.extractString(viewModel.htmlContent, writtenTranslationContext), + binding.multipleChoiceContentTextView ) binding.viewModel = viewModel } diff --git a/app/src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryChapterSummaryViewModel.kt b/app/src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryChapterSummaryViewModel.kt index 1bba4ae11b5..0c34a2e6036 100644 --- a/app/src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryChapterSummaryViewModel.kt +++ b/app/src/main/java/org/oppia/android/app/story/storyitemviewmodel/StoryChapterSummaryViewModel.kt @@ -45,7 +45,9 @@ class StoryChapterSummaryViewModel( if (chapterPlayState == ChapterPlayState.IN_PROGRESS_SAVED) { val explorationCheckpointLiveData = explorationCheckpointController.retrieveExplorationCheckpoint( - ProfileId.getDefaultInstance(), + ProfileId.newBuilder().apply { + internalId = internalProfileId + }.build(), explorationId ).toLiveData() diff --git a/app/src/main/java/org/oppia/android/app/testing/ConceptCardFragmentTestActivityPresenter.kt b/app/src/main/java/org/oppia/android/app/testing/ConceptCardFragmentTestActivityPresenter.kt index f64cfbd2a69..fa1ad4a93d5 100644 --- a/app/src/main/java/org/oppia/android/app/testing/ConceptCardFragmentTestActivityPresenter.kt +++ b/app/src/main/java/org/oppia/android/app/testing/ConceptCardFragmentTestActivityPresenter.kt @@ -8,6 +8,7 @@ import org.oppia.android.app.topic.conceptcard.ConceptCardFragment import org.oppia.android.domain.topic.TEST_SKILL_ID_0 import org.oppia.android.domain.topic.TEST_SKILL_ID_1 import javax.inject.Inject +import org.oppia.android.app.model.ProfileId /** The presenter for [ConceptCardFragmentTestActivity] */ class ConceptCardFragmentTestActivityPresenter @Inject constructor( @@ -16,11 +17,11 @@ class ConceptCardFragmentTestActivityPresenter @Inject constructor( fun handleOnCreate() { activity.setContentView(R.layout.concept_card_fragment_test_activity) activity.findViewById