From 0c98a6cc1688c3a96bed67246ebd429e79077479 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Tue, 7 Sep 2021 22:42:46 -0700 Subject: [PATCH 01/13] [RunAllTests] Fix #3752: Introduce Bazel test batching in CI (#3757) * Initial commit. This is needed to open a PR on GitHub. This commit is being made so that the PR can start off in a broken Actions state. This also initially disables most non-Bazel workflows to make workflow iteration faster and less impacting on other team members. * Introduce infrastructure for batching. This introduces a new mechanism for passing lists of tests to sharded test targets in CI, and hooks it up. No actual sharding is occurring yet. This led to some simplifications in the CI workflow since the script can be more dynamic in computing the full list of targets (which also works around a previous bug with instrumentation tests being run). Java proto lite also needed to be upgraded for the scripts to be able to use it. More testing/documentation needed as this functionality continues to expand. * Add bucketing strategy. This simply partitions bucketed groups of targets into chunks of 10 for each run. Only 3 buckets are currently retained to test sharding in CI before introducing full support. * Fix caching & stabilize builds. Fixes some caching bucket and output bugs. Also, introduces while loop & keep_going to introduce resilience against app test build failures (or just test failures in general). * Increase sharding & add randomization. Also, enable other workflows. Note that CI shouldn't fully pass yet since some documentation and testing needs to be added yet, but this is meant to be a more realistic test of the CI environment before the PR is finished. * Improving partitionin & readability. Adds a human-readable prefix to make the shards look a bit nicer. Also, adds more fine-tuned partitioning to bucket & reduce shard counts to improve overall timing. Will need to be tested in CI. * Add new tests & fix static analysis errors. * Fix script. A newly computed variable wasn't updated to be used in an earlier change. * Fix broken tests & test configuration. Add docstrings for proto. * Fix mistake from earlier commit. * Try 10 max parallel actions instead. See https://github.com/oppia/oppia-android/pull/3757#issuecomment-911460981 for context. * Fix another error from an earlier commit. --- .github/workflows/static_checks.yml | 46 ++ .github/workflows/unit_tests.yml | 150 ++++-- scripts/BUILD.bazel | 7 + scripts/assets/maven_dependencies.textproto | 4 +- .../org/oppia/android/scripts/ci/BUILD.bazel | 15 + .../scripts/ci/ComputeAffectedTests.kt | 379 ++++++++++--- .../scripts/ci/RetrieveAffectedTests.kt | 50 ++ .../oppia/android/scripts/common/BUILD.bazel | 10 + .../scripts/common/ProtoStringEncoder.kt | 52 ++ .../oppia/android/scripts/proto/BUILD.bazel | 11 + .../scripts/proto/affected_tests.proto | 16 + .../scripts/testing/TestBazelWorkspace.kt | 15 +- .../org/oppia/android/scripts/ci/BUILD.bazel | 15 + .../scripts/ci/ComputeAffectedTestsTest.kt | 501 ++++++++++++++++-- .../scripts/ci/RetrieveAffectedTestsTest.kt | 147 +++++ .../oppia/android/scripts/common/BUILD.bazel | 12 + .../scripts/common/ProtoStringEncoderTest.kt | 91 ++++ .../license/MavenDependenciesListCheckTest.kt | 2 +- .../license/MavenDependenciesRetrieverTest.kt | 3 +- .../scripts/testing/TestBazelWorkspaceTest.kt | 12 +- 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 +- 24 files changed, 1394 insertions(+), 201 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/ci/RetrieveAffectedTestsTest.kt 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 6f3542ec995..ff531ffbebb 100644 --- a/.github/workflows/static_checks.yml +++ b/.github/workflows/static_checks.yml @@ -96,6 +96,8 @@ jobs: script_checks: name: Script Checks runs-on: ubuntu-18.04 + env: + CACHE_DIRECTORY: ~/.bazel_cache steps: - uses: actions/checkout@v2 @@ -104,6 +106,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: | @@ -137,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/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 3813bf8d6f1..93171606998 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-encoded-shard\":[$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 @@ -59,8 +97,8 @@ jobs: runs-on: ubuntu-18.04 strategy: fail-fast: false - max-parallel: 5 - matrix: ${{fromJson(needs.bazel_compute_affected_targets.outputs.matrix)}} + max-parallel: 10 + matrix: ${{ fromJson(needs.bazel_compute_affected_targets.outputs.matrix) }} env: ENABLE_CACHING: false CACHE_DIRECTORY: ~/.bazel_cache @@ -77,17 +115,41 @@ 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: Configure Bazel to use a local cache (for scripts) env: - TEST_TARGET: ${{ matrix.test-target }} + 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_ENCODED_SHARD: ${{ matrix.affected-tests-bucket-base64-encoded-shard }} run: | - echo "Test target: $TEST_TARGET" - TEST_CATEGORY=$(echo "$TEST_TARGET" | grep -oP 'org/oppia/android/(.+?)/' | cut -f 4 -d "/") + # 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 $(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" + 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 +158,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 }} @@ -129,7 +191,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: | @@ -163,17 +225,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 }} - 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: | + # 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: Run Oppia Test (without caching, or on a fork) + - 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 }} - run: bazel test -- ${{ matrix.test-target }} + BAZEL_TEST_TARGETS: ${{ env.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 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 --keep_going -- $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/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/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..5aea7c69969 100644 --- a/scripts/src/java/org/oppia/android/scripts/ci/ComputeAffectedTests.kt +++ b/scripts/src/java/org/oppia/android/scripts/ci/ComputeAffectedTests.kt @@ -2,32 +2,43 @@ 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 +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_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) { + if (args.size < 4) { println( "Usage: bazel run //scripts:compute_affected_tests --" + - " " + " " + + " " ) exitProcess(1) } @@ -35,102 +46,310 @@ fun main(args: Array) { 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" + 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 + ) +} - 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) +// 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 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") +/** 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 allTestTargets = bazelClient.retrieveAllTestTargets() - println() + /** + * 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" + } - // Filtering out the targets to be ignored. - val nonInstrumentationAffectedTestTargets = allTestTargets.filter { targetPath -> - !targetPath - .startsWith( - "//instrumentation/src/javatests/org/oppia/android/instrumentation/player", - ignoreCase = true - ) + 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" }) + + // 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 encodedTestBucketEntries = + affectedTestBuckets.associateBy { it.toCompressedBase64() }.entries.shuffled() + File(pathToOutputFile).printWriter().use { writer -> + encodedTestBucketEntries.forEachIndexed { index, (encoded, bucket) -> + writer.println("${bucket.cacheBucketName}-shard$index;$encoded") + } + } } - 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() + } + + 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 files (per Git): $changedFiles") - val changedFileTargets = bazelClient.retrieveBazelTargets(changedFiles).toSet() - println("Changed Bazel file targets: $changedFileTargets") + private fun bucketTargets(testTargets: List): List { + // 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 = when (shardingStrategies.first()) { + ShardingStrategy.LARGE_PARTITIONS -> maxTestCountPerLargeShard + ShardingStrategy.MEDIUM_PARTITIONS -> maxTestCountPerMediumShard + ShardingStrategy.SMALL_PARTITIONS -> maxTestCountPerSmallShard + } + val allPartitionTargets = bucketMap.values.flatten() - val affectedTestTargets = bazelClient.retrieveRelatedTestTargets(changedFileTargets).toSet() - println("Affected Bazel test targets: $affectedTestTargets") + // Use randomization to encourage cache breadth & potentially improve workflow performance. + allPartitionTargets.shuffled().chunked(maxTestCountPerShard) + } - // 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" + // Finally, compile into a list of protos: + // 7. Convert to List + return shardedBuckets.entries.flatMap { (bucketName, shardedTargets) -> + shardedTargets.map { targets -> + AffectedTestsBucket.newBuilder().apply { + cacheBucketName = bucketName + addAllAffectedTestTargets(targets) + }.build() + } + } } - 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") + 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 + ), - val allAffectedTestTargets = (affectedTestTargets + transitiveTestTargets).toSet() + /** Corresponds to data layer tests. */ + DATA( + cacheBucketName = "data", + groupingStrategy = GroupingStrategy.BUCKET_GENERICALLY, + shardingStrategy = ShardingStrategy.LARGE_PARTITIONS + ), - // Filtering out the targets to be ignored. - val nonInstrumentationAffectedTestTargets = allAffectedTestTargets.filter { targetPath -> - !targetPath - .startsWith( - "//instrumentation/src/javatests/org/oppia/android/instrumentation/player", - ignoreCase = true - ) + /** 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, + shardingStrategy = ShardingStrategy.LARGE_PARTITIONS + ); + + companion object { + private val EXTRACT_BUCKET_REGEX = "^//([^(/|:)]+?)[/:].+?\$".toRegex() + + /** Returns the [TestBucket] that corresponds to the specific [testTarget]. */ + fun retrieveCorrespondingTestBucket(testTarget: String): TestBucket? { + return EXTRACT_BUCKET_REGEX.matchEntire(testTarget) + ?.groupValues + ?.maybeSecond() + ?.let { bucket -> + values().find { it.cacheBucketName == bucket } + ?: error( + "Invalid bucket name: $bucket (expected one of:" + + " ${values().map { it.cacheBucketName }})" + ) + } ?: error("Invalid target: $testTarget (could not extract bucket name)") + } + + private fun List.maybeSecond(): E? = if (size >= 2) this[1] else null + } } - println() - println( - "Affected test targets:" + - "\n${nonInstrumentationAffectedTestTargets.joinToString(separator = "\n") { "- $it" }}" - ) - outputFile.printWriter().use { writer -> - nonInstrumentationAffectedTestTargets.forEach { writer.println(it) } + 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 { + /** + * Indicates that the tests for a test bucket run very quickly and don't need as much + * parallelization. + */ + LARGE_PARTITIONS, + + /** + * Indicates that the tests for a test bucket are somewhere between [LARGE_PARTITIONS] and + * [SMALL_PARTITIONS]. + */ + MEDIUM_PARTITIONS, + + /** + * Indicates that the tests for a test bucket run slowly and require more parallelization for + * faster CI runs. + */ + 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 new file mode 100644 index 00000000000..e37e4691ac0 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/ci/RetrieveAffectedTests.kt @@ -0,0 +1,50 @@ +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]) + 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..9d044ddf090 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/proto/affected_tests.proto @@ -0,0 +1,16 @@ +syntax = "proto3"; + +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 c86567477a6..68487b6062b 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( @@ -135,12 +138,12 @@ 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) 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..cb9eafb6d21 100644 --- a/scripts/src/javatests/org/oppia/android/scripts/ci/BUILD.bazel +++ b/scripts/src/javatests/org/oppia/android/scripts/ci/BUILD.bazel @@ -6,9 +6,12 @@ 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", "//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 +19,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 +} 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/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() 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 3dc0a6146ba0dad11d9fe60cf6cd9cb7e4a92d8a Mon Sep 17 00:00:00 2001 From: yash10019coder <76404817+yash10019coder@users.noreply.github.com> Date: Wed, 8 Sep 2021 18:51:03 +0530 Subject: [PATCH 02/13] Fix #3413: Fix espresso test failing in topic practice fragment test (#3743) * Fixed test not passing in this testTopicPracticeFragment_loadFragment_selectSubtopics_clickStartButton_skillListTransferSuccessfully by unregistering the idling resouce in it * removed a unused import as ktlint was failing * Added .use after launcing the activity and added all code inside it * formatted the file * removed unused import * Fixed Idling resources issue --- .../practice/TopicPracticeFragmentTest.kt | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/app/src/sharedTest/java/org/oppia/android/app/topic/practice/TopicPracticeFragmentTest.kt b/app/src/sharedTest/java/org/oppia/android/app/topic/practice/TopicPracticeFragmentTest.kt index 7e9128034f4..3abbf827b28 100644 --- a/app/src/sharedTest/java/org/oppia/android/app/topic/practice/TopicPracticeFragmentTest.kt +++ b/app/src/sharedTest/java/org/oppia/android/app/topic/practice/TopicPracticeFragmentTest.kt @@ -4,6 +4,7 @@ import android.app.Application import androidx.appcompat.app.AppCompatActivity import androidx.recyclerview.widget.RecyclerView import androidx.test.core.app.ActivityScenario +import androidx.test.core.app.ActivityScenario.launch import androidx.test.core.app.ApplicationProvider import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.click @@ -26,7 +27,6 @@ import org.hamcrest.Matchers.allOf import org.hamcrest.Matchers.not import org.junit.After import org.junit.Before -import org.junit.Ignore import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -244,15 +244,16 @@ class TopicPracticeFragmentTest { } @Test - @Ignore("Flaky test") // TODO(#3413): Test is failing unexpectedly. fun testTopicPracticeFragment_loadFragment_selectSubtopics_clickStartButton_skillListTransferSuccessfully() { // ktlint-disable max-line-length - launchTopicActivityIntent(internalProfileId, FRACTIONS_TOPIC_ID) - clickPracticeTab() - clickPracticeItem(position = 1, targetViewId = R.id.subtopic_check_box) - scrollToPosition(position = 5) - clickPracticeItem(position = 5, targetViewId = R.id.topic_practice_start_button) - intended(hasComponent(QuestionPlayerActivity::class.java.name)) - intended(hasExtra(QuestionPlayerActivity.getIntentKey(), skillIdList)) + testCoroutineDispatchers.unregisterIdlingResource() + launchTopicActivityIntent(internalProfileId, FRACTIONS_TOPIC_ID).use { + clickPracticeTab() + clickPracticeItem(position = 1, targetViewId = R.id.subtopic_check_box) + scrollToPosition(position = 5) + clickPracticeItem(position = 5, targetViewId = R.id.topic_practice_start_button) + intended(hasComponent(QuestionPlayerActivity::class.java.name)) + intended(hasExtra(QuestionPlayerActivity.getIntentKey(), skillIdList)) + } } @Test From 33bc789c483cb2ff51e4147675a52a8e6d893a4e Mon Sep 17 00:00:00 2001 From: Jashaswee Jena Date: Thu, 9 Sep 2021 13:27:32 +0530 Subject: [PATCH 03/13] Fix #3464: Merge coming_soon_topic_view into single xml file (#3713) * Refactor: Remove duplicate layout files for coming_soon_topic_view.xml * Reformat coming_soon_topic_view.xml --- .../layout-land/coming_soon_topic_view.xml | 102 ------------------ .../coming_soon_topic_view.xml | 102 ------------------ .../coming_soon_topic_view.xml | 102 ------------------ .../res/layout/coming_soon_topic_view.xml | 6 +- 4 files changed, 3 insertions(+), 309 deletions(-) delete mode 100644 app/src/main/res/layout-land/coming_soon_topic_view.xml delete mode 100644 app/src/main/res/layout-sw600dp-land/coming_soon_topic_view.xml delete mode 100644 app/src/main/res/layout-sw600dp-port/coming_soon_topic_view.xml diff --git a/app/src/main/res/layout-land/coming_soon_topic_view.xml b/app/src/main/res/layout-land/coming_soon_topic_view.xml deleted file mode 100644 index 6bac60e0d09..00000000000 --- a/app/src/main/res/layout-land/coming_soon_topic_view.xml +++ /dev/null @@ -1,102 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout-sw600dp-land/coming_soon_topic_view.xml b/app/src/main/res/layout-sw600dp-land/coming_soon_topic_view.xml deleted file mode 100644 index 6bac60e0d09..00000000000 --- a/app/src/main/res/layout-sw600dp-land/coming_soon_topic_view.xml +++ /dev/null @@ -1,102 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout-sw600dp-port/coming_soon_topic_view.xml b/app/src/main/res/layout-sw600dp-port/coming_soon_topic_view.xml deleted file mode 100644 index ec35dab92ff..00000000000 --- a/app/src/main/res/layout-sw600dp-port/coming_soon_topic_view.xml +++ /dev/null @@ -1,102 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/coming_soon_topic_view.xml b/app/src/main/res/layout/coming_soon_topic_view.xml index cfc347ce6ed..5e1535834a7 100644 --- a/app/src/main/res/layout/coming_soon_topic_view.xml +++ b/app/src/main/res/layout/coming_soon_topic_view.xml @@ -13,11 +13,11 @@ android:id="@+id/topic_container" android:layout_width="144dp" android:layout_height="wrap_content" - app:layoutMarginEnd="@{viewModel.endMargin}" - app:cardCornerRadius="4dp"> + app:cardCornerRadius="4dp" + app:layoutMarginEnd="@{viewModel.endMargin}"> From f56345cff6ddbac7847f0762fabe1ac8ea5ca018 Mon Sep 17 00:00:00 2001 From: Pranav <66965591+Pranav-Bobde@users.noreply.github.com> Date: Fri, 10 Sep 2021 09:55:45 +0530 Subject: [PATCH 04/13] Fix #431: Fixed Profile Page Cut-Off For Small Display (#3630) * Fixed profile page cut off in small display size * removed redundant lines * dimens for landscape xml added * made suggested name change * updated one wrong value in dimens * made suggested changes * made suggested changes according to PPT * made suggested nit changes --- .../main/res/layout-land/profile_chooser_fragment.xml | 4 ++-- .../res/layout-land/profile_chooser_profile_view.xml | 1 + app/src/main/res/layout/profile_chooser_fragment.xml | 4 ++-- app/src/main/res/layout/profile_chooser_profile_view.xml | 2 +- app/src/main/res/values-hdpi/dimens.xml | 9 +++++++++ app/src/main/res/values-land-hdpi/dimens.xml | 9 +++++++++ app/src/main/res/values-land-ldpi/dimens.xml | 8 ++++++++ app/src/main/res/values-land-mdpi/dimens.xml | 8 ++++++++ app/src/main/res/values-land/dimens.xml | 5 +++++ app/src/main/res/values-ldpi/dimens.xml | 8 ++++++++ app/src/main/res/values-mdpi/dimens.xml | 8 ++++++++ app/src/main/res/values-xxhdpi/dimens.xml | 8 ++++++++ app/src/main/res/values/dimens.xml | 7 ++++++- 13 files changed, 75 insertions(+), 6 deletions(-) create mode 100644 app/src/main/res/values-hdpi/dimens.xml create mode 100644 app/src/main/res/values-land-hdpi/dimens.xml create mode 100644 app/src/main/res/values-land-ldpi/dimens.xml create mode 100644 app/src/main/res/values-land-mdpi/dimens.xml create mode 100644 app/src/main/res/values-ldpi/dimens.xml create mode 100644 app/src/main/res/values-mdpi/dimens.xml create mode 100644 app/src/main/res/values-xxhdpi/dimens.xml diff --git a/app/src/main/res/layout-land/profile_chooser_fragment.xml b/app/src/main/res/layout-land/profile_chooser_fragment.xml index 595e923267a..3d9a06b1bc4 100644 --- a/app/src/main/res/layout-land/profile_chooser_fragment.xml +++ b/app/src/main/res/layout-land/profile_chooser_fragment.xml @@ -53,7 +53,7 @@ android:id="@+id/profile_select_text" style="@style/Heading1" android:layout_marginStart="36dp" - android:layout_marginTop="64dp" + android:layout_marginTop="@dimen/profile_chooser_fragment_profile_select_text_margin_top" android:layout_marginEnd="36dp" android:text="@string/profile_chooser_select" android:textColor="@color/white" @@ -65,7 +65,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:layout_marginStart="16dp" - android:layout_marginTop="12dp" + android:layout_marginTop="@dimen/profile_chooser_fragment_profile_recycler_view_margin_top" android:layout_marginEnd="16dp" android:clipToPadding="false" android:fadingEdge="horizontal" diff --git a/app/src/main/res/layout-land/profile_chooser_profile_view.xml b/app/src/main/res/layout-land/profile_chooser_profile_view.xml index daad93ec975..a884798d94f 100644 --- a/app/src/main/res/layout-land/profile_chooser_profile_view.xml +++ b/app/src/main/res/layout-land/profile_chooser_profile_view.xml @@ -89,6 +89,7 @@ android:id="@+id/add_profile_divider_view" android:layout_width="1dp" android:layout_height="match_parent" + android:layout_marginTop="@dimen/profile_chooser_profile_view_view_margin_top" android:layout_gravity="bottom" android:background="@color/oppiaProfileChooserDivider" android:visibility="@{hasProfileEverBeenAddedValue ? View.GONE : View.VISIBLE}" diff --git a/app/src/main/res/layout/profile_chooser_fragment.xml b/app/src/main/res/layout/profile_chooser_fragment.xml index 1c8f504dfbc..bba2aeb6c10 100644 --- a/app/src/main/res/layout/profile_chooser_fragment.xml +++ b/app/src/main/res/layout/profile_chooser_fragment.xml @@ -53,7 +53,7 @@ android:id="@+id/profile_select_text" style="@style/Heading1" android:layout_marginStart="36dp" - android:layout_marginTop="64dp" + android:layout_marginTop="@dimen/profile_chooser_fragment_profile_select_text_margin_top" android:layout_marginEnd="36dp" android:text="@string/profile_chooser_select" android:textColor="@color/white" @@ -65,7 +65,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:layout_marginStart="32dp" - android:layout_marginTop="36dp" + android:layout_marginTop="@dimen/profile_chooser_fragment_profile_recycler_view_margin_top" android:layout_marginEnd="32dp" android:clipToPadding="false" android:fadingEdge="horizontal" diff --git a/app/src/main/res/layout/profile_chooser_profile_view.xml b/app/src/main/res/layout/profile_chooser_profile_view.xml index 3b4abd6dba5..6d5d24a109f 100644 --- a/app/src/main/res/layout/profile_chooser_profile_view.xml +++ b/app/src/main/res/layout/profile_chooser_profile_view.xml @@ -96,7 +96,7 @@ android:id="@+id/add_profile_divider_view" android:layout_width="match_parent" android:layout_height="1dp" - android:layout_marginTop="60dp" + android:layout_marginTop="@dimen/profile_chooser_profile_view_view_margin_top" android:background="@color/oppiaProfileChooserDivider" android:visibility="@{hasProfileEverBeenAddedValue ? View.GONE : View.VISIBLE}" app:layout_constraintEnd_toEndOf="parent" diff --git a/app/src/main/res/values-hdpi/dimens.xml b/app/src/main/res/values-hdpi/dimens.xml new file mode 100644 index 00000000000..bf52594b9cd --- /dev/null +++ b/app/src/main/res/values-hdpi/dimens.xml @@ -0,0 +1,9 @@ + + + + 32dp + 18dp + 32dp + 32dp + + diff --git a/app/src/main/res/values-land-hdpi/dimens.xml b/app/src/main/res/values-land-hdpi/dimens.xml new file mode 100644 index 00000000000..686aa480cdc --- /dev/null +++ b/app/src/main/res/values-land-hdpi/dimens.xml @@ -0,0 +1,9 @@ + + + + 20dp + 8dp + 32dp + 32dp + + diff --git a/app/src/main/res/values-land-ldpi/dimens.xml b/app/src/main/res/values-land-ldpi/dimens.xml new file mode 100644 index 00000000000..6a718617118 --- /dev/null +++ b/app/src/main/res/values-land-ldpi/dimens.xml @@ -0,0 +1,8 @@ + + + + 16dp + 8dp + 32dp + 32dp + diff --git a/app/src/main/res/values-land-mdpi/dimens.xml b/app/src/main/res/values-land-mdpi/dimens.xml new file mode 100644 index 00000000000..71dd70dee47 --- /dev/null +++ b/app/src/main/res/values-land-mdpi/dimens.xml @@ -0,0 +1,8 @@ + + + + 64dp + 32dp + 32dp + 32dp + diff --git a/app/src/main/res/values-land/dimens.xml b/app/src/main/res/values-land/dimens.xml index 4b51ff326bb..2e366174e26 100644 --- a/app/src/main/res/values-land/dimens.xml +++ b/app/src/main/res/values-land/dimens.xml @@ -260,6 +260,11 @@ 76dp 12dp + + 64dp + 12dp + 32dp + 72dp 72dp diff --git a/app/src/main/res/values-ldpi/dimens.xml b/app/src/main/res/values-ldpi/dimens.xml new file mode 100644 index 00000000000..0d5895656e6 --- /dev/null +++ b/app/src/main/res/values-ldpi/dimens.xml @@ -0,0 +1,8 @@ + + + + 32dp + 12dp + 32dp + 32dp + diff --git a/app/src/main/res/values-mdpi/dimens.xml b/app/src/main/res/values-mdpi/dimens.xml new file mode 100644 index 00000000000..21e07668b22 --- /dev/null +++ b/app/src/main/res/values-mdpi/dimens.xml @@ -0,0 +1,8 @@ + + + + 64dp + 36dp + 60dp + 60dp + diff --git a/app/src/main/res/values-xxhdpi/dimens.xml b/app/src/main/res/values-xxhdpi/dimens.xml new file mode 100644 index 00000000000..21e07668b22 --- /dev/null +++ b/app/src/main/res/values-xxhdpi/dimens.xml @@ -0,0 +1,8 @@ + + + + 64dp + 36dp + 60dp + 60dp + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 0be5019b680..a0be77076c7 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -136,7 +136,7 @@ 24dp 24dp 24dp - 84dp + 60dp 24dp 24dp 24dp @@ -444,6 +444,11 @@ 32dp 12dp + + 64dp + 36dp + 60dp + 0dp 0dp From e242828631cf577ba54e9160b35685495e121ef3 Mon Sep 17 00:00:00 2001 From: Akshay Nandwana Date: Fri, 10 Sep 2021 10:37:46 +0530 Subject: [PATCH 05/13] Revert "Fix #3432: [A11y] Content description for rich text based images (#3433)" (#3768) This reverts commit 7505dd5cdd5dac4437722fe23e907d5249810b6d. --- .../parser/html/CustomHtmlContentHandler.kt | 17 -------------- .../util/parser/html/ImageTagHandler.kt | 23 +------------------ .../util/parser/html/ImageTagHandlerTest.kt | 22 +----------------- 3 files changed, 2 insertions(+), 60 deletions(-) diff --git a/utility/src/main/java/org/oppia/android/util/parser/html/CustomHtmlContentHandler.kt b/utility/src/main/java/org/oppia/android/util/parser/html/CustomHtmlContentHandler.kt index bd115719c1a..c19f35f61f3 100644 --- a/utility/src/main/java/org/oppia/android/util/parser/html/CustomHtmlContentHandler.kt +++ b/utility/src/main/java/org/oppia/android/util/parser/html/CustomHtmlContentHandler.kt @@ -113,8 +113,6 @@ class CustomHtmlContentHandler private constructor( customTagHandlers.getValue(tag).handleClosingTag(output) customTagHandlers.getValue(tag) .handleTag(attributes, openTagIndex, output.length, output, imageRetriever) - customTagHandlers.getValue(tag) - .handleContentDescription(attributes, openTagIndex, output.length, output) } } } @@ -165,21 +163,6 @@ class CustomHtmlContentHandler private constructor( * @param output the destination [Editable] to which spans can be added */ fun handleClosingTag(output: Editable) {} - - /** - * Called when a custom tag is encountered. This is always called after the closing tag. - * - * @param attributes the tag's attributes - * @param openIndex the index in the output [Editable] at which this tag begins - * @param closeIndex the index in the output [Editable] at which this tag ends - * @param output the destination [Editable] to which spans can be added - */ - fun handleContentDescription( - attributes: Attributes, - openIndex: Int, - closeIndex: Int, - output: Editable - ) {} } /** diff --git a/utility/src/main/java/org/oppia/android/util/parser/html/ImageTagHandler.kt b/utility/src/main/java/org/oppia/android/util/parser/html/ImageTagHandler.kt index 4581c2587ad..5d55fcf4ed7 100644 --- a/utility/src/main/java/org/oppia/android/util/parser/html/ImageTagHandler.kt +++ b/utility/src/main/java/org/oppia/android/util/parser/html/ImageTagHandler.kt @@ -2,7 +2,6 @@ package org.oppia.android.util.parser.html import android.text.Editable import android.text.Spannable -import android.text.SpannableStringBuilder import android.text.style.ImageSpan import org.oppia.android.util.logging.ConsoleLogger import org.xml.sax.Attributes @@ -10,7 +9,6 @@ import org.xml.sax.Attributes /** The custom tag corresponding to [ImageTagHandler]. */ const val CUSTOM_IMG_TAG = "oppia-noninteractive-image" private const val CUSTOM_IMG_FILE_PATH_ATTRIBUTE = "filepath-with-value" -private const val CUSTOM_IMG_ALT_TEXT_ATTRIBUTE = "alt-with-value" /** * A custom tag handler for supporting custom Oppia images parsed with [CustomHtmlContentHandler]. @@ -45,25 +43,6 @@ class ImageTagHandler( endIndex, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE ) - } else consoleLogger.e("ImageTagHandler", "Failed to parse $CUSTOM_IMG_FILE_PATH_ATTRIBUTE") - } - - override fun handleContentDescription( - attributes: Attributes, - openIndex: Int, - closeIndex: Int, - output: Editable - ) { - val contentDescription = attributes.getJsonStringValue(CUSTOM_IMG_ALT_TEXT_ATTRIBUTE) - if (contentDescription != null) { - val spannableBuilder = SpannableStringBuilder(contentDescription) - spannableBuilder.setSpan( - contentDescription, - /* start= */ 0, - /* end= */ contentDescription.length, - Spannable.SPAN_INCLUSIVE_EXCLUSIVE - ) - output.replace(openIndex, closeIndex, spannableBuilder) - } else consoleLogger.e("ImageTagHandler", "Failed to parse $CUSTOM_IMG_ALT_TEXT_ATTRIBUTE") + } else consoleLogger.e("ImageTagHandler", "Failed to parse image tag") } } diff --git a/utility/src/test/java/org/oppia/android/util/parser/html/ImageTagHandlerTest.kt b/utility/src/test/java/org/oppia/android/util/parser/html/ImageTagHandlerTest.kt index 9dd8e10aeb1..352fad75028 100644 --- a/utility/src/test/java/org/oppia/android/util/parser/html/ImageTagHandlerTest.kt +++ b/utility/src/test/java/org/oppia/android/util/parser/html/ImageTagHandlerTest.kt @@ -50,10 +50,6 @@ private const val IMAGE_TAG_WITHOUT_FILEPATH_MARKUP = "" -private const val IMAGE_TAG_WITHOUT_ALT_VALUE_MARKUP = - "" - /** Tests for [ImageTagHandler]. */ @RunWith(AndroidJUnit4::class) @LooperMode(LooperMode.Mode.PAUSED) @@ -113,7 +109,7 @@ class ImageTagHandlerTest { fun testParseHtml_withImageCardMarkup_hasNoReadableText() { val parsedHtml = CustomHtmlContentHandler.fromHtml( - html = IMAGE_TAG_WITHOUT_ALT_VALUE_MARKUP, + html = IMAGE_TAG_MARKUP_1, imageRetriever = mockImageRetriever, customTagHandlers = tagHandlersWithImageTagSupport ) @@ -124,22 +120,6 @@ class ImageTagHandlerTest { assertThat(parsedHtmlStr.first().isObjectReplacementCharacter()).isTrue() } - @Test - fun testParseHtml_withImageCardMarkup_missingAltValue_hasReadableText() { - val parsedHtml = - CustomHtmlContentHandler.fromHtml( - html = IMAGE_TAG_MARKUP_1, - imageRetriever = mockImageRetriever, - customTagHandlers = tagHandlersWithImageTagSupport - ) - - // The image only adds a control character, so there aren't any human-readable characters. - val parsedHtmlStr = parsedHtml.toString() - val parsedContentDescription = "alt text 1" - assertThat(parsedHtmlStr).hasLength(parsedContentDescription.length) - assertThat(parsedHtmlStr.first().isObjectReplacementCharacter()).isFalse() - } - @Test fun testParseHtml_withImageCardMarkup_missingFilename_doesNotIncludeImageSpan() { val parsedHtml = From 92ce46b81b2183283e04c4d592fc8682338e1404 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Fri, 10 Sep 2021 11:25:08 -0700 Subject: [PATCH 06/13] Fix #1875, #3744, #3735, #2432: Add support for app bundles via Bazel, Proguard, and build flavors (#3750) * Add support for AABs, build flavors, and proguard. There are a lot of details to cover here--see the PR for the complete context. * Lint & codeowner fixes. * Fix failures. - Add missing codeowner - Add support for configuring base branch reference - Update CI for dev/alpha AAB builds to use 'develop' since there's no origin configured by default in the workflows * Different attempt to fix bad develop reference in CI. * Initial commit. This is needed to open a PR on GitHub. This commit is being made so that the PR can start off in a broken Actions state. This also initially disables most non-Bazel workflows to make workflow iteration faster and less impacting on other team members. * Introduce infrastructure for batching. This introduces a new mechanism for passing lists of tests to sharded test targets in CI, and hooks it up. No actual sharding is occurring yet. This led to some simplifications in the CI workflow since the script can be more dynamic in computing the full list of targets (which also works around a previous bug with instrumentation tests being run). Java proto lite also needed to be upgraded for the scripts to be able to use it. More testing/documentation needed as this functionality continues to expand. * Add bucketing strategy. This simply partitions bucketed groups of targets into chunks of 10 for each run. Only 3 buckets are currently retained to test sharding in CI before introducing full support. * Fix caching & stabilize builds. Fixes some caching bucket and output bugs. Also, introduces while loop & keep_going to introduce resilience against app test build failures (or just test failures in general). * Increase sharding & add randomization. Also, enable other workflows. Note that CI shouldn't fully pass yet since some documentation and testing needs to be added yet, but this is meant to be a more realistic test of the CI environment before the PR is finished. * Improving partitionin & readability. Adds a human-readable prefix to make the shards look a bit nicer. Also, adds more fine-tuned partitioning to bucket & reduce shard counts to improve overall timing. Will need to be tested in CI. * Add new tests & fix static analysis errors. * Fix script. A newly computed variable wasn't updated to be used in an earlier change. * Fix broken tests & test configuration. Add docstrings for proto. * Fix mistake from earlier commit. * Try 10 max parallel actions instead. See https://github.com/oppia/oppia-android/pull/3757#issuecomment-911460981 for context. * Fix another error from an earlier commit. * 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). --- .github/CODEOWNERS | 11 +- .github/workflows/build_tests.yml | 258 ++++++++++++++ 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 | 15 + .../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 | 323 ++++++++++++++++++ third_party/BUILD.bazel | 9 +- third_party/versions.bzl | 4 + version.bzl | 7 + 30 files changed, 1535 insertions(+), 7 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/CODEOWNERS b/.github/CODEOWNERS index df29aa65c9d..b3092718d6c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -58,7 +58,7 @@ gradlew.bat @BenHenning /app/src/main/res/values*/strings.xml @BenHenning /app/src/main/res/values*/untranslated_strings.xml @BenHenning -# Proguard configuration. +# Proguard configurations. *.pro @BenHenning # Lesson assets. @@ -86,6 +86,9 @@ buf.yaml @anandwana001 # Binary files. *.png @BenHenning +# Configurations for Bazel-built Android App Bundles. +bundle_config.pb.json @BenHenning + # Important codebase files. LICENSE @BenHenning NOTICE @BenHenning @@ -225,3 +228,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/.github/workflows/build_tests.yml b/.github/workflows/build_tests.yml index 9e653e266bc..b827f77733b 100644 --- a/.github/workflows/build_tests.yml +++ b/.github/workflows/build_tests.yml @@ -137,3 +137,261 @@ 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 + with: + fetch-depth: 0 + + - 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 + with: + fetch-depth: 0 + + - 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..845129a3402 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 = flavor) + for flavor in AVAILABLE_FLAVORS +] diff --git a/WORKSPACE b/WORKSPACE index 6432ca9f2e8..cbcd442d1b2 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: @@ -157,6 +157,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..8c17e56cbec --- /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 -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 + # 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 65c29064892..2fcfb81eba5 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", @@ -211,3 +212,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..fc5ec90d7e0 --- /dev/null +++ b/scripts/src/java/org/oppia/android/scripts/build/TransformAndroidManifest.kt @@ -0,0 +1,138 @@ +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 + +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 84a5d2e949c..1b3b76ec17b 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..02889cc08bd --- /dev/null +++ b/scripts/src/javatests/org/oppia/android/scripts/build/TransformAndroidManifestTest.kt @@ -0,0 +1,323 @@ +package org.oppia.android.scripts.build + +import com.google.common.truth.Truth.assertThat +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 +import java.io.File + +/** + * 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..860faf7687e 100644 --- a/third_party/BUILD.bazel +++ b/third_party/BUILD.bazel @@ -12,7 +12,7 @@ own Bazel macros to automatically set up code generation (which includes pulling dependencies). """ -load("@rules_java//java:defs.bzl", "java_library") +load("@rules_java//java:defs.bzl", "java_binary", "java_library") load("@rules_jvm_external//:defs.bzl", "artifact") load(":versions.bzl", "MAVEN_PRODUCTION_DEPENDENCY_VERSIONS", "MAVEN_TEST_DEPENDENCY_VERSIONS") @@ -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 3b2585e5349..d27ab652108 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 ff485e9fa788ebbf3c344f455fcc585b576682e1 Mon Sep 17 00:00:00 2001 From: bkaur-bkj <64087572+bkaur-bkj@users.noreply.github.com> Date: Tue, 14 Sep 2021 17:47:31 +0530 Subject: [PATCH 07/13] Fix #3568 merged the Promoted_story_card xmls (#3767) * merged the Promoted_story_card xmls * dimens renamed * dimen changed * changed promoted_story_card_width to 280 dp * removed the common attribute from dimen files --- .../res/layout-land/promoted_story_card.xml | 103 ------------------ .../promoted_story_card.xml | 102 ----------------- .../promoted_story_card.xml | 102 ----------------- .../main/res/layout/promoted_story_card.xml | 8 +- app/src/main/res/values-land/dimens.xml | 5 + .../main/res/values-sw600dp-land/dimens.xml | 5 + .../main/res/values-sw600dp-port/dimens.xml | 6 +- app/src/main/res/values/dimens.xml | 6 + 8 files changed, 25 insertions(+), 312 deletions(-) delete mode 100755 app/src/main/res/layout-land/promoted_story_card.xml delete mode 100644 app/src/main/res/layout-sw600dp-land/promoted_story_card.xml delete mode 100644 app/src/main/res/layout-sw600dp-port/promoted_story_card.xml diff --git a/app/src/main/res/layout-land/promoted_story_card.xml b/app/src/main/res/layout-land/promoted_story_card.xml deleted file mode 100755 index 4b9951015cf..00000000000 --- a/app/src/main/res/layout-land/promoted_story_card.xml +++ /dev/null @@ -1,103 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout-sw600dp-land/promoted_story_card.xml b/app/src/main/res/layout-sw600dp-land/promoted_story_card.xml deleted file mode 100644 index f8ff4daf685..00000000000 --- a/app/src/main/res/layout-sw600dp-land/promoted_story_card.xml +++ /dev/null @@ -1,102 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout-sw600dp-port/promoted_story_card.xml b/app/src/main/res/layout-sw600dp-port/promoted_story_card.xml deleted file mode 100644 index 34780cdd4a4..00000000000 --- a/app/src/main/res/layout-sw600dp-port/promoted_story_card.xml +++ /dev/null @@ -1,102 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/layout/promoted_story_card.xml b/app/src/main/res/layout/promoted_story_card.xml index b0d418b0917..9239643c554 100755 --- a/app/src/main/res/layout/promoted_story_card.xml +++ b/app/src/main/res/layout/promoted_story_card.xml @@ -12,9 +12,9 @@ + android:textSize="@dimen/promoted_story_card_text_size" /> 40dp 96dp + + 8dp + 8dp + 14sp + 8dp diff --git a/app/src/main/res/values-sw600dp-land/dimens.xml b/app/src/main/res/values-sw600dp-land/dimens.xml index f15655f2f9f..6fdbe35a1e9 100644 --- a/app/src/main/res/values-sw600dp-land/dimens.xml +++ b/app/src/main/res/values-sw600dp-land/dimens.xml @@ -322,4 +322,9 @@ 48dp 100dp + + 0dp + 32dp + 16sp + 4dp diff --git a/app/src/main/res/values-sw600dp-port/dimens.xml b/app/src/main/res/values-sw600dp-port/dimens.xml index d2f9881d062..428084dfa53 100644 --- a/app/src/main/res/values-sw600dp-port/dimens.xml +++ b/app/src/main/res/values-sw600dp-port/dimens.xml @@ -324,5 +324,9 @@ 48dp 100dp - + + 8dp + 24dp + 16sp + 4dp diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index a0be77076c7..94dad7a33c2 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -490,4 +490,10 @@ 36dp 148dp + + 8dp + 8dp + 280dp + 16sp + 8dp From 3f92b8485a3d02597efe1378a76d040ce2bf6e18 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Tue, 14 Sep 2021 12:58:56 -0700 Subject: [PATCH 08/13] Fix regex checks for translated strings. Also, performance improvements for the regex check. --- .../file_content_validation_checks.textproto | 2 +- .../file_content_validation_checks.proto | 3 + .../regex/RegexPatternValidationCheck.kt | 103 ++++++++++++------ .../regex/RegexPatternValidationCheckTest.kt | 24 ++++ 4 files changed, 99 insertions(+), 33 deletions(-) diff --git a/scripts/assets/file_content_validation_checks.textproto b/scripts/assets/file_content_validation_checks.textproto index 27552f874d7..6399ace7b2a 100644 --- a/scripts/assets/file_content_validation_checks.textproto +++ b/scripts/assets/file_content_validation_checks.textproto @@ -100,5 +100,5 @@ 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" } 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..de61643c2b9 100644 --- a/scripts/src/java/org/oppia/android/scripts/regex/RegexPatternValidationCheck.kt +++ b/scripts/src/java/org/oppia/android/scripts/regex/RegexPatternValidationCheck.kt @@ -32,24 +32,25 @@ fun main(vararg args: String) { // Check if the repo has any filename failure. val hasFilenameCheckFailure = retrieveFilenameChecks() - .fold(initial = false) { isFailing, filenameCheck -> - val checkFailed = checkProhibitedFileNamePattern( + .fold(initial = false) { hasFailingFile, filenameCheck -> + val fileFails = checkProhibitedFileNamePattern( repoRoot, searchFiles, filenameCheck, ) - isFailing || checkFailed + return@fold hasFailingFile || fileFails } // Check if the repo has any file content failure. - val hasFileContentCheckFailure = retrieveFileContentChecks() - .fold(initial = false) { isFailing, fileContentCheck -> - val checkFailed = checkProhibitedContent( + val contentChecks = retrieveFileContentChecks().map { MatchableFileContentCheck.createFrom(it) } + val hasFileContentCheckFailure = + searchFiles.fold(initial = false) { hasFailingFile, searchFile -> + val fileFails = checkProhibitedContent( repoRoot, - searchFiles, - fileContentCheck + searchFile, + contentChecks ) - isFailing || checkFailed + return@fold hasFailingFile || fileFails } if (hasFilenameCheckFailure || hasFileContentCheckFailure) { @@ -138,38 +139,33 @@ private fun checkProhibitedFileNamePattern( * Checks for a prohibited file content. * * @param repoRoot the root directory of the repo - * @param searchFiles a list of all the files which needs to be checked - * @param fileContentCheck proto object of FileContentCheck + * @param searchFile the file to check for prohibited content + * @param fileContentChecks contents to check for validity * @return whether the file content pattern is correct or not */ private fun checkProhibitedContent( repoRoot: File, - searchFiles: List, - fileContentCheck: FileContentCheck + searchFile: File, + fileContentChecks: Iterable ): Boolean { - val filePathRegex = fileContentCheck.filePathRegex.toRegex() - val prohibitedContentRegex = fileContentCheck.prohibitedContentRegex.toRegex() - - val matchedFiles = searchFiles.filter { file -> - val fileRelativePath = file.toRelativeString(repoRoot) - val isExempted = fileRelativePath in fileContentCheck.exemptedFileNameList - return@filter if (!isExempted && filePathRegex.matches(fileRelativePath)) { - file.useLines { lines -> - lines.foldIndexed(initial = false) { lineIndex, isFailing, lineContent -> - val matches = prohibitedContentRegex.containsMatchIn(lineContent) - if (matches) { - logProhibitedContentFailure( - lineIndex + 1, // Increment by 1 since line numbers begin at 1 rather than 0. - fileContentCheck.failureMessage, - fileRelativePath - ) - } - isFailing || matches + val lines = searchFile.readLines() + return fileContentChecks.fold(initial = false) { hasFailingFile, fileContentCheck -> + val fileRelativePath = searchFile.toRelativeString(repoRoot) + val fileFails = if (fileContentCheck.isFileAffectedByCheck(fileRelativePath)) { + val affectedLines = fileContentCheck.computeAffectedLines(lines) + if (affectedLines.isNotEmpty()) { + affectedLines.forEach { lineIndex -> + logProhibitedContentFailure( + lineIndex + 1, // Increment by 1 since line numbers begin at 1 rather than 0. + fileContentCheck.failureMessage, + fileRelativePath + ) } } + affectedLines.isNotEmpty() } else false + return@fold hasFailingFile || fileFails } - return matchedFiles.isNotEmpty() } /** @@ -208,3 +204,46 @@ private fun logProhibitedContentFailure( val failureMessage = "$filePath:$lineNumber: $errorToShow" println(failureMessage) } + +/** A matchable version of [FileContentCheck]. */ +private data class MatchableFileContentCheck( + val filePathRegex: Regex, + val prohibitedContentRegex: Regex, + val failureMessage: String, + val exemptedFileNames: List, + val exemptedFilePatterns: List +) { + /** + * Returns whether the relative file given by the specified path should be affected by this check + * (i.e. that it matches the inclusion pattern and is not explicitly or implicitly excluded). + */ + fun isFileAffectedByCheck(relativePath: String): Boolean = + filePathRegex.matches(relativePath) && !isFileExempted(relativePath) + + /** + * Returns the list of line indexes which contain prohibited content per this check (given an + * iterable of lines). Note that the returned indexes are based on the iteration order of the + * provided iterable. + */ + fun computeAffectedLines(lines: Iterable): List { + return lines.withIndex().filter { (_, line) -> + prohibitedContentRegex.containsMatchIn(line) + }.map { (index, ) -> index } + } + + private fun isFileExempted(relativePath: String): Boolean = + relativePath in exemptedFileNames || exemptedFilePatterns.any { it.matches(relativePath) } + + companion object { + /** Returns a new [MatchableFileContentCheck] based on the specified [FileContentCheck]. */ + fun createFrom(fileContentCheck: FileContentCheck): MatchableFileContentCheck { + return MatchableFileContentCheck( + filePathRegex = fileContentCheck.filePathRegex.toRegex(), + prohibitedContentRegex = fileContentCheck.prohibitedContentRegex.toRegex(), + failureMessage = fileContentCheck.failureMessage, + exemptedFileNames = fileContentCheck.exemptedFileNameList, + exemptedFilePatterns = fileContentCheck.exemptedFilePatternsList.map { it.toRegex() } + ) + } + } +} diff --git a/scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt b/scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt index e6f8c23870a..01515fd4387 100644 --- a/scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt +++ b/scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt @@ -828,6 +828,30 @@ class RegexPatternValidationCheckTest { assertThat(outContent.toString().trim()).isEqualTo(REGEX_CHECK_PASSED_OUTPUT_INDICATOR) } + @Test + fun testFileContent_translatableString_inPrimaryStringsFile_fileContentIsCorrect() { + val prohibitedContent = "Translatable" + tempFolder.newFolder("testfiles", "app", "src", "main", "res", "values") + val stringFilePath = "app/src/main/res/values/strings.xml" + tempFolder.newFile("testfiles/$stringFilePath").writeText(prohibitedContent) + + runScript() + + assertThat(outContent.toString().trim()).isEqualTo(REGEX_CHECK_PASSED_OUTPUT_INDICATOR) + } + + @Test + fun testFileContent_translatableString_inTranslatedPrimaryStringsFile_fileContentIsCorrect() { + val prohibitedContent = "Translatable" + tempFolder.newFolder("testfiles", "app", "src", "main", "res", "values-ar") + val stringFilePath = "app/src/main/res/values-ar/strings.xml" + tempFolder.newFile("testfiles/$stringFilePath").writeText(prohibitedContent) + + runScript() + + assertThat(outContent.toString().trim()).isEqualTo(REGEX_CHECK_PASSED_OUTPUT_INDICATOR) + } + @Test fun testFilenameAndContent_useProhibitedFileName_useProhibitedFileContent_multipleFailures() { tempFolder.newFolder("testfiles", "data", "src", "main") From 99130f09b3aeb921845ef9fb9dbac435a0838b17 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Tue, 14 Sep 2021 13:00:32 -0700 Subject: [PATCH 09/13] Lint-ish fix. --- .../oppia/android/scripts/regex/RegexPatternValidationCheck.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 de61643c2b9..464251b2689 100644 --- a/scripts/src/java/org/oppia/android/scripts/regex/RegexPatternValidationCheck.kt +++ b/scripts/src/java/org/oppia/android/scripts/regex/RegexPatternValidationCheck.kt @@ -228,7 +228,7 @@ private data class MatchableFileContentCheck( fun computeAffectedLines(lines: Iterable): List { return lines.withIndex().filter { (_, line) -> prohibitedContentRegex.containsMatchIn(line) - }.map { (index, ) -> index } + }.map { (index, _) -> index } } private fun isFileExempted(relativePath: String): Boolean = From 4a266ba0d5ea061886fc605ae0dfa26a2d0266e9 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Tue, 14 Sep 2021 14:46:57 -0700 Subject: [PATCH 10/13] Add check for nested res subdirectories. --- ...lename_pattern_validation_checks.textproto | 6 +- .../regex/RegexPatternValidationCheckTest.kt | 69 +++++++++++++++++-- 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/scripts/assets/filename_pattern_validation_checks.textproto b/scripts/assets/filename_pattern_validation_checks.textproto index fc6cad22b44..9307ed6f21d 100644 --- a/scripts/assets/filename_pattern_validation_checks.textproto +++ b/scripts/assets/filename_pattern_validation_checks.textproto @@ -1,4 +1,8 @@ filename_checks { prohibited_filename_regex: "^((?!(app|testing)).)+/src/main/.+?Activity.kt" - failure_message: "Activities cannot be placed outside the app or testing module" + failure_message: "Activities cannot be placed outside the app or testing module." +} +filename_checks { + prohibited_filename_regex: "^.+?/res/([^/]+/){2,}[^/]+$" + failure_message: "Only one level of subdirectories under res/ should be maintained (further subdirectories aren't supported by the project configuration)." } diff --git a/scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt b/scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt index 01515fd4387..675ebe06f53 100644 --- a/scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt +++ b/scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt @@ -17,13 +17,18 @@ class RegexPatternValidationCheckTest { private val originalOut: PrintStream = System.out private val REGEX_CHECK_PASSED_OUTPUT_INDICATOR: String = "REGEX PATTERN CHECKS PASSED" private val REGEX_CHECK_FAILED_OUTPUT_INDICATOR: String = "REGEX PATTERN CHECKS FAILED" + private val activitiesPlacementErrorMessage = + "Activities cannot be placed outside the app or testing module." + private val nestedResourceSubdirectoryErrorMessage = + "Only one level of subdirectories under res/ should be maintained (further subdirectories " + + "aren't supported by the project configuration)." private val supportLibraryUsageErrorMessage = "AndroidX should be used instead of the support library" private val coroutineWorkerUsageErrorMessage = "For stable tests, prefer using ListenableWorker with an Oppia-managed dispatcher." private val settableFutureUsageErrorMessage = - "SettableFuture should only be used in pre-approved locations since it's easy to potentially" + - " mess up & lead to a hanging ListenableFuture." + "SettableFuture should only be used in pre-approved locations since it's easy to potentially " + + "mess up & lead to a hanging ListenableFuture." private val androidGravityLeftErrorMessage = "Use android:gravity=\"start\", instead, for proper RTL support" private val androidGravityRightErrorMessage = @@ -47,8 +52,8 @@ class RegexPatternValidationCheckTest { private val androidTouchAnchorSideRightErrorMessage = "Use motion:touchAnchorSide=\"end\", instead, for proper RTL support" private val oppiaCantBeTranslatedErrorMessage = - "Oppia should never used directly in a string (since it shouldn't be translated). Instead," + - " use a parameter & insert the string retrieved from app_name." + "Oppia should never used directly in a string (since it shouldn't be translated). Instead, " + + "use a parameter & insert the string retrieved from app_name." private val untranslatableStringsGoInSpecificFileErrorMessage = "Untranslatable strings should go in untranslated_strings.xml, instead." private val translatableStringsGoInMainFileErrorMessage = @@ -104,8 +109,58 @@ class RegexPatternValidationCheckTest { assertThat(exception).hasMessageThat().contains(REGEX_CHECK_FAILED_OUTPUT_INDICATOR) assertThat(outContent.toString().trim()).isEqualTo( """ - File name/path violation: Activities cannot be placed outside the app or testing module + File name/path violation: $activitiesPlacementErrorMessage - data/src/main/TestActivity.kt + + $wikiReferenceNote + """.trimIndent() + ) + } + + @Test + fun testFileNamePattern_appResources_stringsFile_fileNamePatternIsCorrect() { + tempFolder.newFolder("testfiles", "app", "src", "main", "res", "values") + tempFolder.newFile("testfiles/app/src/main/res/values/strings.xml") + + runScript() + + assertThat(outContent.toString().trim()).isEqualTo(REGEX_CHECK_PASSED_OUTPUT_INDICATOR) + } + + @Test + fun testFileNamePattern_appResources_subValuesDir_stringsFile_fileNamePatternIsNotCorrect() { + tempFolder.newFolder("testfiles", "app", "src", "main", "res", "values", "subdir") + tempFolder.newFile("testfiles/app/src/main/res/values/subdir/strings.xml") + + val exception = assertThrows(Exception::class) { + runScript() + } + + assertThat(exception).hasMessageThat().contains(REGEX_CHECK_FAILED_OUTPUT_INDICATOR) + assertThat(outContent.toString().trim()).isEqualTo( + """ + File name/path violation: $nestedResourceSubdirectoryErrorMessage + - app/src/main/res/values/subdir/strings.xml + + $wikiReferenceNote + """.trimIndent() + ) + } + + @Test + fun testFileNamePattern_domainResources_subValuesDir_stringsFile_fileNamePatternIsNotCorrect() { + tempFolder.newFolder("testfiles", "domain", "src", "main", "res", "drawable", "subdir") + tempFolder.newFile("testfiles/domain/src/main/res/drawable/subdir/example.png") + + val exception = assertThrows(Exception::class) { + runScript() + } + + assertThat(exception).hasMessageThat().contains(REGEX_CHECK_FAILED_OUTPUT_INDICATOR) + assertThat(outContent.toString().trim()).isEqualTo( + """ + File name/path violation: $nestedResourceSubdirectoryErrorMessage + - domain/src/main/res/drawable/subdir/example.png $wikiReferenceNote """.trimIndent() @@ -866,10 +921,10 @@ class RegexPatternValidationCheckTest { assertThat(exception).hasMessageThat().contains(REGEX_CHECK_FAILED_OUTPUT_INDICATOR) assertThat(outContent.toString().trim()).isEqualTo( """ - File name/path violation: Activities cannot be placed outside the app or testing module + File name/path violation: $activitiesPlacementErrorMessage - data/src/main/TestActivity.kt - data/src/main/TestActivity.kt:1: AndroidX should be used instead of the support library + data/src/main/TestActivity.kt:1: $supportLibraryUsageErrorMessage $wikiReferenceNote """.trimIndent() ) From 2d47a87b9020b95355e86e96254b1ad68f3843b2 Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Tue, 14 Sep 2021 16:16:45 -0700 Subject: [PATCH 11/13] Fix #3786: Fix regex checks for translated strings (#3787) * Fix regex checks for translated strings. Also, performance improvements for the regex check. * Lint-ish fix. * Add check for nested res subdirectories. --- .../file_content_validation_checks.textproto | 2 +- ...lename_pattern_validation_checks.textproto | 6 +- .../file_content_validation_checks.proto | 3 + .../regex/RegexPatternValidationCheck.kt | 103 ++++++++++++------ .../regex/RegexPatternValidationCheckTest.kt | 93 ++++++++++++++-- 5 files changed, 166 insertions(+), 41 deletions(-) diff --git a/scripts/assets/file_content_validation_checks.textproto b/scripts/assets/file_content_validation_checks.textproto index 27552f874d7..6399ace7b2a 100644 --- a/scripts/assets/file_content_validation_checks.textproto +++ b/scripts/assets/file_content_validation_checks.textproto @@ -100,5 +100,5 @@ 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" } diff --git a/scripts/assets/filename_pattern_validation_checks.textproto b/scripts/assets/filename_pattern_validation_checks.textproto index fc6cad22b44..9307ed6f21d 100644 --- a/scripts/assets/filename_pattern_validation_checks.textproto +++ b/scripts/assets/filename_pattern_validation_checks.textproto @@ -1,4 +1,8 @@ filename_checks { prohibited_filename_regex: "^((?!(app|testing)).)+/src/main/.+?Activity.kt" - failure_message: "Activities cannot be placed outside the app or testing module" + failure_message: "Activities cannot be placed outside the app or testing module." +} +filename_checks { + prohibited_filename_regex: "^.+?/res/([^/]+/){2,}[^/]+$" + failure_message: "Only one level of subdirectories under res/ should be maintained (further subdirectories aren't supported by the project configuration)." } 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..464251b2689 100644 --- a/scripts/src/java/org/oppia/android/scripts/regex/RegexPatternValidationCheck.kt +++ b/scripts/src/java/org/oppia/android/scripts/regex/RegexPatternValidationCheck.kt @@ -32,24 +32,25 @@ fun main(vararg args: String) { // Check if the repo has any filename failure. val hasFilenameCheckFailure = retrieveFilenameChecks() - .fold(initial = false) { isFailing, filenameCheck -> - val checkFailed = checkProhibitedFileNamePattern( + .fold(initial = false) { hasFailingFile, filenameCheck -> + val fileFails = checkProhibitedFileNamePattern( repoRoot, searchFiles, filenameCheck, ) - isFailing || checkFailed + return@fold hasFailingFile || fileFails } // Check if the repo has any file content failure. - val hasFileContentCheckFailure = retrieveFileContentChecks() - .fold(initial = false) { isFailing, fileContentCheck -> - val checkFailed = checkProhibitedContent( + val contentChecks = retrieveFileContentChecks().map { MatchableFileContentCheck.createFrom(it) } + val hasFileContentCheckFailure = + searchFiles.fold(initial = false) { hasFailingFile, searchFile -> + val fileFails = checkProhibitedContent( repoRoot, - searchFiles, - fileContentCheck + searchFile, + contentChecks ) - isFailing || checkFailed + return@fold hasFailingFile || fileFails } if (hasFilenameCheckFailure || hasFileContentCheckFailure) { @@ -138,38 +139,33 @@ private fun checkProhibitedFileNamePattern( * Checks for a prohibited file content. * * @param repoRoot the root directory of the repo - * @param searchFiles a list of all the files which needs to be checked - * @param fileContentCheck proto object of FileContentCheck + * @param searchFile the file to check for prohibited content + * @param fileContentChecks contents to check for validity * @return whether the file content pattern is correct or not */ private fun checkProhibitedContent( repoRoot: File, - searchFiles: List, - fileContentCheck: FileContentCheck + searchFile: File, + fileContentChecks: Iterable ): Boolean { - val filePathRegex = fileContentCheck.filePathRegex.toRegex() - val prohibitedContentRegex = fileContentCheck.prohibitedContentRegex.toRegex() - - val matchedFiles = searchFiles.filter { file -> - val fileRelativePath = file.toRelativeString(repoRoot) - val isExempted = fileRelativePath in fileContentCheck.exemptedFileNameList - return@filter if (!isExempted && filePathRegex.matches(fileRelativePath)) { - file.useLines { lines -> - lines.foldIndexed(initial = false) { lineIndex, isFailing, lineContent -> - val matches = prohibitedContentRegex.containsMatchIn(lineContent) - if (matches) { - logProhibitedContentFailure( - lineIndex + 1, // Increment by 1 since line numbers begin at 1 rather than 0. - fileContentCheck.failureMessage, - fileRelativePath - ) - } - isFailing || matches + val lines = searchFile.readLines() + return fileContentChecks.fold(initial = false) { hasFailingFile, fileContentCheck -> + val fileRelativePath = searchFile.toRelativeString(repoRoot) + val fileFails = if (fileContentCheck.isFileAffectedByCheck(fileRelativePath)) { + val affectedLines = fileContentCheck.computeAffectedLines(lines) + if (affectedLines.isNotEmpty()) { + affectedLines.forEach { lineIndex -> + logProhibitedContentFailure( + lineIndex + 1, // Increment by 1 since line numbers begin at 1 rather than 0. + fileContentCheck.failureMessage, + fileRelativePath + ) } } + affectedLines.isNotEmpty() } else false + return@fold hasFailingFile || fileFails } - return matchedFiles.isNotEmpty() } /** @@ -208,3 +204,46 @@ private fun logProhibitedContentFailure( val failureMessage = "$filePath:$lineNumber: $errorToShow" println(failureMessage) } + +/** A matchable version of [FileContentCheck]. */ +private data class MatchableFileContentCheck( + val filePathRegex: Regex, + val prohibitedContentRegex: Regex, + val failureMessage: String, + val exemptedFileNames: List, + val exemptedFilePatterns: List +) { + /** + * Returns whether the relative file given by the specified path should be affected by this check + * (i.e. that it matches the inclusion pattern and is not explicitly or implicitly excluded). + */ + fun isFileAffectedByCheck(relativePath: String): Boolean = + filePathRegex.matches(relativePath) && !isFileExempted(relativePath) + + /** + * Returns the list of line indexes which contain prohibited content per this check (given an + * iterable of lines). Note that the returned indexes are based on the iteration order of the + * provided iterable. + */ + fun computeAffectedLines(lines: Iterable): List { + return lines.withIndex().filter { (_, line) -> + prohibitedContentRegex.containsMatchIn(line) + }.map { (index, _) -> index } + } + + private fun isFileExempted(relativePath: String): Boolean = + relativePath in exemptedFileNames || exemptedFilePatterns.any { it.matches(relativePath) } + + companion object { + /** Returns a new [MatchableFileContentCheck] based on the specified [FileContentCheck]. */ + fun createFrom(fileContentCheck: FileContentCheck): MatchableFileContentCheck { + return MatchableFileContentCheck( + filePathRegex = fileContentCheck.filePathRegex.toRegex(), + prohibitedContentRegex = fileContentCheck.prohibitedContentRegex.toRegex(), + failureMessage = fileContentCheck.failureMessage, + exemptedFileNames = fileContentCheck.exemptedFileNameList, + exemptedFilePatterns = fileContentCheck.exemptedFilePatternsList.map { it.toRegex() } + ) + } + } +} diff --git a/scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt b/scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt index e6f8c23870a..675ebe06f53 100644 --- a/scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt +++ b/scripts/src/javatests/org/oppia/android/scripts/regex/RegexPatternValidationCheckTest.kt @@ -17,13 +17,18 @@ class RegexPatternValidationCheckTest { private val originalOut: PrintStream = System.out private val REGEX_CHECK_PASSED_OUTPUT_INDICATOR: String = "REGEX PATTERN CHECKS PASSED" private val REGEX_CHECK_FAILED_OUTPUT_INDICATOR: String = "REGEX PATTERN CHECKS FAILED" + private val activitiesPlacementErrorMessage = + "Activities cannot be placed outside the app or testing module." + private val nestedResourceSubdirectoryErrorMessage = + "Only one level of subdirectories under res/ should be maintained (further subdirectories " + + "aren't supported by the project configuration)." private val supportLibraryUsageErrorMessage = "AndroidX should be used instead of the support library" private val coroutineWorkerUsageErrorMessage = "For stable tests, prefer using ListenableWorker with an Oppia-managed dispatcher." private val settableFutureUsageErrorMessage = - "SettableFuture should only be used in pre-approved locations since it's easy to potentially" + - " mess up & lead to a hanging ListenableFuture." + "SettableFuture should only be used in pre-approved locations since it's easy to potentially " + + "mess up & lead to a hanging ListenableFuture." private val androidGravityLeftErrorMessage = "Use android:gravity=\"start\", instead, for proper RTL support" private val androidGravityRightErrorMessage = @@ -47,8 +52,8 @@ class RegexPatternValidationCheckTest { private val androidTouchAnchorSideRightErrorMessage = "Use motion:touchAnchorSide=\"end\", instead, for proper RTL support" private val oppiaCantBeTranslatedErrorMessage = - "Oppia should never used directly in a string (since it shouldn't be translated). Instead," + - " use a parameter & insert the string retrieved from app_name." + "Oppia should never used directly in a string (since it shouldn't be translated). Instead, " + + "use a parameter & insert the string retrieved from app_name." private val untranslatableStringsGoInSpecificFileErrorMessage = "Untranslatable strings should go in untranslated_strings.xml, instead." private val translatableStringsGoInMainFileErrorMessage = @@ -104,8 +109,58 @@ class RegexPatternValidationCheckTest { assertThat(exception).hasMessageThat().contains(REGEX_CHECK_FAILED_OUTPUT_INDICATOR) assertThat(outContent.toString().trim()).isEqualTo( """ - File name/path violation: Activities cannot be placed outside the app or testing module + File name/path violation: $activitiesPlacementErrorMessage - data/src/main/TestActivity.kt + + $wikiReferenceNote + """.trimIndent() + ) + } + + @Test + fun testFileNamePattern_appResources_stringsFile_fileNamePatternIsCorrect() { + tempFolder.newFolder("testfiles", "app", "src", "main", "res", "values") + tempFolder.newFile("testfiles/app/src/main/res/values/strings.xml") + + runScript() + + assertThat(outContent.toString().trim()).isEqualTo(REGEX_CHECK_PASSED_OUTPUT_INDICATOR) + } + + @Test + fun testFileNamePattern_appResources_subValuesDir_stringsFile_fileNamePatternIsNotCorrect() { + tempFolder.newFolder("testfiles", "app", "src", "main", "res", "values", "subdir") + tempFolder.newFile("testfiles/app/src/main/res/values/subdir/strings.xml") + + val exception = assertThrows(Exception::class) { + runScript() + } + + assertThat(exception).hasMessageThat().contains(REGEX_CHECK_FAILED_OUTPUT_INDICATOR) + assertThat(outContent.toString().trim()).isEqualTo( + """ + File name/path violation: $nestedResourceSubdirectoryErrorMessage + - app/src/main/res/values/subdir/strings.xml + + $wikiReferenceNote + """.trimIndent() + ) + } + + @Test + fun testFileNamePattern_domainResources_subValuesDir_stringsFile_fileNamePatternIsNotCorrect() { + tempFolder.newFolder("testfiles", "domain", "src", "main", "res", "drawable", "subdir") + tempFolder.newFile("testfiles/domain/src/main/res/drawable/subdir/example.png") + + val exception = assertThrows(Exception::class) { + runScript() + } + + assertThat(exception).hasMessageThat().contains(REGEX_CHECK_FAILED_OUTPUT_INDICATOR) + assertThat(outContent.toString().trim()).isEqualTo( + """ + File name/path violation: $nestedResourceSubdirectoryErrorMessage + - domain/src/main/res/drawable/subdir/example.png $wikiReferenceNote """.trimIndent() @@ -828,6 +883,30 @@ class RegexPatternValidationCheckTest { assertThat(outContent.toString().trim()).isEqualTo(REGEX_CHECK_PASSED_OUTPUT_INDICATOR) } + @Test + fun testFileContent_translatableString_inPrimaryStringsFile_fileContentIsCorrect() { + val prohibitedContent = "Translatable" + tempFolder.newFolder("testfiles", "app", "src", "main", "res", "values") + val stringFilePath = "app/src/main/res/values/strings.xml" + tempFolder.newFile("testfiles/$stringFilePath").writeText(prohibitedContent) + + runScript() + + assertThat(outContent.toString().trim()).isEqualTo(REGEX_CHECK_PASSED_OUTPUT_INDICATOR) + } + + @Test + fun testFileContent_translatableString_inTranslatedPrimaryStringsFile_fileContentIsCorrect() { + val prohibitedContent = "Translatable" + tempFolder.newFolder("testfiles", "app", "src", "main", "res", "values-ar") + val stringFilePath = "app/src/main/res/values-ar/strings.xml" + tempFolder.newFile("testfiles/$stringFilePath").writeText(prohibitedContent) + + runScript() + + assertThat(outContent.toString().trim()).isEqualTo(REGEX_CHECK_PASSED_OUTPUT_INDICATOR) + } + @Test fun testFilenameAndContent_useProhibitedFileName_useProhibitedFileContent_multipleFailures() { tempFolder.newFolder("testfiles", "data", "src", "main") @@ -842,10 +921,10 @@ class RegexPatternValidationCheckTest { assertThat(exception).hasMessageThat().contains(REGEX_CHECK_FAILED_OUTPUT_INDICATOR) assertThat(outContent.toString().trim()).isEqualTo( """ - File name/path violation: Activities cannot be placed outside the app or testing module + File name/path violation: $activitiesPlacementErrorMessage - data/src/main/TestActivity.kt - data/src/main/TestActivity.kt:1: AndroidX should be used instead of the support library + data/src/main/TestActivity.kt:1: $supportLibraryUsageErrorMessage $wikiReferenceNote """.trimIndent() ) From 908d57b2b14cd136c2f9dbba5f35b927d7dcac41 Mon Sep 17 00:00:00 2001 From: "translatewiki.net" Date: Wed, 15 Sep 2021 08:40:25 +0300 Subject: [PATCH 12/13] Localisation updates from https://translatewiki.net. (#3687) Co-authored-by: Ben Henning --- app/src/main/res/values-ar/strings.xml | 416 +++++++++++++++++++ app/src/main/res/values-pt-rBR/strings.xml | 440 +++++++++++++++++++++ 2 files changed, 856 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..7c8348a709f --- /dev/null +++ b/app/src/main/res/values-ar/strings.xml @@ -0,0 +1,416 @@ + + + + أعلى قائمة التنقل + الصفحة الرئيسية + خيارات + التنزيلات + مساعدة + مشغل رحلة الاستكشاف + المساعدة + تغيير الملف الشخصي + خيارات المطورين + أدوات تحكم المشرف + فتح قائمة التنقل + غلق قائمة التنقل + تشغيل الصوت + إيقاف الصوت مؤقتًا + موافق + إلغاء + لغة الصوت + غير متصل بالإنترنت + من فضلك تأكد من وجود اتصال بالإنترنت عن طريق شبكة الوايفاي أو بيانات الهاتف، ثم حاول مرة أخرى. + حسنًا + موافق + إلغاء + متصل بالإنترنت عن طريق بيانات الهاتف + تشغيل الصوت عبر الإنترنت قد يستخدم الكثير من بيانات الهاتف. + لا تُظهِر هذه الرسالة مُجددًا + بطاقة المفهوم + الضغط هنا سوف يغلق بطاقة المفهوم + بطاقة المراجعة + هل تريد المغادرة إلى صفحة الموضوع؟ + لن يتم حفظ تقدمك. + مغادرة + إلغاء + هل تريد المغادرة إلى صفحة الموضوع؟ + لن يتم حفظ تقدمك. + إلغاء + مغادرة + تم الوصول إلى الحد الأقصى من سعة التخزين + سوف يتم حذف تقدمك في درس %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 أرقام. + الحقول المعلمة بـ* مطلوبة. + صورة الملف الشخصي الحالية. + تعديل صورة الملف الشخصي + مرحبًا بكم في %s + تعلّم أي شيءٍ تريده بطريقة فعّالة وممتعة. + أضف مستخدمين إلى حسابك. + شارك الخبرة وأنشئ حتى 10 ملفات شخصية. + تنزيل لحالة عدم وجود الإنترنت. + استمرّ في تعلّم دروسك بدون اتصال بالإنترنت. + استمتع! + استمتع بمغامراتك التعليمية مع دروسنا الفعّالة المجانية. + تخطي + التالي + ابدأ + شاشة العرض %d من %d + أهلًا، %s! + من فضلك أدخل رقم التعريف الشخصي الخاص بالمشرف. + من فضلك أدخل رقم التعريف الشخصي الخاص بك. + نسيت رقم التعريف الشخصي الخاص بي. + رقم تعريف شخصي غير صحيح. + عرض + إخفاء + إغلاق + تم تغيير رقم التعريف الشخصي بنجاح + نسيت رقم التعريف الشخصي؟ + لإعادة ضبط رقم التعريف الشخصي PIN الخاص بك، إحذف تطبيق %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 + رخص حقوق النسخ + عارض رخصة حقوق النسخ + انتقل مرة أخرى إلى %s + قائمة تبعيات الطرف الثالث + قائمة تراخيص حقوق النشر + استئناف الدرس + تابع + ابدأ من جديد + صباح الخير + مساء الخير + مساء الخير، + كيف يمكنني إنشاء ملف تعريف(حساب) جديد؟ + كيف يمكنني حذف ملف التعريف(حساب)؟ + كيف يمكنني تغيير بريدي الإلكتروني / رقم هاتفي؟ + ما هي %s؟ + من هو المشرف؟ + لماذا لا يتم تحميل مشغل الاستكشاف؟ + لماذا لا يتم تشغيل الصوت الخاص بي؟ + كيف يمكنني تنزيل موضوع؟ + لا أجد سؤالي هنا. ماذا الان؟ + <p>إذا كانت هذه هي المرة الأولى التي تنشئ فيها ملفًا شخصيًا وليس لديك رقم تعريف شخصي: </p> \n<p> 1. من منتقي الملف الشخصي ، اضغط على\n<strong> قم بإعداد ملفات تعريف متعددة</strong>\n</p>\n<p> 2. قم بإنشاء رقم تعريف شخصي و\n<strong>احفظ</strong>\n</p> \n<p> 3. املأ جميع البيانات للملف الشخصي.</p> \n<ol> \n<li>(اختياري) قم بتحميل صورة.</li> \n<li>إدخال اسم.</li> \n<li>(اختياري) قم بتعيين رقم تعريف شخصي مكون من 3 أرقام.</li> \n</ol> \n<p> 4. اضغط\n<strong>إنشاء</strong> . تمت إضافة هذا الملف الشخصي إلى منتقي ملف التعريف الخاص بك!\n<br/> \n<br/> إذا قمت بإنشاء ملف تعريف من قبل ولديك رقم تعريف شخصي:\n</p>\n<p> 1. من منتقي الملف الشخصي ، اضغط على\n<strong>إضافة الملف الشخصي</strong>\n</p> \n<p> 2. أدخل رقم التعريف الشخصي الخاص بك وانقر فوق\n<strong>إرسال</strong>\n</p> \n<p>3. املأ جميع الحقول للملف الشخصي.</p> \n<ol> \n<li>(اختياري) قم بتحميل صورة.</li> \n<li>إدخال اسم.</li> \n<li>(اختياري) قم بتعيين رقم تعريف شخصي مكون من 3 أرقام.</li> \n</ol> \n<p> 4. اضغط\n<strong>إنشاء</strong> . تمت إضافة هذا الملف الشخصي إلى منتقي ملف التعريف الخاص بك!\n<br/><br/> ملاحظة: فقط ال\n<u>مدير</u> قادر على إدارة الملفات الشخصية.\n</p> + <p>بمجرد حذف ملف التعريف:</p>\n<p><br></p> \n<p>\n<li>لا يمكن استعادة ملف التعريف.</li>\n</p>\n<p><li>سيتم حذف معلومات الملف الشخصي مثل الاسم والصور والتقدم بشكل دائم.</li></p>\n<p><br></p>\n<p>لحذف ملف تعريف (باستثناء<u>المسؤول</u></p>\n<p>1. من الصفحة الرئيسية للمسؤول ، اضغط على زر القائمة أعلى اليسار.</p>\n<p>2. اضغط على<strong>ضوابط المسؤول</strong></p>\n<p>3. اضغط على<strong>تحرير ملفات التعريف</strong></p>\n<p>4. اضغط على الملف الشخصي الذي ترغب في حذفه.</p>\n<p>5. في الجزء السفلي من الشاشة ، انقر فوق<strong>حذف الملف الشخصي</strong></p>\n<p>6. اضغط<strong>حذف</strong>لتأكيد الحذف.</p>\n<p><br></p>\n<p>ملاحظة:<u>المسؤول</u>فقط هو القادر على إدارة الملفات الشخصية.</p> + <p>لتغيير بريدك الإلكتروني / رقم هاتفك:</p> <p>1. من الصفحة الرئيسية للمشرف ، اضغط على زر القائمة أعلى اليسار.</p> <p>2. اضغط على <strong> عناصر تحكم المسؤول </ strong>.</p> <p>3. اضغط على <strong> تعديل الحساب </ strong>.</p> <p><br></p> <p>إذا كنت تريد تغيير بريدك الإلكتروني:</p> <p>4. أدخل بريدك الإلكتروني الجديد وانقر على <strong> حفظ </ strong>.</p> <p>5. يتم إرسال رابط التأكيد لتأكيد بريدك الإلكتروني الجديد. ستنتهي صلاحية الرابط بعد 24 ساعة ويجب النقر عليه لربطه بحسابك.</p> <p><br></p> <p>في حالة تغيير رقم هاتفك: </ p> <p> 4. أدخل رقم هاتفك الجديد وانقر على <strong> تحقق </ strong>.</p> <p>5. يتم إرسال رمز لتأكيد رقمك الجديد. ستنتهي صلاحية الرمز بعد 5 دقائق ويجب إدخاله في الشاشة الجديدة لربطه بحسابك.</p> + <p>%1$s \n<i>\"أو-بي-يا\"</i>(فنلندية) - \"للتعلم\"</p>\n<p><br></p><p>%1$sمهمتنا هي مساعدة أي شخص على تعلم أي شيء يريده بطريقة فعالة وممتعة.</p><p><br></p><p>من خلال إنشاء مجموعة من الدروس المجانية عالية الجودة والفعالة بشكل واضح بمساعدة معلمين من جميع أنحاء العالم ، تهدف %1$s إلى تزويد الطلاب بتعليم جيد - بغض النظر عن مكان وجودهم أو الموارد التقليدية التي يمكنهم الوصول إليها.</p><p><br></p><p>كطالب ، يمكنك أن تبدأ مغامرتك التعليمية من خلال تصفح الموضوعات المدرجة في الصفحة الرئيسية!</p> + <p>المشرف هو المستخدم الرئيسي الذي يدير ملفات التعريف والإعدادات لكل ملف تعريف على حسابه. هم على الأرجح والدك أو معلمك أو وصي عليك الذي أنشأ هذا الملف الشخصي لك.</p><p><br></p><p>يمكن للمسؤولين إدارة الملفات الشخصية وتعيين أرقام التعريف الشخصية وتغيير الإعدادات الأخرى ضمن حساباتهم. بناءً على ملف التعريف الخاص بك ، قد تكون أذونات المسؤول مطلوبة لبعض الميزات مثل تنزيل الموضوعات وتغيير رقم التعريف الشخصي وغير ذلك.</p><p><br></p><p>لمعرفة من هو المسؤول لديك ، انتقل إلى منتقي الملف الشخصي. الملف الشخصي الأول المدرج ولديه \"المسؤول\" مكتوب باسمه هو المسؤول.</p> + <p>إذا لم يتم تحميل مشغل الاستكشاف</p><p><br></p><p>تحقق لمعرفة ما إذا كان التطبيق محدثًا أم لا:</p><p> <ol> <li> انتقل إلى متجر Play وتأكد من تحديث التطبيق إلى أحدث إصدار </li> </ol> </p><p><br></p><p>تحقق من اتصالك بالإنترنت:</p><p> <li> إذا كان اتصالك بالإنترنت بطيئًا ، فحاول إعادة الاتصال بشبكة Wi-Fi أو الاتصال بشبكة أخرى. </li> </p><p><br></p><p>اطلب من المشرف التحقق من أجهزتهم واتصال الإنترنت:</p><p> <li> اطلب من المشرف استكشاف الأخطاء وإصلاحها باستخدام الخطوات المذكورة أعلاه </li> </p><p><br></p><p>أخبرنا إذا كنت لا تزال تواجه مشكلات في التحميل:</p><p> <li> أبلغ عن مشكلة عن طريق الاتصال بنا على admin@oppia.org. </li> </p> + <p>إذا لم يتم تشغيل الصوت الخاص بك</p><p><br></p>\n<p>تحقق لمعرفة ما إذا كان التطبيق محدثًا أم لا:</p>\n<p> <li>انتقل إلى متجر Play وتأكد من تحديث التطبيق إلى أحدث إصدار</li> </p><p><br></p>\n<p>تحقق من اتصالك بالإنترنت:</p><p> <li>إذا كان اتصالك بالإنترنت بطيئًا ، فحاول إعادة الاتصال بشبكة Wi-Fi أو الاتصال بشبكة أخرى. قد يتسبب الإنترنت البطيء في تحميل الصوت بشكل غير منتظم ، مما يجعل من الصعب تشغيله.</li> </p><p><br></p>\n<p>اطلب من المسؤول التحقق من أجهزتهم واتصال الإنترنت:</p><p> \n<li>اطلب من المسؤول استكشاف الأخطاء وإصلاحها باستخدام الخطوات المذكورة أعلاه</li> </p><p><br></p>\n<p>أخبرنا إذا كنت لا تزال تواجه مشكلات في التحميل:</p><p> <li>أبلغ عن مشكلة عن طريق الاتصال بنا على admin@oppia.org.</li></p> + <p>لتنزيل استكشاف:</p>\n<p>1. من الصفحة الرئيسية ، انقر فوق موضوع أو استكشاف.</p>\n<p>2. من صفحة الموضوع هذه ، انقر على علامة التبويب <strong> معلومات </ strong>.</p>\n<p>3. اضغط على <strong> تنزيل الموضوع </ strong>.</p>\n<p>4. اعتمادًا على إعدادات التطبيق ، قد تحتاج إلى موافقة المسؤول أو اتصال Wifi ثابتًا لإكمال التنزيل. إذا لزم الأمر ، بمجرد استيفاء هذه المتطلبات ، يتم تنزيل الموضوع على الجهاز ويمكن استخدامه في وضع عدم الاتصال بواسطة جميع ملفات التعريف. <p> + <p> إذا لم تتمكن من العثور على سؤالك أو كنت ترغب في الإبلاغ عن خطأ ، فاتصل بنا على admin@oppia.org. </p> + 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..33eda2d12fa --- /dev/null +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -0,0 +1,440 @@ + + + + Cabeçalho de navegação + Início + Opções + Meus Downloads + Ajuda + Reprodutor de Exploração + 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 + 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$s em %2$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 + Nome* + PIN de 3 Dígitos* + Confirmar PIN de 3 Dígitos* + 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 à %s! + 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 + Slide %d de %d + 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 seu PIN, desinstale %s e reinstale-o.\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. + Novo PIN de %1$s + 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 + *Requerido + 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 + Bom dia, + Boa tarde, + Boa noite, + Como posso criar um novo perfil? + Como posso deletar um perfil? + Como posso alterar meu e-mail/número de telefone? + O que é %s? + Quem é um administrador? + Por que a exploração não está carregando? + Por que meu áudio não está tocando? + Como faço o download de um tópico? + Não consigo encontrar minha pergunta aqui. E agora? + <p>Se é a sua primeira vez criando um perfil e você não tem um PIN:</p> <p> 1. No Seletor de Perfil, toque em <strong>Configurar Múltiplos Perfis</strong>. </p> <p> 2. Crie um PIN e <strong>Salvar</strong>. </p> <p> 3. Preencha todos os campos do perfil. </p> <ol> <li> (Opcional) Carregar uma foto. </li> <li> Insira um nome. </li> <li> (Opcional) Atribua um PIN de 3 dígitos. </li> </ol> <p> 4. Toque em <strong>Criar</strong>. Este perfil está adicionado ao seu Seletor de Perfil! <br/> <br/> Se você já criou um perfil antes e tem um PIN: </p> <p> 1. No Seletor de Perfil, toque em <strong>Adicionar Perfil</strong>. </p> <p> 2. Digite seu PIN e toque em <strong>Enviar</strong>. </p> <p> 3. Preencha todos os campos do perfil. </p> <ol> <li> (Opcional) Carregar uma foto. </li> <li> Insira um nome. </li> <li> (Opcional) Atribua um PIN de 3 dígitos. </li> </ol> <p> 4. Toque em <strong>Criar</strong>. Este perfil está adicionado ao seu Seletor de Perfil! <br/> <br/> Nota: Apenas o <u>Administrador</u> pode gerenciar perfis.</p> + <p>Depois que um perfil é deletado:</p> <p><br></p> <p> <li> O perfil não pode ser recuperado. </li> </p> <p> <li> As informações do perfil, como nome, fotos e progresso, serão excluídas permanentemente. </li> </p> <p><br></p> <p>Para deletar um perfil(excluindo o do <u>Administrador</u>):</p> <p>1. Na página inicial do administrador, toque no botão de menu no canto superior esquerdo.</p> <p>2. Toque em <strong>Controles do Administrador</strong>.</p> <p>3. Toque em <strong>Editar Perfis</strong>.</p> <p>4. Toque no perfil que deseja excluir.</p> <p>5. Na parte inferior da tela, toque em <strong>Exclusão de Perfil</strong>.</p> <p>6. Toque em <strong>Deletar</strong> para confirmar a exclusão.</p><p><br></p><p>Nota: Apenas o <u>Administrador</u> pode gerenciar perfis.</p> + <p>Para alterar seu e-mail/número de telefone:</p> <p>1. Na página inicial do administrador, toque no botão de menu no canto superior esquerdo.</p> <p>2. Toque em <strong>Controles do Administrador</strong>.</p> <p>3. Toque em <strong>Editar Conta</strong>.</p> <p><br></p> <p>Se você deseja alterar seu e-mail:</p> <p>4. Digite seu novo e-mail e toque em <strong>Salvar</strong>.</p> <p>5. Um link de confirmação será enviado para confirmar seu novo e-mail. O link irá expirar após 24 horas e deve ser clicado para ser associado à sua conta. </p> <p><br></p> <p>Se mudar seu número de telefone:</p> <p>4. Digite seu novo número de telefone e toque em <strong>Verificar</strong>.</p> <p>5. Um código será enviado para confirmar seu novo número. O código expira após 5 minutos e deve ser inserido na nova tela para ser associado à sua conta.</p> + <p>%1$s <i>\"O-pee-yah\"</i> (Finnish) - \"aprender\"</p><p><br></p><p>%1$s tem a missão de ajudar qualquer pessoa a aprender o que quiser de uma forma eficaz e agradável.</p><p><br></p><p>Ao criar um conjunto de aulas gratuitas, de alta qualidade e comprovadamente eficazes com a ajuda de educadores de todo o mundo, %1$s visa proporcionar aos alunos uma educação de qualidade - independentemente de onde estejam ou a quais recursos tradicionais tenham acesso.</p><p><br></p><p>Como estudante, você pode começar sua aventura de aprendizado navegando pelos tópicos listados na página inicial!</p> + <p>Um administrador é o usuário principal que gerencia perfis e configurações para cada perfil em sua conta. Provavelmente, eles são seus pais, professores ou responsáveis ​​que criaram este perfil para você.</p><p><br></p><p>Os administradores podem gerenciar perfis, atribuir PINs e alterar outras configurações em suas contas. Dependendo do seu perfil, as permissões de administrador podem ser necessárias para determinados recursos, como download de tópicos, alteração do PIN e muito mais. </p><p><br></p><p>Para ver quem é o seu administrador, vá para o Seletor de perfil. O primeiro perfil listado e com \"Administrador\" escrito em seu nome é o Administrador. </p> + <p>Se a exploração não estiver carregando</p><p><br></p><p>Verifique se o aplicativo está atualizado:</p><p> <ol> <li> Acesse a Play Store e certifique-se de que o aplicativo esteja atualizado com a versão mais recente </li> </ol> </p><p><br></p><p>Verifique sua conexão com a internet:</p><p> <li> Se sua conexão com a Internet estiver lenta, tente se reconectar à rede Wi-Fi ou conectar-se a uma rede diferente. </li> </p><p><br></p><p>Peça ao administrador para verificar o dispositivo e a conexão com a Internet:</p><p> <li> Peça ao administrador para solucionar o problema usando as etapas acima </li> </p><p><br></p><p>Informe-nos se você ainda tiver problemas com o carregamento:</p><p> <li> Relate um problema entrando em contato conosco em admin@oppia.org. </li> </p> + <p>Se o seu áudio não estiver tocando</p><p><br></p><p>Verifique se o aplicativo está atualizado:</p><p> <li> Acesse a Play Store e certifique-se de que o aplicativo esteja atualizado com a versão mais recente </li> </p><p><br></p><p>Verifique sua conexão com a internet:</p><p> <li> Se sua conexão com a Internet estiver lenta, tente se reconectar à rede Wi-Fi ou conectar-se a uma rede diferente. A Internet lenta pode fazer com que o áudio carregue irregularmente, dificultando a reprodução. </li> </p><p><br></p><p>Peça ao administrador para verificar o dispositivo e a conexão com a Internet:</p><p> <li> Peça ao administrador para solucionar o problema usando as etapas acima</li> </p><p><br></p><p>Informe-nos se você ainda tiver problemas com o carregamento:</p><p> <li> Relate um problema entrando em contato conosco em admin@oppia.org. </li></p> + <p>Para baixar uma Exploração</p><p>1. Na página inicial, toque em um Tópico ou Exploração.</p><p>2. Na Página do Tópico, toque em <strong>Info</strong> aba.</p><p>3. Toque em <strong>Baixar Tópico</strong>. </p><p>4. Dependendo das configurações do aplicativo, você pode precisar da aprovação do Administrador ou de uma conexão Wifi estável para concluir o download. Se necessário, uma vez que esses requisitos sejam satisfeitos, o Tópico foi baixado para o dispositivo e pode ser usado offline por todos os perfis. <p> + <p>Se você não consegue encontrar sua pergunta ou gostaria de relatar um problema, entre em contato conosco em admin@oppia.org.</p> + From 2ad1f4176e76bd99caa70d24b665ca1b746d5c8d Mon Sep 17 00:00:00 2001 From: Ben Henning Date: Tue, 14 Sep 2021 22:43:29 -0700 Subject: [PATCH 13/13] Attempt to delete strings to force history. --- app/src/main/res/values-ar/strings.xml | 382 ------------------- app/src/main/res/values-pt-rBR/strings.xml | 410 --------------------- 2 files changed, 792 deletions(-) delete mode 100644 app/src/main/res/values-ar/strings.xml delete 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 deleted file mode 100644 index d95a0f8bfb3..00000000000 --- a/app/src/main/res/values-ar/strings.xml +++ /dev/null @@ -1,382 +0,0 @@ - - - - أعلى قائمة التنقل - الصفحة الرئيسية - خيارات - التنزيلات - مساعدة - مشغل رحلة الاستكشاف - المساعدة - تغيير الملف الشخصي - خيارات المطورين - أدوات تحكم المشرف - فتح قائمة التنقل - غلق قائمة التنقل - تشغيل الصوت - إيقاف الصوت مؤقتًا - موافق - إلغاء - لغة الصوت - غير متصل بالإنترنت - من فضلك تأكد من وجود اتصال بالإنترنت عن طريق شبكة الوايفاي أو بيانات الهاتف، ثم حاول مرة أخرى. - حسنًا - موافق - إلغاء - متصل بالإنترنت عن طريق بيانات الهاتف - تشغيل الصوت عبر الإنترنت قد يستخدم الكثير من بيانات الهاتف. - لا تُظهِر هذه الرسالة مُجددًا - بطاقة المفهوم - الضغط هنا سوف يغلق بطاقة المفهوم - بطاقة المراجعة - هل تريد المغادرة إلى صفحة الموضوع؟ - لن يتم حفظ تقدمك. - مغادرة - إلغاء - هل تريد المغادرة إلى صفحة الموضوع؟ - لن يتم حفظ تقدمك. - إلغاء - مغادرة - تم الوصول إلى الحد الأقصى من سعة التخزين - سوف يتم حذف تقدمك في درس %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 deleted file mode 100644 index 221312ff17e..00000000000 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ /dev/null @@ -1,410 +0,0 @@ - - - - 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 -