diff --git a/.github/check_alpha_release.js b/.github/check_alpha_release.js index 332305a69137..30d732d3659b 100644 --- a/.github/check_alpha_release.js +++ b/.github/check_alpha_release.js @@ -3,12 +3,21 @@ const fs = require("node:fs"); module.exports = async function checkAlphaRelease({ github, context, core }) { + async function skipRelease(reason) { + core.info(`Skipping scheduled release: ${reason}`); + await github.rest.actions.cancelWorkflowRun({ + ...context.repo, + run_id: context.runId, + }); + // Cancellation is asynchronous. Keep this step alive so cancelWorkflowRun can kill it. + await keepProcessAlive(); + } + const metadata = JSON.parse( fs.readFileSync("AnkiDroid/build/outputs/release-metadata.json", "utf8"), ); if (metadata.releaseType !== "alpha") { - core.info("Skipping scheduled release: main is not on an alpha version."); - return false; + return await skipRelease("main is not on an alpha version."); } const releases = await github.paginate(github.rest.repos.listReleases, { @@ -22,18 +31,16 @@ module.exports = async function checkAlphaRelease({ github, context, core }) { release => !release.draft && today - Math.floor(Date.parse(release.published_at) / day) < 7, ); if (recentRelease) { - core.info( - `Skipping scheduled release: ${recentRelease.tag_name} was published fewer than seven UTC calendar days ago.`, + return await skipRelease( + `${recentRelease.tag_name} was published fewer than seven UTC calendar days ago.`, ); - return false; } const latestAlpha = releases .filter(release => !release.draft && /^v\d+\.\d+\.\d+alpha\d+$/.test(release.tag_name)) .sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at))[0]; if (!latestAlpha) { - core.info("Skipping scheduled release: no published alpha to compare against."); - return false; + return await skipRelease("no published alpha to compare against."); } const { data: comparison } = await github.rest.repos.compareCommitsWithBasehead({ @@ -41,10 +48,7 @@ module.exports = async function checkAlphaRelease({ github, context, core }) { basehead: `${latestAlpha.tag_name}...${context.sha}`, }); if (comparison.status !== "ahead" || comparison.ahead_by === 0) { - core.info( - `Skipping scheduled release: no new commits descending from ${latestAlpha.tag_name}.`, - ); - return false; + return await skipRelease(`no new commits descending from ${latestAlpha.tag_name}.`); } core.info( @@ -52,3 +56,9 @@ module.exports = async function checkAlphaRelease({ github, context, core }) { ); return true; }; + +async function keepProcessAlive() { + // Fail after five minutes if cancellation never arrives. + await new Promise(resolve => setTimeout(resolve, 5 * 60 * 1000)); + throw new Error("Timed out waiting for workflow cancellation."); +} diff --git a/.github/report_test_failures.py b/.github/report_test_failures.py index 718bcdbbfe3a..7acc7ed803a3 100644 --- a/.github/report_test_failures.py +++ b/.github/report_test_failures.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later -"""Report failures and skipped tests from JUnit XML using the standard library.""" +"""Report failures, skipped tests, and overview summary from JUnit XML using the standard library.""" import argparse import html @@ -12,6 +12,19 @@ import xml.etree.ElementTree as ET +TABLE_HEADER = ( + "## Test Results\n\n" + "| Suite | Result | Duration | Tests | Passed | Failed | Skipped |\n" + "| ----- | ------ | -------- | ----- | ------ | ------ | ------- |\n" +) + + +def format_duration(seconds): + minutes = int(seconds // 60) + rem_seconds = seconds % 60 + return f"{minutes:02d}m {rem_seconds:06.3f}s" + + def read_ignored_tests(log_file): ignored = set() failed = set() @@ -26,22 +39,61 @@ def read_ignored_tests(log_file): return ignored - failed -def main(results_dir, log_file=None, pattern="TEST-*.xml", title="Test failures"): +def get_report_duration(root): + suites = root.findall("testsuite") + if suites: + total = 0.0 + for s in suites: + try: + total += float(s.get("time", 0) or 0) + except (ValueError, TypeError): + pass + if total > 0.0: + return total + try: + root_time = float(root.get("time", 0) or 0) + if root_time > 0.0: + return root_time + except (ValueError, TypeError): + pass + total = 0.0 + for tc in root.iter("testcase"): + try: + total += float(tc.get("time", 0) or 0) + except (ValueError, TypeError): + pass + return total + + +def main(results_dir, log_file=None, pattern="TEST-*.xml", title="Test failures", suite=None): failures = [] skipped = [] ignored = read_ignored_tests(log_file) + total_duration = 0.0 + passed_count = 0 + failed_count = 0 + skipped_count = 0 + for report in sorted(results_dir.rglob(pattern)): try: root = ET.parse(report).getroot() except (ET.ParseError, OSError) as error: - failures.append(f"Could not read {report}: {error}") + if os.environ.get("GITHUB_ACTIONS") == "true": + print(f"::warning::Could not read {report}: {error}") + else: + print(f"Warning: Could not read {report}: {error}", file=sys.stderr) continue + total_duration += get_report_duration(root) + for test in root.iter("testcase"): + name = f"{test.get('classname', '')}.{test.get('name', '')}" + has_failure = False + has_skipped = False + for result in test: if result.tag not in ("failure", "error", "skipped"): continue - name = f"{test.get('classname', '')}.{test.get('name', '')}" details = "".join(result.itertext()).strip() message = result.get("message", "") # Android can report assumption violations as failures or errors. @@ -56,10 +108,30 @@ def main(results_dir, log_file=None, pattern="TEST-*.xml", title="Test failures" "org.junit.AssumptionViolatedException", "org.junit.internal.AssumptionViolatedException", ): + has_skipped = True skipped.append(f"{name}\n{details or result.get('type', 'Test skipped')}") else: + has_failure = True failures.append(f"{name}\n{details or result.get('type', 'Test failed')}") + if has_failure: + failed_count += 1 + elif has_skipped: + skipped_count += 1 + else: + passed_count += 1 + + total_tests = passed_count + failed_count + skipped_count + is_successful = failed_count == 0 and total_tests > 0 + result_text = "SUCCESS" if is_successful else "FAILURE" + elapsed = format_duration(total_duration) + + if suite: + print( + f"{suite}: {result_text} ({elapsed}) - " + f"{total_tests} tests: {passed_count} passed, {failed_count} failed, {skipped_count} skipped" + ) + for failure in failures: if os.environ.get("GITHUB_ACTIONS") == "true": # GitHub renders the escaped message as a multiline error in the log. @@ -79,19 +151,27 @@ def main(results_dir, log_file=None, pattern="TEST-*.xml", title="Test failures" if os.environ.get("GITHUB_ACTIONS") == "true": print("::endgroup::") - if (failures or skipped) and os.environ.get("GITHUB_STEP_SUMMARY"): - with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as summary: + if os.environ.get("GITHUB_STEP_SUMMARY"): + with open(os.environ["GITHUB_STEP_SUMMARY"], "a+", encoding="utf-8") as summary: + summary.seek(0) + existing_content = summary.read() + if suite: + if "## Test Results" not in existing_content: + summary.write(TABLE_HEADER) + summary.write(f"| {suite} | **{result_text}** | {elapsed} | {total_tests} | {passed_count} | {failed_count} | {skipped_count} |\n\n") + if failures: summary.write(f"### {title}\n\n") for failure in failures: summary.write(f"
{html.escape(failure)}
\n\n") if skipped: + skipped_title = f"Skipped tests ({len(skipped)})" summary.write(f"
{skipped_title}\n\n") for skip in skipped: summary.write(f"
{html.escape(skip)}
\n\n") summary.write("
\n\n") - return 1 if failures else 0 + return 1 if (failures or (suite and not is_successful)) else 0 if __name__ == "__main__": @@ -100,5 +180,6 @@ def main(results_dir, log_file=None, pattern="TEST-*.xml", title="Test failures" parser.add_argument("log_file", type=Path, nargs="?") parser.add_argument("--pattern", default="TEST-*.xml", help="Recursive report glob relative to results_dir") parser.add_argument("--title", default="Test failures", help="Failure heading in the job summary") + parser.add_argument("--suite", help="Suite name for markdown summary table") args = parser.parse_args() - sys.exit(main(args.results_dir, args.log_file, args.pattern, args.title)) + sys.exit(main(args.results_dir, args.log_file, args.pattern, args.title, args.suite)) diff --git a/.github/workflows/label.yml b/.github/workflows/label.yml index 2fd5dbe5b228..9fad62b93163 100644 --- a/.github/workflows/label.yml +++ b/.github/workflows/label.yml @@ -14,6 +14,7 @@ jobs: steps: - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: + retries: 3 script: | let removeLabelsList = [ "Pending Merge", @@ -42,6 +43,7 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, + request: { retries: 0 }, body: `Maintainers: Please [Sync Translations](https://github.com/ankidroid/Anki-Android/actions/workflows/sync_translations.yml) to produce a commit with only the automated changes from this PR. Read more about updating strings on the wiki, @@ -80,6 +82,7 @@ jobs: steps: - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: + retries: 3 script: | const I18N_FILES = [ "01-core", @@ -133,6 +136,7 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, + request: { retries: 0 }, body: `> [!IMPORTANT] > **Maintainers**: This PR contains https://github.com/ankidroid/Anki-Android/labels/Strings changes @@ -207,6 +211,7 @@ jobs: steps: - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: + retries: 3 script: | const creator = context.payload.sender.login const opts = github.rest.issues.listForRepo.endpoint.merge({ @@ -240,6 +245,7 @@ jobs: steps: - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: + retries: 3 script: | await github.rest.issues.addLabels({ issue_number: context.issue.number, diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7080bae85db7..0dc8bf3241e3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -27,6 +27,9 @@ concurrency: jobs: check_release: name: 'Check whether a release is due' + permissions: + contents: read + actions: write # Cancel this run when a scheduled publish is skipped. if: github.event_name == 'schedule' && github.repository == 'ankidroid/Anki-Android' && github.ref == 'refs/heads/main' timeout-minutes: 20 runs-on: ubuntu-latest diff --git a/.github/workflows/screenshot_comment.yml b/.github/workflows/screenshot_comment.yml index 1e994a187b84..a9c8698c0a81 100644 --- a/.github/workflows/screenshot_comment.yml +++ b/.github/workflows/screenshot_comment.yml @@ -64,7 +64,7 @@ jobs: name: Check if there are valid files shell: bash run: | - # Find Roborazzi diff PNGs in the downloaded artifact (in /). + # Find Roborazzi diff PNGs in the downloaded artifact (in //). mapfile -t files_to_add < <(find . -name "*_compare.png" -type f) if [ ${#files_to_add[@]} -gt 0 ]; then exist_valid_files="true" @@ -82,17 +82,18 @@ jobs: BASE_REF: ${{ github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.pull_requests[0].base.ref || github.event.repository.default_branch }} shell: bash run: | - # Find Roborazzi diff PNGs to mention in the comment (in /). + # Find Roborazzi diff PNGs to mention in the comment (in //). mapfile -t files < <(find . -name "*_compare.png" -type f | sort) total=${#files[@]} # Cap the full list so we remain within GitHub's comment limit max_details=100 - # Group by test class - the file's immediate parent directory. + # Group by module and test class, after the artifact's per-run directory. declare -A class_counts class_files for file in "${files[@]}"; do class_dir="${file%/*}" - class="${class_dir##*/}" + class="${class_dir#./screenshot-diff/}" + class="${class#*/}" class_counts[$class]=$(( ${class_counts[$class]:-0} + 1 )) class_files[$class]+="$(basename "$file")"$'\n' done diff --git a/.github/workflows/screenshot_compare.yml b/.github/workflows/screenshot_compare.yml index 0d9599f012d4..60595f5d2c13 100644 --- a/.github/workflows/screenshot_compare.yml +++ b/.github/workflows/screenshot_compare.yml @@ -57,13 +57,14 @@ jobs: gradle-version: wrapper - name: Warm Gradle Cache - # This makes sure we fetch gradle network resources with a retry + # Keep compilation here: robolectricSdkDownload only downloads SDK jars. + # Compiling resolves test dependencies within the retry (Issue 21682). uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 with: timeout_minutes: 10 retry_wait_seconds: 60 max_attempts: 3 - command: ./gradlew robolectricSdkDownload :AnkiDroid:compilePlayDebugUnitTestKotlin -Pscreenshot --daemon + command: ./gradlew robolectricSdkDownload compileScreenshotTests -Pscreenshot --daemon - name: Download base branch screenshots uses: dawidd6/action-download-artifact@eab87c9830c39eff17e5a6eadb20bfb4bc880477 # v27 @@ -72,11 +73,12 @@ jobs: name: screenshot workflow: screenshot_store.yml branch: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref || github.event.repository.default_branch }} + event: push - name: Compare screenshots id: compare-screenshot-test run: | - ./gradlew compareRoborazziPlayDebug -Pscreenshot --stacktrace + ./gradlew compareScreenshots -Pscreenshot --stacktrace - name: Stage screenshot diffs if: ${{ always() }} @@ -84,18 +86,15 @@ jobs: # Folder name inside the artifact (e.g. "screenshot-diff-pr-12345" on PRs, "screenshot-diff-main" on push) SUBDIR: ${{ github.event_name == 'pull_request' && format('screenshot-diff-pr-{0}', github.event.number) || format('screenshot-diff-{0}', github.ref_name) }} run: | - # Trim 'AnkiDroid/build/outputs/roborazzi' from the artifact - # Only keep the _compare images of failing cases: /diffs/*_compare.png moves to / + # Preserve module paths so identically named tests cannot overwrite one another. mkdir -p "screenshot-diff/$SUBDIR" - shopt -s nullglob - for diffs_dir in AnkiDroid/build/outputs/roborazzi/*/diffs; do - class_name="$(basename "$(dirname "$diffs_dir")")" - compares=("$diffs_dir"/*_compare.png) - if (( ${#compares[@]} )); then - mkdir -p "screenshot-diff/$SUBDIR/$class_name" - mv "${compares[@]}" "screenshot-diff/$SUBDIR/$class_name/" - fi - done + while IFS= read -r -d '' compare; do + module="${compare%%/build/reports/screenshot-diffs/*}" + relative="${compare#*/build/reports/screenshot-diffs/}" + destination="screenshot-diff/$SUBDIR/${module#./}/$relative" + mkdir -p "$(dirname "$destination")" + cp "$compare" "$destination" + done < <(find . -type f -path '*/build/reports/screenshot-diffs/*_compare.png' -print0) - name: Upload screenshot diffs uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -132,4 +131,4 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: pr # downloaded by screenshot_comment.yml - path: pr/ \ No newline at end of file + path: pr/ diff --git a/.github/workflows/screenshot_store.yml b/.github/workflows/screenshot_store.yml index dc53a73d5829..56781679b1b1 100644 --- a/.github/workflows/screenshot_store.yml +++ b/.github/workflows/screenshot_store.yml @@ -26,14 +26,18 @@ on: push: branches: - main + - dependency-updates # On PRs, only run this when structural changes occur, to be sure 'main' can still store baselines pull_request: paths: - '.github/workflows/screenshot_*.yml' - 'gradle/libs.versions.toml' - - 'AnkiDroid/build.gradle' - - 'AnkiDroid/build.gradle.kts' # future-proof + - '**/build.gradle' + - '**/build.gradle.kts' + - 'settings.gradle.kts' - '**/ScreenshotTest.kt' + - '**/ScreenshotTestCategory.kt' + - 'buildSrc/src/main/kotlin/ankidroid.plugins.screenshots.gradle.kts' - 'tools/compare-screenshot-test.sh' permissions: {} @@ -41,7 +45,7 @@ permissions: {} jobs: store-screenshot-test: # On PRs, this verifies the workflow runs on `main`, it's not a store - name: ${{ github.ref == 'refs/heads/main' && 'store' || 'test' }} + name: ${{ github.event_name == 'push' && 'store' || 'test' }} runs-on: ubuntu-latest timeout-minutes: 20 @@ -70,20 +74,21 @@ jobs: gradle-version: wrapper - name: Warm Gradle Cache - # This makes sure we fetch gradle network resources with a retry - # TODO: --rerun-tasks in 'Record screenshots' discards the compilation performed here + # Keep compilation here: robolectricSdkDownload only downloads SDK jars. + # Compiling resolves test dependencies within the retry (Issue 21682). + # --rerun-tasks repeats compilation but reuses downloaded dependencies. uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 with: timeout_minutes: 10 retry_wait_seconds: 60 max_attempts: 3 - command: ./gradlew robolectricSdkDownload :AnkiDroid:compilePlayDebugUnitTestKotlin -Pscreenshot --daemon + command: ./gradlew robolectricSdkDownload compileScreenshotTests -Pscreenshot --daemon - name: Record screenshots id: record-test run: | # Use --rerun-tasks to disable cache for test task - ./gradlew recordRoborazziPlayDebug -Pscreenshot --stacktrace --rerun-tasks + ./gradlew recordScreenshots -Pscreenshot --stacktrace --rerun-tasks - name: Upload screenshot baseline uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -110,4 +115,4 @@ jobs: name: screenshot-test-results path: | **/build/test-results - retention-days: 30 \ No newline at end of file + retention-days: 30 diff --git a/.github/workflows/tests_emulator.yml b/.github/workflows/tests_emulator.yml index a8e5dd5abd77..c6ed46ca5f78 100644 --- a/.github/workflows/tests_emulator.yml +++ b/.github/workflows/tests_emulator.yml @@ -68,13 +68,23 @@ jobs: // try different architectures, targets, delays etc let arch = ['x86_64']; let target = ['google_apis', 'google_apis_ps16k']; - // Keep API 37 and the 16 KB emulator off the per-change CI path. - // Scheduled and manual runs exercise both API levels and targets. + let include = []; + // Keep API 37, the 16 KB emulator and the tablet off the per-change CI path. + // Scheduled and manual runs exercise both API levels and targets, plus a tablet. if (context.eventName !== 'schedule' && context.eventName !== 'workflow_dispatch') { target = ['google_apis']; } else { // Keep the minor version: SDK 37 packages use android-37.0. apiLevel.push('37.0'); + include = iterationArray.map((iteration) => ({ + "iteration": iteration, + "api-level": 36, + "arch": "x86_64", + "target": "google_apis", + "first-boot-delay": 600, + "device": "tablet", + "profile": "pixel_tablet" + })); } let firstBootDelay = [600]; @@ -83,7 +93,9 @@ jobs: "api-level": apiLevel, "arch": arch, "target": target, - "first-boot-delay": firstBootDelay + "first-boot-delay": firstBootDelay, + "device": ["phone"], + "include": include } - name: Debug Output run: echo "${{ steps.build-matrix.outputs.result }}" @@ -91,7 +103,7 @@ jobs: # This uses the matrix generated from the matrix-prep stage # it will run unit tests on whatever OS combinations are desired emulator_test: - name: Android Emulator Test (${{ matrix.api-level }}, ${{ matrix.arch }}, ${{ matrix.target }}, ${{ matrix.first-boot-delay }}, ${{ matrix.iteration }}) + name: Android Emulator Test (${{ matrix.api-level }}, ${{ matrix.arch }}, ${{ matrix.target }}, ${{ matrix.first-boot-delay }}, ${{ matrix.iteration }}${{ matrix.device == 'tablet' && ', tablet' || '' }}) runs-on: ubuntu-latest needs: matrix_prep timeout-minutes: 75 @@ -152,7 +164,7 @@ jobs: # Cache only the per-change emulator: the 16 KB snapshot costs about 2.5 GiB. - name: AVD cache - if: matrix.target == 'google_apis' + if: matrix.target == 'google_apis' && matrix.device == 'phone' uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 id: avd-cache with: @@ -163,6 +175,9 @@ jobs: restore-keys: | avd-${{ matrix.api-level }}-${{ matrix.arch }}-${{matrix.target}}-v2 + - name: Pin Build Time + run: echo "ORG_GRADLE_PROJECT_buildTime=$(date +%s000)" >> "$GITHUB_ENV" + - name: Warm Gradle Cache # This makes sure we fetch gradle network resources with a retry uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 @@ -202,15 +217,16 @@ jobs: "${ANDROID_HOME:?}/system-images/android-${SDK_API_LEVEL:?}/${SDK_TARGET:?}/${SDK_ARCH:?}/.installer" - name: AVD Boot and Snapshot Creation - # Warm up the uncached 16 KB emulator on every run; otherwise only on main cache misses. + # Warm up the uncached 16 KB and tablet emulators on every run; otherwise only on main cache misses. # Comment the if out to generate snapshots on branch for performance testing - if: "${{ matrix.target == 'google_apis_ps16k' || (steps.avd-cache.outputs.cache-hit != 'true' && github.ref == 'refs/heads/main') }}" + if: "${{ matrix.target == 'google_apis_ps16k' || matrix.device == 'tablet' || (steps.avd-cache.outputs.cache-hit != 'true' && github.ref == 'refs/heads/main') }}" uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 with: api-level: ${{ matrix.api-level }} force-avd-creation: false target: ${{ matrix.target }} arch: ${{ matrix.arch }} + profile: ${{ matrix.profile }} emulator-options: ${{ env.EMULATOR_OPTIONS }} ram-size: ${{ env.EMULATOR_RAM_SIZE }} pre-emulator-launch-script: ${{ env.EMULATOR_PRE_LAUNCH_SCRIPT }} @@ -225,10 +241,10 @@ jobs: # This step is separate so pure install time may be calculated as a step - name: Emulator Snapshot After Firstboot Warmup - # Always warm up the 16 KB emulator. The default emulator only needs it on main cache misses. + # Always warm up the 16 KB and tablet emulators. The default emulator only needs it on main cache misses. # Switch the if statements via comment if generating snapshots for performance testing # if: matrix.first-boot-delay != '0' - if: "${{ matrix.target == 'google_apis_ps16k' || (steps.avd-cache.outputs.cache-hit != 'true' && github.ref == 'refs/heads/main') }}" + if: "${{ matrix.target == 'google_apis_ps16k' || matrix.device == 'tablet' || (steps.avd-cache.outputs.cache-hit != 'true' && github.ref == 'refs/heads/main') }}" env: FIRST_BOOT_DELAY: ${{ matrix.first-boot-delay }} uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 @@ -237,6 +253,7 @@ jobs: force-avd-creation: false target: ${{ matrix.target }} arch: ${{ matrix.arch }} + profile: ${{ matrix.profile }} emulator-options: ${{ env.EMULATOR_OPTIONS }} ram-size: ${{ env.EMULATOR_RAM_SIZE }} pre-emulator-launch-script: ${{ env.EMULATOR_PRE_LAUNCH_SCRIPT }} @@ -260,6 +277,7 @@ jobs: force-avd-creation: false target: ${{ matrix.target }} arch: ${{ matrix.arch }} + profile: ${{ matrix.profile }} emulator-options: -no-snapshot-save ${{ env.EMULATOR_OPTIONS }} ram-size: ${{ env.EMULATOR_RAM_SIZE }} pre-emulator-launch-script: ${{ env.EMULATOR_PRE_LAUNCH_SCRIPT }} @@ -274,11 +292,31 @@ jobs: bash .github/adb_retry.sh emu screenrecord start --time-limit 1800 video.webm sleep 5 bash .github/with_emulator_log.sh adb-log.txt python3 .github/run_emulator_tests.py - TEST_RELEASE_BUILD=false ./gradlew :baselineprofile:connectedBenchmarkBenchmarkAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.ichi2.anki.baselineprofile.ReleaseSmokeTest -Pandroid.testInstrumentationRunnerArguments.releaseSmokeTest=true --daemon - name: Report Emulator Test Results if: always() && hashFiles('AnkiDroid/build/outputs/androidTest-results/connected/**/TEST-*.xml') != '' - run: python3 .github/report_test_failures.py AnkiDroid/build/outputs/androidTest-results/connected adb-log.txt --title "Emulator test failures" + run: python3 .github/report_test_failures.py AnkiDroid/build/outputs/androidTest-results/connected adb-log.txt --title "Emulator test failures" --suite ":AnkiDroid:connectedPlayReleaseAndroidTest" + + - name: Build Release Smoke Test + run: TEST_RELEASE_BUILD=false ./gradlew packagePlayRelease :baselineprofile:packageBenchmarkBenchmark --daemon + + - name: Run Release Smoke Test + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 + timeout-minutes: 10 + with: + api-level: ${{ matrix.api-level }} + force-avd-creation: false + target: ${{ matrix.target }} + arch: ${{ matrix.arch }} + emulator-options: -no-snapshot-save ${{ env.EMULATOR_OPTIONS }} + ram-size: ${{ env.EMULATOR_RAM_SIZE }} + pre-emulator-launch-script: ${{ env.EMULATOR_PRE_LAUNCH_SCRIPT }} + sdcard-path-or-size: 100M + disable-animations: true + script: | + bash .github/disable_art_metrics.sh + bash .github/adb_retry.sh shell settings put global hide_error_dialogs 1 + TEST_RELEASE_BUILD=false ./gradlew :baselineprofile:connectedBenchmarkBenchmarkAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.ichi2.anki.baselineprofile.ReleaseSmokeTest -Pandroid.testInstrumentationRunnerArguments.releaseSmokeTest=true --daemon - name: Configure NDK tools PATH for alignment checking run: | @@ -332,21 +370,21 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: always() with: - name: ${{ matrix.api-level }}-${{ matrix.arch }}-${{matrix.target}}-${{matrix.first-boot-delay}}-${{matrix.iteration}}-jacocoAndroidTestReport + name: ${{ matrix.api-level }}-${{ matrix.arch }}-${{matrix.target}}-${{matrix.first-boot-delay}}-${{matrix.iteration}}-${{ matrix.device }}-jacocoAndroidTestReport path: ~/work/Anki-Android/Anki-Android/AnkiDroid/build/reports/jacoco/ - name: Upload Emulator Log uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: always() with: - name: ${{ matrix.api-level }}-${{ matrix.arch }}-${{matrix.target}}-${{matrix.first-boot-delay}}-${{matrix.iteration}}-adb_logs + name: ${{ matrix.api-level }}-${{ matrix.arch }}-${{matrix.target}}-${{matrix.first-boot-delay}}-${{matrix.iteration}}-${{ matrix.device }}-adb_logs path: adb-log.txt - name: Upload Emulator Screen Record uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: always() with: - name: ${{ matrix.api-level }}-${{ matrix.arch }}-${{matrix.target}}-${{matrix.first-boot-delay}}-${{matrix.iteration}}-adb_video + name: ${{ matrix.api-level }}-${{ matrix.arch }}-${{matrix.target}}-${{matrix.first-boot-delay}}-${{matrix.iteration}}-${{ matrix.device }}-adb_video path: video.webm - uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 diff --git a/AnkiDroid/build.gradle.kts b/AnkiDroid/build.gradle.kts index ebcf51b3d8e0..772789c94c5b 100644 --- a/AnkiDroid/build.gradle.kts +++ b/AnkiDroid/build.gradle.kts @@ -12,13 +12,13 @@ plugins { // Gradle plugin portal alias(libs.plugins.tripletPlay) id("ankidroid.android.app") + id("ankidroid.android.compose") id("ankidroid.plugins.jacoco") id("ankidroid.plugins.robolectric-downloader") alias(libs.plugins.androidx.baselineprofile) alias(libs.plugins.kotlin.serialization) - alias(libs.plugins.kotlin.compose) alias(libs.plugins.keeper) - alias(libs.plugins.roborazzi) + id("ankidroid.plugins.screenshots") id("idea") } @@ -89,7 +89,6 @@ android { aidl = true viewBinding = true resValues = true - compose = true } if (testReleaseBuild) { @@ -131,9 +130,9 @@ android { // // This ensures the correct ordering between the various types of releases (dev < alpha < beta < release) which is // needed for upgrades to be offered correctly. - versionCode = 22600104 + versionCode = 22600106 // If you change this to a new version, you probably also want to update .gradle/workflows/milestone.yml for the new version... - versionName = "2.26.0alpha4" + versionName = "2.26.0alpha6" testApplicationId = "com.ichi2.anki.tests" vectorDrawables.useSupportLibrary = true testInstrumentationRunner = "com.ichi2.testutils.NewCollectionPathTestRunner" @@ -334,8 +333,6 @@ android { test.useJUnitPlatform { // ./gradlew testFullDebugUnitTest -Pscreenshot if (project.hasProperty("screenshot")) { - includeTags("com.ichi2.anki.ScreenshotTestCategory") - // ./gradlew :AnkiDroid:recordRoborazziPlayDebug -Pscreenshot -Ptheme=black -Pdevice=tablet,foldable val theme = project.findProperty("theme")?.takeIf { it.toString().isNotEmpty() } ?: "light" val device = project.findProperty("device")?.takeIf { it.toString().isNotEmpty() } ?: "phone" @@ -358,8 +355,6 @@ android { test.systemProperty("screenshot.theme", theme) test.systemProperty("screenshot.device", device) - } else { - excludeTags("com.ichi2.anki.ScreenshotTestCategory") } // Ensures that no EmptyApplication test relies on AnkiDroidApp // by only running EmptyApplication tests @@ -751,6 +746,7 @@ dependencies { implementation(project(":common:android")) implementation(project(":compat")) implementation(project(":libanki")) + implementation(project(":note-editor")) implementation(project(":vbpd")) "baselineProfile"(project(":baselineprofile")) implementation(project(":widgets")) @@ -758,17 +754,9 @@ dependencies { implementation(libs.google.analytics.kt) implementation(libs.androidx.activity) - // TODO: hoist the Compose enablement (kotlin.compose plugin, buildFeatures.compose, - // and the dependencies below) into a convention plugin under `buildSrc/` once the - // Kotlin-DSL migration of this build script lands implementation(libs.androidx.activity.compose) - implementation(platform(libs.androidx.compose.bom)) - implementation(libs.androidx.compose.ui) - implementation(libs.androidx.compose.ui.tooling.preview) - implementation(libs.androidx.compose.material3) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.runtime.compose) - debugImplementation(libs.androidx.compose.ui.tooling) implementation(libs.androidx.annotation) implementation(libs.androidx.appcompat) implementation(libs.androidx.browser) @@ -855,9 +843,6 @@ dependencies { // ankidroid.plugins.robolectric-downloader may need a new SDK jar entry // when Robolectric or targetSdk changes. See the instructions in that plugin. testImplementation(libs.robolectric) - testImplementation(libs.androidx.compose.ui.test.junit4) - // Needed for createComposeRule(), but not for createAndroidComposeRule() - debugImplementation(libs.androidx.compose.ui.test.manifest) testImplementation(libs.androidx.test.core) testImplementation(libs.androidx.test.junit) testImplementation(libs.kotlin.reflect) @@ -908,10 +893,4 @@ dependencies { // Required so the ExperimentalCoroutinesApi opt-in (applied globally) doesn't cause // an "unresolved" warning, which is treated as an error due to allWarningsAsErrors testFixturesImplementation(libs.kotlinx.coroutines.core) - // The Kotlin Compose Compiler plugin attaches to every Kotlin compilation in the - // module including testFixtures, which has no @Composable code and refuses to - // run unless the Compose Runtime is on the classpath. compileOnly satisfies the - // plugin's version check without shipping the runtime in the testFixtures output. - testFixturesCompileOnly(platform(libs.androidx.compose.bom)) - testFixturesCompileOnly(libs.androidx.compose.runtime) } diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/DeckPickerImportTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/DeckPickerImportTest.kt new file mode 100644 index 000000000000..bfba6bbbd07a --- /dev/null +++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/DeckPickerImportTest.kt @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki + +import androidx.core.content.edit +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.CollectionManager.withCol +import com.ichi2.anki.common.preferences.sharedPrefs +import com.ichi2.anki.dialogs.DialogHandler +import com.ichi2.anki.tests.InstrumentedTest +import com.ichi2.anki.testutil.GrantStoragePermission.storagePermission +import com.ichi2.anki.testutil.disableIntroductionSlide +import com.ichi2.anki.testutil.grantPermissions +import com.ichi2.anki.testutil.notificationPermission +import com.ichi2.anki.testutil.waitUntil +import com.ichi2.utils.ImportUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.junit.After +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +class DeckPickerImportTest : InstrumentedTest() { + @get:Rule + val runtimePermissionRule = grantPermissions(storagePermission, notificationPermission) + + private val preferences get() = testContext.sharedPrefs() + private val hadBackupPromptPreference = preferences.contains("backupPromptDisabled") + private val backupPromptDisabled = preferences.getBoolean("backupPromptDisabled", false) + + @Test + fun importConfirmationSurvivesDelayedTabletStartup() { + DialogHandler.storeMessage(ImportUtils.CollectionImportAdd("deck.apkg").toMessage()) + + withDelayedStartup { scenario, resumeStartup -> + assertImportConfirmationVisible() + scenario.assertStartupWaiting() + + resumeStartup() + scenario.awaitStartup() + + assertImportConfirmationVisible() + } + } + + @Before + fun prepareTablet() { + // The study options side panel is selected by layout-sw600dp. + assumeTrue("Requires the tablet layout", testContext.resources.configuration.smallestScreenWidthDp >= 600) + disableIntroductionSlide() + InitialActivity.setUpgradedToLatestVersion(preferences) + preferences.edit { putBoolean("backupPromptDisabled", true) } + addNoteUsingBasicNoteType() + } + + @After + fun restorePreferencesAndPendingDialogs() { + DialogHandler.storeMessage(null) + preferences.edit { + if (hadBackupPromptPreference) { + putBoolean("backupPromptDisabled", backupPromptDisabled) + } else { + remove("backupPromptDisabled") + } + } + } + + private fun withDelayedStartup(test: suspend (scenario: ActivityScenario, resumeStartup: suspend () -> Unit) -> Unit) = + runBlocking { + val collectionQueueHeld = CountDownLatch(1) + val releaseStartup = CountDownLatch(1) + val pendingCollectionOperation = + launch(Dispatchers.IO) { + withCol { + collectionQueueHeld.countDown() + check(releaseStartup.await(30, TimeUnit.SECONDS)) { "Startup was not released" } + } + } + var scenario: ActivityScenario? = null + try { + assertTrue(collectionQueueHeld.await(10, TimeUnit.SECONDS), "The collection queue must be held before launching") + val activityScenario = ActivityScenario.launch(DeckPicker::class.java) + scenario = activityScenario + test(activityScenario) { + releaseStartup.countDown() + pendingCollectionOperation.join() + } + } finally { + releaseStartup.countDown() + withContext(NonCancellable) { + pendingCollectionOperation.join() + scenario?.close() + } + } + } + + private fun ActivityScenario.assertStartupWaiting() { + onActivity { activity -> + assertTrue(activity.fragmented, "The tablet side panel must be present") + assertNull(activity.fragment, "Study options must not have been initialized yet") + assertTrue(activity.viewModel.startupJob?.isActive == true, "Startup must still be waiting") + } + } + + private fun ActivityScenario.awaitStartup() { + waitUntil(message = { "Startup did not finish initializing study options" }) { + var finished = false + onActivity { activity -> + finished = + activity.viewModel.startupJob?.isCompleted == true && + activity.viewModel.loadDeckCounts?.isCompleted == true && + activity.fragment != null + } + finished + } + } + + private fun assertImportConfirmationVisible() { + onView(withText(testContext.getString(CommonString.import_dialog_message_add, "deck.apkg"))) + .check(matches(isDisplayed())) + onView(withText(CommonString.import_message_add)) + .check(matches(isDisplayed())) + } +} diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/cardviewer/HtmlTypeAnswerReviewerTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/cardviewer/HtmlTypeAnswerReviewerTest.kt index 8dd588fb39a0..23f26fae8e1f 100644 --- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/cardviewer/HtmlTypeAnswerReviewerTest.kt +++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/cardviewer/HtmlTypeAnswerReviewerTest.kt @@ -2,6 +2,7 @@ package com.ichi2.anki.cardviewer +import android.app.Activity import android.text.InputType import android.view.inputmethod.EditorInfo import android.webkit.WebView @@ -17,6 +18,7 @@ import com.ichi2.anki.Reviewer import com.ichi2.anki.common.preferences.sharedPrefs import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.libanki.NoteTypeId +import com.ichi2.anki.previewer.CardViewerActivity import com.ichi2.anki.tests.InstrumentedTest import com.ichi2.anki.tests.checkWithTimeout import com.ichi2.anki.testutil.AvoidDayRolloverRule @@ -27,6 +29,7 @@ import com.ichi2.anki.testutil.ensureWebViewIsSupported import com.ichi2.anki.testutil.focusInput import com.ichi2.anki.testutil.grantPermissions import com.ichi2.anki.testutil.notificationPermission +import com.ichi2.anki.ui.windows.reviewer.ReviewerFragment import org.junit.After import org.junit.Before import org.junit.Rule @@ -34,13 +37,18 @@ import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.Parameterized import java.util.UUID +import kotlin.test.assertIs import kotlin.test.assertNotNull -/** Checks the legacy reviewer's HTML, script loading, and WebView keyboard support together. */ +/** Checks the real reviewers' HTML, script loading, and WebView keyboard support together. */ @RunWith(Parameterized::class) class HtmlTypeAnswerReviewerTest : InstrumentedTest() { @JvmField // required for Parameter @Parameterized.Parameter + var newReviewer = false + + @JvmField // required for Parameter + @Parameterized.Parameter(1) var autoFocus = false @get:Rule @@ -72,7 +80,7 @@ class HtmlTypeAnswerReviewerTest : InstrumentedTest() { originalPreferences = listOf(newReviewerKey, "useInputTag", "autoFocusTypeInAnswer", doubleTapKey).associateWith { preferences.all[it] } preferences.edit { - putBoolean(newReviewerKey, false) + putBoolean(newReviewerKey, newReviewer) putBoolean("useInputTag", true) putBoolean("autoFocusTypeInAnswer", autoFocus) // These tests intentionally grade immediately after Done. @@ -134,6 +142,8 @@ class HtmlTypeAnswerReviewerTest : InstrumentedTest() { @Test fun onlyTheMarkedAnswerDisablesSuggestions() = withReviewer { webView -> + // Let reviewer autofocus finish before moving to another field. + focusAnswer(webView, noSuggest = true) webView.focusInput("ordinary") val baseline = keyboard.awaitEditor().inputType webView.focusInput("typeans") @@ -165,7 +175,7 @@ class HtmlTypeAnswerReviewerTest : InstrumentedTest() { keyboard.perform("done") webView.awaitJavascript("document.querySelector('#typeans .typeGood')?.textContent === 'Γ©tΓ©'") if (question != "Third") { - onView(withId(R.id.flashcard_layout_ease3)) + onView(withId(if (newReviewer) R.id.good_button else R.id.flashcard_layout_ease3)) .also { it.checkWithTimeout(matches(isDisplayed())) } .perform(click()) } @@ -190,10 +200,16 @@ class HtmlTypeAnswerReviewerTest : InstrumentedTest() { } private fun withReviewer(block: (WebView) -> Unit) { - ActivityScenario.launch(Reviewer.getIntent(testContext)).use { scenario -> + ActivityScenario.launch(Reviewer.getIntent(testContext)).use { scenario -> lateinit var webView: WebView scenario.onActivity { activity -> - webView = assertNotNull(activity.webView) + webView = + if (newReviewer) { + val fragment = assertIs(assertIs(activity).fragment) + assertIs(fragment.binding.webViewLayout.getChildAt(0)) + } else { + assertNotNull(assertIs(activity).webView) + } } webView.awaitJavascript("document.getElementById('typeans') !== null") block(webView) @@ -202,7 +218,7 @@ class HtmlTypeAnswerReviewerTest : InstrumentedTest() { companion object { @JvmStatic // required for Parameters - @Parameterized.Parameters(name = "autoFocus={0}") - fun focusModes() = listOf(false, true) + @Parameterized.Parameters(name = "newReviewer={0}, autoFocus={1}") + fun reviewers() = listOf(arrayOf(false, false), arrayOf(false, true), arrayOf(true, false), arrayOf(true, true)) } } diff --git a/AnkiDroid/src/main/assets/changelog.html b/AnkiDroid/src/main/assets/changelog.html index 161320a39136..ea5c5d394647 100644 --- a/AnkiDroid/src/main/assets/changelog.html +++ b/AnkiDroid/src/main/assets/changelog.html @@ -464,6 +464,605 @@

AnkiDroid Changelog

+
+

Version 2.26.0alpha6 is a test build. Its changelog comes with the stable release. Until then, here are the changes in the latest stable version.

+
+
+

Version 2.25.3 (20261017)

+
+
+

Hello everyone out there in AnkiDroid-land πŸ‘‹

+
+
+

We’re already moving right along on work for 2.26 but we are always listening for and +fixing issues when we hear about them - hopefully this patch release gets rid of some +pain points for everyone

+
+
+

Cheers!

+
+
+
    +
  • +

    Deck Picker: Fix the import confirmation disappearing on tablets during startup

    +
  • +
  • +

    Reviewing: Re-enable HTTP(s) links - our security fixes went too far

    +
  • +
  • +

    Note Editor: Fix filename normalization issues when pasting media

    +
  • +
  • +

    Text-to-speech: Report network errors and timeouts

    +
  • +
  • +

    App-wide: Fix crashes if widgets are unsupported

    +
  • +
+
+
+

Full list of 2.25.3 changes.

+
+
+
+
+

Version 2.25.2 (20261013)

+
+
+

…​aaaaand we’re back! Google has officially blessed our releases and allowed them +out in the wild again after our donation link kerfuffle. In fact, literally millions +of you (nearly 3 million and counting) are already on the new 2.25 release series

+
+
+

For any of you that have opted in to crash reporting, thank you! That’s one of the main +ways we detect new problems, and your reward is this fresh batch of fixes

+
+
+

Enjoy!

+
+
+
    +
  • +

    Deck Picker: Fix 'one way sync' disappearing on tablets

    +
  • +
  • +

    Text-to-speech: Handle AnkiDroid crashing when accessing faulty TTS Engines

    +
  • +
  • +

    Reviewer: Fix TTS freezes when leaving the study screen

    +
  • +
  • +

    Performance: Fix various freezes while syncing

    +
  • +
  • +

    Sync: Fix errors when closing the app

    +
  • +
  • +

    App-wide: Fix some Android region settings blocking backend translations

    +
  • +
  • +

    App-wide: Fix random freezes on some devices (Custom Tabs issue)

    +
  • +
  • +

    App-wide: Fix crashes when closing/rotating screens

    +
  • +
  • +

    Internationalization: Fix tablet panes resizing in the opposite direction

    +
  • +
+
+
+

Full list of 2.25.2 changes.

+
+
+
+
+

Version 2.25.1 (20261006)

+
+
+

Wait what!? What is this? A 2.25.1 and you didn’t even get 2.25.0 yet!

+
+
+

That’s right! 2.25.0 did ship but Google wouldn’t release it because…​well, honestly +we’re not sure, their approval / rejection process is completely opaque! All we know is that +it originally had to do with them rejecting our donations link. And now it appears they’re +rejecting old versions! But we do what we can. And we can keep trying. So here’s 2.25.1!

+
+
+

With sincere wishes all of our lovely AnkiDroid users get to enjoy it ☺️ 🫢

+
+
+

Full 2.25.0 changelog down below πŸ‘‡

+
+
+

Here’s our 2.25.1 bugfix list:

+
+
+
    +
  • +

    Study screen: Preserve scroll position when flipping a card

    +
  • +
  • +

    Study screen: Reset zoom when changing cards

    +
  • +
  • +

    Reviewing: Fix MathJax font scaling (may change the layout of some cards)

    +
  • +
  • +

    Reviewing: Fix crash on close after a WebView failure

    +
  • +
  • +

    Text-to-speech: Prefer installed voices (fixes issues on Pixel devices)

    +
  • +
  • +

    Text-to-speech: Performance improvements for legacy TTS

    +
  • +
  • +

    Card Browser: Fix crashes while scrolling

    +
  • +
  • +

    Card Browser: Preserve selection in more cases

    +
  • +
  • +

    Card Browser: Fix icon tint being applied globally

    +
  • +
  • +

    Tags: Fix parent tags remaining selected after being unchecked

    +
  • +
  • +

    Tags: Fix checkbox states when editing multiple notes or filtering tags

    +
  • +
  • +

    Tags: Fix ordering (partially selected tags at the top)

    +
  • +
  • +

    Tags: Fix tags being changed on scroll

    +
  • +
  • +

    Tags: Better performance

    +
  • +
  • +

    Note Editor: Handle 'Restore to Default' in the template editor

    +
  • +
  • +

    Note Editor: Match Anki Desktop when switching to a note type without an deck

    +
  • +
  • +

    Note Types: Update state after undo/redo

    +
  • +
  • +

    Various screens: Better edge to edge UI support

    +
  • +
  • +

    Sync: Fix crash on long background syncs

    +
  • +
  • +

    Sync: Fix undismissable notification sometimes appearing after a completed sync

    +
  • +
  • +

    Deck Options: Prevent scrolling from changing Easy Days

    +
  • +
  • +

    Deck Picker Widget: Make the whole deck row tappable

    +
  • +
  • +

    Widgets: Improve layout with large fonts and small widget sizes

    +
  • +
  • +

    Widgets: Hide cut-off rows and refresh the deck list when resized

    +
  • +
  • +

    Widgets: Fix incorrect colors on load (Android 12+)

    +
  • +
  • +

    App-wide: Reduce unnecessary data saved when moving app data to disk

    +
  • +
  • +

    App-wide: Fix crash when TMPDIR is unavailable

    +
  • +
+
+ +
+
+
+

Version 2.25.0 (20260927)

+
+
+

Hey hey hey! It’s new AnkiDroid version time πŸŽπŸ‘€. We’ve got a bunch of new features for you, plus the usual +mix of security fixes, bug fixes and quality of life improvements. We hope you enjoy it.

+
+
+

Removal of donation links: Please see GitHub Issue 21656 for why this has happened and how to help us with this.

+
+
+

Google Summer of Code 2026

+
+

We participated in Google Summer of Code 2026!

+
+
+ +
+
+
+

Security

+
+

A special thank you to the following people for reporting the security issues fixed in this release:

+
+
+ +
+
+
    +
  • +

    Reviewer: cards can no longer open dangerous links [GHSL-2026-085, GHSA-54q9-5c5p-9rxg]

    +
  • +
  • +

    Reviewer: cards require permission to launch apps [GHSL-2026-085, GHSA-54q9-5c5p-9rxg]

    +
    +
      +
    • +

      May be unblocked in advanced settings, if all your cards are trustworthy.

      +
    • +
    +
    +
  • +
  • +

    JavaScript API: blocked calls which could read and modify cards (other than the currently selected card) [GHSL-2026-084, GHSA-54q9-5c5p-9rxg]

    +
    +
      +
    • +

      May be unblocked in advanced settings, if all your cards are trustworthy.

      +
    • +
    +
    +
  • +
  • +

    Reviewer: fix path traversal when loading media [GHSL-2026-083, GHSA-qjjx-px4w-4986]

    +
  • +
  • +

    Import: sanitize filenames to block path traversal [GHSA-q29p-h3pp-mh3v]

    +
  • +
  • +

    Note Editor: reject untrusted files from the image picker [GHSA-cr2g-gf75-cm6r]

    +
  • +
+
+
+
+

AnkiDroid features

+
+
    +
  • +

    Design: All screens now support edge to edge (Android requirement)

    +
    +
      +
    • +

      This required extensive changes to all screens of the app. Please report any bugs.

      +
    • +
    +
    +
  • +
  • +

    Themes: E-ink theme & improved theme selection.

    +
  • +
  • +

    Card Browser: Find & Replace support.

    +
  • +
  • +

    Card Browser: Improved sort screen, with new columns to sort by.

    +
  • +
  • +

    TTS: Support all installed engines.

    +
  • +
  • +

    Analytics: Analytics are fixed. Analytics are opt-in only, and all opt-in flags have been reset.

    +
  • +
  • +

    Deck Picker: Better designed floating action button.

    +
  • +
  • +

    New Study Screen: Card templates can record audio.

    +
  • +
  • +

    Performance: Whiteboard and drawing now save 'webp' files.

    +
  • +
  • +

    Change note type: Huge UI improvements.

    +
  • +
+
+
+
+

Anki 26.05 features

+
+
    +
  • +

    New Cloze syntax: {{c1,2,3::text}} to hide text on cards 1, 2 and 3. (manual)

    +
  • +
  • +

    Reviewing: Card content determines RTL direction (dir="auto") (source)

    +
  • +
  • +

    Search: syntax for ignoring accents: tag:nc:.

    +
  • +
  • +

    Image Occlusion: improved RTL layout.

    +
  • +
  • +

    Image Occlusion: display text annotations during review.

    +
  • +
  • +

    CSV import: supplying a note-type column allows differing field counts.

    +
  • +
  • +

    Deck Options: UI improvements on smaller screens.

    +
  • +
  • +

    FSRS: faster calculations and better spacing of siblings.

    +
  • +
  • +

    FSRS: suspended cards are excluded from rescheduling.

    +
  • +
  • +

    Media: better handling of case-sensitive filenames.

    +
  • +
  • +

    See more in the Anki 26.05 changelog.

    +
  • +
+
+
+
+

Fixes and improvements

+
+
    +
  • +

    Google Play: 'Donate' links removed to comply with Play policies

    +
  • +
  • +

    Custom study: Fix 'due cards' not working when tag selection is skipped.

    +
  • +
  • +

    Deck Picker: A deck is selected when it is created.

    +
  • +
  • +

    Deck Picker: Better navigation via arrow keys.

    +
  • +
  • +

    Deck Picker: Improved 'Rename deck' dialog UX.

    +
  • +
  • +

    Deck Picker: Fix counts when totals above 9,999.

    +
  • +
  • +

    Shared Deck Download: Improved retry logic.

    +
  • +
  • +

    Card Browser: UX: better selection restore and RTL design.

    +
  • +
  • +

    Filtered Decks: UX Improvements.

    +
  • +
  • +

    Filtered Decks: Handle state restoration after process death.

    +
  • +
  • +

    Reviewer: Fix the shake gesture firing rapidly.

    +
  • +
  • +

    Previewer: Fixed background in night mode.

    +
  • +
  • +

    Study Options: Fix 'undo' opening the reviewer.

    +
  • +
  • +

    Deck Options: Improved progress reporting.

    +
  • +
  • +

    Note Editor: Fewer 'unsaved changes' prompts.

    +
  • +
  • +

    Card Template Editor: Better focus handling.

    +
  • +
  • +

    Set Due Date: Fixed crash when opening.

    +
  • +
  • +

    WebViews: Improved outdated WebView detection.

    +
  • +
  • +

    Deck Picker: Fixed "deck not found in limits map" error.

    +
  • +
  • +

    API: Fixed a crash in answerCard.

    +
  • +
  • +

    New Study Screen: better handling of custom scheduling and app restoration.

    +
  • +
  • +

    Widgets: UI improvements.

    +
  • +
  • +

    Note Editor: Fix out of memory crashes.

    +
  • +
  • +

    Accessibility: Improvements for Card Browser and Reviewer.

    +
  • +
+
+
+
+
+
+

Version 2.24.1 (20260831)

+
+
+

Urgent update to our 2.24.0 release: security updates, and temporarily removing donation links to +comply with Play Store policies (more below).

+
+
+

A special thank you to the following people for reporting the security issues fixed in this release:

+
+
+ +
+
+

Removal of donation links: Please see GitHub Issue 21656 for why this has happened and how +to help us with this.

+
+
+

Please see the 2.24.0 changelog below for the main 2.24 features announcement πŸ‘‡

+
+
+
    +
  • +

    Security

    +
    +
      +
    • +

      Reviewer: cards can no longer open dangerous links [GHSL-2026-085, GHSA-54q9-5c5p-9rxg]

      +
    • +
    • +

      Reviewer: cards require permission to launch apps [GHSL-2026-085, GHSA-54q9-5c5p-9rxg]

      +
      +
        +
      • +

        May be unblocked in advanced settings, if all your cards are trustworthy.

        +
      • +
      +
      +
    • +
    • +

      JavaScript API: blocked calls which could read and modify cards (other than the currently selected card) [GHSL-2026-084, GHSA-54q9-5c5p-9rxg]

      +
      +
        +
      • +

        May be unblocked in advanced settings, if all your cards are trustworthy.

        +
      • +
      +
      +
    • +
    • +

      Reviewer: fix path traversal when loading media [GHSL-2026-083, GHSA-qjjx-px4w-4986]

      +
    • +
    • +

      Import: sanitize filenames to block path traversal [GHSA-q29p-h3pp-mh3v]

      +
    • +
    • +

      Note Editor: reject untrusted files from the image picker [GHSA-cr2g-gf75-cm6r]

      +
    • +
    +
    +
  • +
  • +

    Improvements

    +
    +
      +
    • +

      Privacy: removed a log entry which could hint at your timezone

      +
    • +
    • +

      Card Browser: better 'invalid search' messages

      +
    • +
    • +

      Google Play: 'Donate' links removed to comply with Play policies

      +
    • +
    +
    +
  • +
  • +

    Fixes

    +
    +
      +
    • +

      Deck Picker & Card Browser: Fix memory leaks

      +
    • +
    • +

      Fix crash when sharing an image to the Note Editor

      +
    • +
    • +

      Fix crash when exporting a large number of notes

      +
    • +
    • +

      Fix 'Auto Advance' not following the review queue

      +
    • +
    • +

      Improve error names in error messages

      +
    • +
    • +

      Whiteboard: fix flickering [Samsung/OnePlus]

      +
    • +
    • +

      Whiteboard: fix crash on rotation when 'type in the answer' is open

      +
    • +
    • +

      Note Editor: fix 'MathJax block' syntax

      +
    • +
    • +

      Note Types: only enable 'Save' when there are unsaved changes

      +
    • +
    • +

      Sync: fix conflict dialog only showing its title [Samsung-only]

      +
    • +
    • +

      Export: sharing text is now a text/plain file

      +
    • +
    • +

      Settings: fix crash when dragging reviewer actions and switching screens

      +
    • +
    • +

      Settings: include 'Themes' in search results

      +
    • +
    • +

      Reviewer: fix snackbar position when answer buttons are hidden

      +
    • +
    +
    +
  • +
+
+
+

Full list of 2.24.1 fixes

+
+
+

Version 2.24.0 (20260502)

@@ -520,6 +1119,9 @@

+ +
  • Sync: Added 'Network timeout' preference

  • @@ -1863,6 +2465,85 @@

    +

    Version 2.19.6 (20260831)

    +
    +
    +

    Urgent update to our 2.19.5 release: security updates, and temporarily removing donation links to +comply with Play Store policies (more below).

    +
    +
    +

    A special thank you to the following people for reporting the security issues fixed in this release:

    +
    +
    + +
    +
    +

    Removal of donation links: Please see GitHub Issue 21656 for why this has happened and how +to help us with this.

    +
    +
    +

    Please see the 2.19.1 changelog below for the main 2.19 features announcement πŸ‘‡

    +
    +
    +
      +
    • +

      Security

      +
      +
        +
      • +

        Reviewer: cards can no longer open dangerous links [GHSL-2026-085, GHSA-54q9-5c5p-9rxg]

        +
      • +
      • +

        Reviewer: cards require permission to launch apps [GHSL-2026-085, GHSA-54q9-5c5p-9rxg]

        +
        +
          +
        • +

          May be unblocked in advanced settings, if all your cards are trustworthy.

          +
        • +
        +
        +
      • +
      • +

        JavaScript API: blocked calls which could read and modify cards (other than the currently selected card) [GHSL-2026-084, GHSA-54q9-5c5p-9rxg]

        +
        +
          +
        • +

          May be unblocked in advanced settings, if all your cards are trustworthy.

          +
        • +
        +
        +
      • +
      • +

        Reviewer: fix path traversal when loading media [GHSA-qjjx-px4w-4986]

        +
      • +
      • +

        Import: sanitize filenames to block path traversal [GHSA-q29p-h3pp-mh3v]

        +
      • +
      +
      +
    • +
    • +

      Improvements

      +
      +
        +
      • +

        Google Play: 'Donate' links removed to comply with Play policies

        +
      • +
      +
      +
    • +
    +
    +
    +

  • +

    Version 2.19.5 (20250711)

    @@ -2795,6 +3476,85 @@

    +

    Version 2.16.6 (20260831)

    +
    +
    +

    Urgent update to our 2.16.5 release: security updates, and temporarily removing donation links to +comply with Play Store policies (more below).

    +
    +
    +

    A special thank you to the following people for reporting the security issues fixed in this release:

    +
    +
    + +
    +
    +

    Removal of donation links: Please see GitHub Issue 21656 for why this has happened and how +to help us with this.

    +
    +
    +

    Please see the 2.16.2 changelog below for the main 2.16 features announcement πŸ‘‡

    +
    +
    +
      +
    • +

      Security

      +
      +
        +
      • +

        Reviewer: cards can no longer open dangerous links [GHSL-2026-085, GHSA-54q9-5c5p-9rxg]

        +
      • +
      • +

        Reviewer: cards require permission to launch apps [GHSL-2026-085, GHSA-54q9-5c5p-9rxg]

        +
        +
          +
        • +

          May be unblocked in advanced settings, if all your cards are trustworthy.

          +
        • +
        +
        +
      • +
      • +

        JavaScript API: blocked calls which could read and modify cards (other than the currently selected card) [GHSL-2026-084, GHSA-54q9-5c5p-9rxg]

        +
        +
          +
        • +

          May be unblocked in advanced settings, if all your cards are trustworthy.

          +
        • +
        +
        +
      • +
      • +

        Reviewer: fix path traversal when loading media [GHSA-qjjx-px4w-4986]

        +
      • +
      • +

        Import: sanitize filenames to block path traversal [GHSA-q29p-h3pp-mh3v]

        +
      • +
      +
      +
    • +
    • +

      Improvements

      +
      +
        +
      • +

        Google Play: 'Donate' links removed to comply with Play policies

        +
      • +
      +
      +
    • +
    +
    +
    +

    +

    Version 2.16.5 (20230906)

    @@ -5833,8 +6593,8 @@

    <

    - \ No newline at end of file + diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt index d3f2effea34e..f9dd109ff94e 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt @@ -9,7 +9,6 @@ package com.ichi2.anki import android.annotation.SuppressLint -import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.content.SharedPreferences @@ -2627,7 +2626,8 @@ abstract class AbstractFlashcardViewer : } if (intent == null) { val uri = url.toUri() - if (!appPermission.checkCanLaunchExternalApps()) { + intent = appPermission.createUrlIntent(uri) + if (intent == null) { Timber.w("URL blocked: canLaunchExternalApps") return true } @@ -2636,14 +2636,13 @@ abstract class AbstractFlashcardViewer : return true } Timber.d("Opening external link \"%s\" with an Intent", url) - intent = Intent(Intent.ACTION_VIEW, uri) } else { Timber.d("Opening resolved external link \"%s\" with an Intent: %s", url, intent) } try { startActivity(intent) - } catch (_: ActivityNotFoundException) { - Timber.w("No app found to handle open external url from AbstractFlashcardViewer") + } catch (e: Exception) { + Timber.w(e, "Could not open url from AbstractFlashcardViewer") showSnackbar(CommonString.activity_start_failed) } return true diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AndroidTtsPlayer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AndroidTtsPlayer.kt index f300040ba594..d72fb7a5b42f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AndroidTtsPlayer.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AndroidTtsPlayer.kt @@ -24,6 +24,7 @@ import timber.log.Timber import kotlin.coroutines.resume class AndroidTtsPlayer( + /** Voices in engine-preference order, with the default engine first. */ private val voices: List, /** Builds a [TextToSpeech] bound to an engine package. Overridable for testing */ private val ttsFactory: suspend (engine: String) -> TextToSpeech? = { engine -> TtsVoices.createTts(engine) }, @@ -93,7 +94,7 @@ class AndroidTtsPlayer( TtsVoices.ttsEngine?.let { defaultEngine -> getOrCreateTts(defaultEngine) } } - // Prefer installed voices for language fallback without changing the shared matcher. + // Prefer installed voices for language fallback, preserving engine-preference order. // Keep uninstalled voices selectable: an explicit request can trigger their download. override fun getAvailableVoices(): List = voices.sortedBy { it.unavailable() } @@ -198,7 +199,7 @@ class AndroidTtsPlayer( @CheckResult suspend fun createInstance(scope: CoroutineScope): AndroidTtsPlayer { - val voices = TtsVoices.allTtsVoices().toList() + val voices = TtsVoices.allTtsVoices() return AndroidTtsPlayer(voices).apply { init(scope) Timber.v("TTS creation: initialized player instance") diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt index 65f4a46e8656..5f17b2df1a21 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt @@ -83,10 +83,10 @@ import com.ichi2.anki.dialogs.DatabaseErrorDialog import com.ichi2.anki.dialogs.DatabaseErrorDialog.CustomExceptionData import com.ichi2.anki.dialogs.DatabaseErrorDialog.DatabaseErrorDialogType import com.ichi2.anki.dialogs.DialogHandler -import com.ichi2.anki.dialogs.ExportReadyDialog.Companion.ARG_SHARE_AS_TEXT -import com.ichi2.anki.dialogs.ExportReadyDialog.Companion.KEY_EXPORT_PATH import com.ichi2.anki.dialogs.ExportReadyDialog.Companion.REQUEST_EXPORT_SAVE import com.ichi2.anki.dialogs.ExportReadyDialog.Companion.REQUEST_EXPORT_SHARE +import com.ichi2.anki.dialogs.ExportReadyDialog.Companion.RESULT_EXPORT_PATH +import com.ichi2.anki.dialogs.ExportReadyDialog.Companion.RESULT_SHARE_AS_TEXT import com.ichi2.anki.dialogs.SimpleMessageDialog import com.ichi2.anki.dialogs.handleExportReadyRequest import com.ichi2.anki.dialogs.viewmodel.ExportReadyViewModel @@ -170,13 +170,13 @@ open class AnkiActivity( } supportFragmentManager.setFragmentResultListener(REQUEST_EXPORT_SAVE, this) { _, bundle -> saveExportFile( - bundle.getString(KEY_EXPORT_PATH) ?: error("Missing required exportPath!"), + bundle.getString(RESULT_EXPORT_PATH) ?: error("Missing required exportPath!"), ) } supportFragmentManager.setFragmentResultListener(REQUEST_EXPORT_SHARE, this) { _, bundle -> shareFile( - path = bundle.requireString(KEY_EXPORT_PATH), - asText = bundle.getBoolean(ARG_SHARE_AS_TEXT, false), + path = bundle.requireString(RESULT_EXPORT_PATH), + asText = bundle.getBoolean(RESULT_SHARE_AS_TEXT, false), ) } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt index e0936fb42fee..a244962e9e31 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt @@ -59,6 +59,7 @@ import com.ichi2.anki.multiprofile.isPhoenixProcess import com.ichi2.anki.navigation.initializeNavigator import com.ichi2.anki.observability.ChangeManager import com.ichi2.anki.preferences.SharedPreferencesProvider +import com.ichi2.anki.recorder.VoiceRecorder import com.ichi2.anki.reviewreminders.ReminderLogTree import com.ichi2.anki.servicelayer.DebugInfoService import com.ichi2.anki.servicelayer.ThrowableFilterService @@ -129,8 +130,8 @@ open class AnkiDroidApp : } } + @set:VisibleForTesting var profileManager: ProfileManager? = null - private set private val profileContext: Context? get() = profileManager?.activeProfileContext @@ -279,6 +280,7 @@ open class AnkiDroidApp : runCatching { measureTime("cacheCleanup") { MultimediaArgsStorage.create(this@AnkiDroidApp).removeExpiredFiles() + VoiceRecorder.removeExpiredRecordings(VoiceRecorder.recordingsDirectory(this@AnkiDroidApp)) } }.onFailure { Timber.w(it, "Unable to clean up expired cache files") } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateBrowserAppearanceEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateBrowserAppearanceEditor.kt index 3f3e446b9b55..b39f01956cb1 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateBrowserAppearanceEditor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateBrowserAppearanceEditor.kt @@ -137,14 +137,14 @@ class CardTemplateBrowserAppearanceEditor : AnkiActivity(R.layout.activity_card_ } public override fun onSaveInstanceState(outState: Bundle) { - outState.putString(INTENT_QUESTION_FORMAT, questionFormat) - outState.putString(INTENT_ANSWER_FORMAT, answerFormat) + outState.putString(EXTRA_QUESTION_FORMAT, questionFormat) + outState.putString(EXTRA_ANSWER_FORMAT, answerFormat) super.onSaveInstanceState(outState) } private fun initializeUiFromBundle(bundle: Bundle) { - binding.questionFormat.setText(bundle.getString(INTENT_QUESTION_FORMAT)) - binding.answerFormat.setText(bundle.getString(INTENT_ANSWER_FORMAT)) + binding.questionFormat.setText(bundle.getString(EXTRA_QUESTION_FORMAT)) + binding.answerFormat.setText(bundle.getString(EXTRA_ANSWER_FORMAT)) discardChangesCallback.isEnabled = hasChanges() @@ -152,9 +152,9 @@ class CardTemplateBrowserAppearanceEditor : AnkiActivity(R.layout.activity_card_ title = TR.sentenceCase.browserAppearance } - private fun answerHasChanged(intent: Intent): Boolean = intent.getStringExtra(INTENT_ANSWER_FORMAT) != answerFormat + private fun answerHasChanged(intent: Intent): Boolean = intent.getStringExtra(EXTRA_ANSWER_FORMAT) != answerFormat - private fun questionHasChanged(intent: Intent): Boolean = intent.getStringExtra(INTENT_QUESTION_FORMAT) != questionFormat + private fun questionHasChanged(intent: Intent): Boolean = intent.getStringExtra(EXTRA_QUESTION_FORMAT) != questionFormat private val questionFormat: String get() = getTextValue(binding.questionFormat) @@ -174,8 +174,8 @@ class CardTemplateBrowserAppearanceEditor : AnkiActivity(R.layout.activity_card_ Timber.i("Save and Exit") val data = Intent().apply { - putExtra(INTENT_QUESTION_FORMAT, questionFormat) - putExtra(INTENT_ANSWER_FORMAT, answerFormat) + putExtra(EXTRA_QUESTION_FORMAT, questionFormat) + putExtra(EXTRA_ANSWER_FORMAT, answerFormat) } setResult(RESULT_OK, data) finish() @@ -208,8 +208,8 @@ class CardTemplateBrowserAppearanceEditor : AnkiActivity(R.layout.activity_card_ null } else { try { - val question = intent.getStringExtra(INTENT_QUESTION_FORMAT) - val answer = intent.getStringExtra(INTENT_ANSWER_FORMAT) + val question = intent.getStringExtra(EXTRA_QUESTION_FORMAT) + val answer = intent.getStringExtra(EXTRA_ANSWER_FORMAT) Result(question, answer) } catch (e: Exception) { Timber.w(e, "Could not read result from intent") @@ -220,8 +220,8 @@ class CardTemplateBrowserAppearanceEditor : AnkiActivity(R.layout.activity_card_ } companion object { - const val INTENT_QUESTION_FORMAT = "bqfmt" - const val INTENT_ANSWER_FORMAT = "bafmt" + const val EXTRA_QUESTION_FORMAT = "bqfmt" + const val EXTRA_ANSWER_FORMAT = "bafmt" /** Specified the card browser should use the default template formatter */ const val VALUE_USE_DEFAULT = "" @@ -239,8 +239,8 @@ class CardTemplateBrowserAppearanceEditor : AnkiActivity(R.layout.activity_card_ answerFormat: String, ): Intent = Intent(context, CardTemplateBrowserAppearanceEditor::class.java).apply { - putExtra(INTENT_QUESTION_FORMAT, questionFormat) - putExtra(INTENT_ANSWER_FORMAT, answerFormat) + putExtra(EXTRA_QUESTION_FORMAT, questionFormat) + putExtra(EXTRA_ANSWER_FORMAT, answerFormat) } } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt index eebc07c52163..1b573e474d76 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt @@ -60,6 +60,7 @@ import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.android.input.ShortcutGroup import com.ichi2.anki.android.input.shortcut import com.ichi2.anki.cardviewer.SingleCardSide +import com.ichi2.anki.common.analytics.Analytics import com.ichi2.anki.common.android.animationDisabled import com.ichi2.anki.common.android.appContext import com.ichi2.anki.common.annotations.NeedsTest @@ -87,6 +88,8 @@ import com.ichi2.anki.libanki.NoteTypeId import com.ichi2.anki.libanki.NotetypeJson import com.ichi2.anki.libanki.Notetypes import com.ichi2.anki.libanki.Notetypes.Companion.NOT_FOUND_NOTE_TYPE +import com.ichi2.anki.libanki.backend.BackendUtils +import com.ichi2.anki.libanki.backend.model.toBackendNote import com.ichi2.anki.libanki.exception.ConfirmModSchemaException import com.ichi2.anki.libanki.getStockNotetype import com.ichi2.anki.libanki.getStockNotetypeKinds @@ -117,14 +120,18 @@ import com.ichi2.utils.show import dev.androidbroadcast.vbpd.viewBinding import kotlinx.coroutines.launch import net.ankiweb.rsdroid.Translations +import net.ankiweb.rsdroid.exceptions.BackendTemplateException import org.json.JSONArray import org.json.JSONException import org.json.JSONObject import timber.log.Timber +import java.util.concurrent.TimeoutException import java.util.regex.Pattern import kotlin.math.max import kotlin.math.min +import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeSource private typealias BackendCardTemplate = com.ichi2.anki.libanki.CardTemplate @@ -204,22 +211,22 @@ open class CardTemplateEditor : AnkiActivity(R.layout.activity_card_template_edi // Load the args either from the intent or savedInstanceState bundle if (savedInstanceState == null) { // get note type id - noteTypeId = intent.getLongExtra(EDITOR_NOTE_TYPE_ID, NOT_FOUND_NOTE_TYPE) + noteTypeId = intent.getLongExtra(EXTRA_NOTE_TYPE_ID, NOT_FOUND_NOTE_TYPE) if (noteTypeId == NOT_FOUND_NOTE_TYPE) { Timber.e("CardTemplateEditor :: no note type ID was provided") finish() return } // get id for currently edited note (optional) - noteId = intent.getLongExtra(EDITOR_NOTE_ID, -1L) + noteId = intent.getLongExtra(EXTRA_NOTE_ID, -1L) // get id for currently edited template (optional) - startingOrdId = intent.getIntExtra(EDITOR_START_ORD_ID, -1) + startingOrdId = intent.getIntExtra(EXTRA_START_ORD_ID, -1) tabToCursorPositions[0] = hashMapOf() tabToViewId[0] = R.id.front_edit } else { - noteTypeId = savedInstanceState.getLong(EDITOR_NOTE_TYPE_ID) - noteId = savedInstanceState.getLong(EDITOR_NOTE_ID) - startingOrdId = savedInstanceState.getInt(EDITOR_START_ORD_ID) + noteTypeId = savedInstanceState.getLong(EXTRA_NOTE_TYPE_ID) + noteId = savedInstanceState.getLong(EXTRA_NOTE_ID) + startingOrdId = savedInstanceState.getInt(EXTRA_START_ORD_ID) tabToCursorPositions = savedInstanceState.getSerializableCompat>>(TAB_TO_CURSOR_POSITION_KEY)!! tabToViewId = savedInstanceState.getSerializableCompat>(TAB_TO_VIEW_ID)!! tempNoteType = CardTemplateNotetype.fromBundle(savedInstanceState) @@ -335,9 +342,9 @@ open class CardTemplateEditor : AnkiActivity(R.layout.activity_card_template_edi public override fun onSaveInstanceState(outState: Bundle) { with(outState) { tempNoteType?.let { putAll(it.toBundle()) } - putLong(EDITOR_NOTE_TYPE_ID, noteTypeId) - putLong(EDITOR_NOTE_ID, noteId) - putInt(EDITOR_START_ORD_ID, startingOrdId) + putLong(EXTRA_NOTE_TYPE_ID, noteTypeId) + putLong(EXTRA_NOTE_ID, noteId) + putInt(EXTRA_START_ORD_ID, startingOrdId) putSerializable(TAB_TO_VIEW_ID, tabToViewId) putSerializable(TAB_TO_CURSOR_POSITION_KEY, tabToCursorPositions) super.onSaveInstanceState(this) @@ -816,7 +823,7 @@ open class CardTemplateEditor : AnkiActivity(R.layout.activity_card_template_edi parentFragmentManager.setFragmentResultListener(insertFieldRequestKey, viewLifecycleOwner) { key, bundle -> // this is guaranteed to be non null, as we put a non null value on the other side - insertField(bundle.getString(InsertFieldDialog.KEY_INSERTED_FIELD)!!) + insertField(bundle.getString(InsertFieldDialog.RESULT_INSERTED_FIELD)!!) } setupMenu() } @@ -1076,16 +1083,13 @@ open class CardTemplateEditor : AnkiActivity(R.layout.activity_card_template_edi Timber.w("addCardTemplate attempted on cloze note type") return } - // Show confirmation dialog - // isOrdinalPendingAdd method will check if there are any new card types added or not, - // if TempModel has new card type then numAffectedCards will be 0 by default. - val numAffectedCards = - if (!CardTemplateNotetype.isOrdinalPendingAdd(templateEditor.tempNoteType!!, templateEditor.ord)) { - templateEditor.getColUnsafe.notetypes.tmplUseCount(templateEditor.tempNoteType!!.notetype, templateEditor.ord) - } else { - 0 - } - confirmAddCards(templateEditor.tempNoteType!!.notetype, numAffectedCards) + val noteType = templateEditor.tempNoteType!!.notetype + val newTemplate = buildNewTemplate(noteType) + launchCatchingTask { + // Show confirmation dialog + val numAffectedCards = withProgress { withCol { countCardsCreatedBy(noteType, newTemplate) } } + confirmAddCards(noteType, newTemplate, numAffectedCards) + } } fun saveNoteType(): Boolean { @@ -1313,7 +1317,7 @@ open class CardTemplateEditor : AnkiActivity(R.layout.activity_card_template_edi } fun getNote(col: Collection): Note? { - val nid = requireArguments().getLong(EDITOR_NOTE_ID) + val nid = requireArguments().getLong(EXTRA_NOTE_ID) return if (nid != -1L) col.getNote(nid) else null } @@ -1484,6 +1488,7 @@ open class CardTemplateEditor : AnkiActivity(R.layout.activity_card_template_edi */ private fun confirmAddCards( notetype: NotetypeJson, + newTemplate: BackendCardTemplate, numAffectedCards: Int, ) { val d = ConfirmationDialog() @@ -1501,7 +1506,7 @@ open class CardTemplateEditor : AnkiActivity(R.layout.activity_card_template_edi positiveButtonText = getString(CommonString.menu_add), ) - val addCard = Runnable { addNewTemplate(notetype) } + val addCard = Runnable { addNewTemplate(notetype, newTemplate) } val confirm = Runnable { executeWithSyncCheck(addCard) } d.setConfirm(confirm) templateEditor.showDialogFragment(d) @@ -1577,8 +1582,22 @@ open class CardTemplateEditor : AnkiActivity(R.layout.activity_card_template_edi * Add new template to a given note type * @param noteType note type to add new template to */ - private fun addNewTemplate(noteType: NotetypeJson) { - // Build new template + private fun addNewTemplate( + noteType: NotetypeJson, + newTemplate: BackendCardTemplate, + ) { + val templates = noteType.templates + templates.append(newTemplate) + templateEditor.tempNoteType!!.addNewTemplate(newTemplate) + templateEditor.mainBinding.cardTemplateEditorPager.adapter!! + .notifyDataSetChanged() + templateEditor.mainBinding.cardTemplateEditorPager.setCurrentItem( + templates.length() - 1, + templateEditor.animationDisabled(), + ) + } + + private fun buildNewTemplate(noteType: NotetypeJson): BackendCardTemplate { val oldCardIndex = requireArguments().getInt(CARD_INDEX) val templates = noteType.templates val oldTemplate = templates[oldCardIndex] @@ -1591,16 +1610,9 @@ open class CardTemplateEditor : AnkiActivity(R.layout.activity_card_template_edi flipQA(newTemplate) } val lastExistingOrd = templates.last().ord - Timber.d("addNewTemplate() lastExistingOrd was %s", lastExistingOrd) + Timber.d("buildNewTemplate() lastExistingOrd was %s", lastExistingOrd) newTemplate.setOrd(lastExistingOrd + 1) - templates.append(newTemplate) - templateEditor.tempNoteType!!.addNewTemplate(newTemplate) - templateEditor.mainBinding.cardTemplateEditorPager.adapter!! - .notifyDataSetChanged() - templateEditor.mainBinding.cardTemplateEditorPager.setCurrentItem( - templates.length() - 1, - templateEditor.animationDisabled(), - ) + return newTemplate } /** @@ -1669,7 +1681,7 @@ open class CardTemplateEditor : AnkiActivity(R.layout.activity_card_template_edi val f = CardTemplateFragment() val args = Bundle() args.putInt(CARD_INDEX, cardIndex) - args.putLong(EDITOR_NOTE_ID, noteId) + args.putLong(EXTRA_NOTE_ID, noteId) args.putInt(EDITOR_VIEW_ID_KEY, viewId) f.arguments = args return f @@ -1687,9 +1699,9 @@ open class CardTemplateEditor : AnkiActivity(R.layout.activity_card_template_edi private const val TAB_TO_CURSOR_POSITION_KEY = "tabToCursorPosition" private const val EDITOR_VIEW_ID_KEY = "editorViewId" private const val TAB_TO_VIEW_ID = "tabToViewId" - const val EDITOR_NOTE_TYPE_ID = "noteTypeId" - private const val EDITOR_NOTE_ID = "noteId" - private const val EDITOR_START_ORD_ID = "ordId" + const val EXTRA_NOTE_TYPE_ID = "noteTypeId" + private const val EXTRA_NOTE_ID = "noteId" + private const val EXTRA_START_ORD_ID = "ordId" private const val CARD_INDEX = "card_ord" // Keys for saving pane weights in SharedPreferences @@ -1713,9 +1725,9 @@ open class CardTemplateEditor : AnkiActivity(R.layout.activity_card_template_edi ord: CardOrdinal? = null, ) = Intent(context, CardTemplateEditor::class.java) .apply { - putExtra(EDITOR_NOTE_TYPE_ID, noteTypeId) - noteId?.let { putExtra(EDITOR_NOTE_ID, it) } - ord?.let { putExtra(EDITOR_START_ORD_ID, it) } + putExtra(EXTRA_NOTE_TYPE_ID, noteTypeId) + noteId?.let { putExtra(EXTRA_NOTE_ID, it) } + ord?.let { putExtra(EXTRA_START_ORD_ID, it) } Timber.d( "Built intent for CardTemplateEditor; ntid: %s; nid: %s; ord: %s", @@ -1726,3 +1738,30 @@ open class CardTemplateEditor : AnkiActivity(R.layout.activity_card_template_edi } } } + +@VisibleForTesting +internal fun Collection.countCardsCreatedBy( + noteType: NotetypeJson, + template: BackendCardTemplate, + timeout: Duration = 1.seconds, +): Int { + val deadline = TimeSource.Monotonic.markNow() + timeout + val unreferencedOrd = generateSequence(0) { it + 1 }.first { "c${it + 1}" !in template.qfmt } + val templateBytes = BackendUtils.toJsonBytes(template.deepClone().apply { setOrd(unreferencedOrd) }) + val noteIds = notetypes.nids(noteType) + return try { + noteIds.count { nid -> + if (deadline.hasPassedNow()) { + Timber.w("counting the cards of a new card type timed out") + Analytics.sendAnalyticsException(TimeoutException("New card type count timed out with ${noteIds.size} notes"), false) + return noteIds.size + } + !backend + .renderUncommittedCardLegacy(getNote(nid).toBackendNote(), unreferencedOrd, templateBytes, false, true) + .isEmpty + } + } catch (e: BackendTemplateException) { + Timber.w(e, "new card type has a template error") + 0 + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt index 7f8194ae4104..7510a57c1cce 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt @@ -65,6 +65,7 @@ import androidx.fragment.app.commitNow import androidx.lifecycle.Lifecycle import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.LinearLayoutManager import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.work.NetworkType @@ -221,6 +222,7 @@ import com.ichi2.utils.showDialogIfWebViewOutdated import com.ichi2.utils.title import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch @@ -301,6 +303,7 @@ open class DeckPicker : private lateinit var decksLayoutManager: LinearLayoutManager private lateinit var deckListAdapter: DeckAdapter + private var backgroundImageErrorShown = false private lateinit var pullToSyncWrapper: SwipeRefreshLayout @VisibleForTesting @@ -522,7 +525,7 @@ open class DeckPicker : return } Timber.d("Not displaying app intro") - if (intent.hasExtra(INTENT_SYNC_FROM_LOGIN)) { + if (intent.hasExtra(EXTRA_SYNC_FROM_LOGIN)) { Timber.d("launched from introduction activity login: syncing") syncOnResume = true } @@ -588,7 +591,16 @@ open class DeckPicker : ) } - lifecycleScope.launch { applyDeckPickerBackground() } + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + applyDeckPickerBackground() + try { + awaitCancellation() + } finally { + deckPickerBinding.background.setImageDrawable(null) + } + } + } setupPullToSync() // Setup the FloatingActionButtons @@ -1212,17 +1224,23 @@ open class DeckPicker : private suspend fun applyDeckPickerBackground() { val result = BackgroundImage.resolve(this) if (result is BackgroundImage.ResolveResult.Failure) { - showThemedToast(this, result.message(this), shortLength = false) + showBackgroundImageError(result.message(this)) } val drawable = (result as? BackgroundImage.ResolveResult.Ready)?.drawable val applied = deckPickerBinding.background.setImageDrawableSafe(drawable) { - showThemedToast(this, getString(CommonString.background_image_too_large), shortLength = false) + showBackgroundImageError(getString(CommonString.background_image_too_large)) } // activityHasBackground calls notifyDataSetChanged, ensure only 1 call deckListAdapter.activityHasBackground = applied && drawable != null } + private fun showBackgroundImageError(message: String) { + if (backgroundImageErrorShown) return + backgroundImageErrorShown = true + showThemedToast(this, message, shortLength = false) + } + override fun onCreateOptionsMenu(menu: Menu): Boolean { if (viewModel.flowOfStartupResponse.value is StartupResponse.FatalError) { return false @@ -1572,7 +1590,7 @@ open class DeckPicker : // Due to the App Introduction, this may be called before permission has been granted. if (syncOnResume && hasCollectionStoragePermissions()) { syncOnResume = false - intent.removeExtra(INTENT_SYNC_FROM_LOGIN) + intent.removeExtra(EXTRA_SYNC_FROM_LOGIN) Timber.i("Performing Sync on Resume") Permissions.requestNotificationPermissionsForSyncing(this) sync() @@ -2117,11 +2135,15 @@ open class DeckPicker : private fun tryShowStudyOptionsPanel(): Boolean { val containerId = binding.studyoptionsFragment?.id ?: return false - // Close other panels before looking for restored study options. - supportFragmentManager.popBackStackImmediate( - null, - FragmentManager.POP_BACK_STACK_INCLUSIVE, - ) + // Only clear navigation when closing another side panel. Import dialogs can already + // be on the back stack when startup finishes, and must survive panel initialization. + val sidePanel = supportFragmentManager.findFragmentById(containerId) + if (sidePanel != null && sidePanel !is StudyOptionsFragment) { + supportFragmentManager.popBackStackImmediate( + null, + FragmentManager.POP_BACK_STACK_INCLUSIVE, + ) + } val studyOptions = fragment if (studyOptions != null) { @@ -2488,7 +2510,7 @@ open class DeckPicker : * * This is for the 'download existing collection from AnkiWeb' use case */ - const val INTENT_SYNC_FROM_LOGIN = "syncFromLogin" + const val EXTRA_SYNC_FROM_LOGIN = "syncFromLogin" /** * Available options performed by other activities (request codes for onActivityResult()) @@ -2515,7 +2537,7 @@ open class DeckPicker : autoSync: Boolean = false, ) = Intent(context, DeckPicker::class.java).apply { if (autoSync) { - putExtra(INTENT_SYNC_FROM_LOGIN, true) + putExtra(EXTRA_SYNC_FROM_LOGIN, true) } } } @@ -2523,7 +2545,7 @@ open class DeckPicker : override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) this.intent = intent - if (intent.hasExtra(INTENT_SYNC_FROM_LOGIN)) { + if (intent.hasExtra(EXTRA_SYNC_FROM_LOGIN)) { Timber.i("Sync requested from Login") this.syncOnResume = true } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt index 00b80c70c1d1..27e27392dd54 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt @@ -30,6 +30,7 @@ class DeckPickerFloatingActionMenu( ) { // TODO: refactor this to decouple with Homescreen & DeckPicker private val binding: IncludeFloatingAddButtonBinding = homescreenBinding.deckPickerPane.floatingActionButton + private val extendedFabHorizontalPadding = binding.fabMain.paddingStart + binding.fabMain.paddingEnd /** Layout deck_picker.xml is attached here */ private val linearLayout: LinearLayout = homescreenBinding.deckPickerPane.deckpickerView @@ -70,7 +71,7 @@ class DeckPickerFloatingActionMenu( binding.fabBGLayout.visibility = View.VISIBLE binding.fabMain.backgroundTintList = ColorStateList.valueOf(fabPressedColor) binding.fabMain.setIconResource(addNoteIcon) - binding.fabMain.extend() + extendMainButton() with(binding) { addSharedButton.animate().translationY(0f).duration = 100 @@ -102,6 +103,26 @@ class DeckPickerFloatingActionMenu( } } + /** Extends Add without overshooting its final width, preserving the styled minimum width. */ + private fun extendMainButton() = + with(binding.fabMain) { + val originalMinWidth = minWidth + val originalMinimumWidth = minimumWidth + // Material measures with collapsed padding, then adds the extra extended padding. + // Discount that padding from the minimum during measurement so it isn't counted twice. + val extraPadding = extendedFabHorizontalPadding - paddingStart - paddingEnd + // Both TextView's minWidth and View's minimumWidth constrain this measurement. + minWidth = (originalMinWidth - extraPadding).coerceAtLeast(0) + minimumWidth = (originalMinimumWidth - extraPadding).coerceAtLeast(0) + try { + extend() + } finally { + // Preserve the styled minimum width for the final wrap_content layout. + minWidth = originalMinWidth + minimumWidth = originalMinimumWidth + } + } + /** * This function takes a parameter which decides if we want to apply the rise and shrink animation * for FAB or not. @@ -331,7 +352,17 @@ class DeckPickerFloatingActionMenu( false } + // Close the FAB menu when clicking outside the menu container binding.fabBGLayout.setOnClickListener { closeFloatingActionMenu(applyRiseAndShrinkAnimation = true) } + + // Close the FAB menu when clicking the background inside the menu container + // (e.g., when tapping to the left of narrower buttons such as the "Add" button) + binding.fabLinearLayout.setOnClickListener { + if (isFABOpen) { + closeFloatingActionMenu(applyRiseAndShrinkAnimation = true) + } + } + val addDeckListener = View.OnClickListener { if (isFABOpen) { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DrawingFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DrawingFragment.kt index 42ee61a1ed73..50e550ab5a42 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DrawingFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DrawingFragment.kt @@ -94,7 +94,7 @@ class DrawingFragment : Fragment(R.layout.fragment_drawing) { val imagePath = saveWhiteboard(whiteboardView) val result = Intent().apply { - putExtra(IMAGE_PATH_KEY, imagePath) + putExtra(EXTRA_IMAGE_PATH, imagePath) } // TODO don't depend on an Activity requireActivity().setResult(Activity.RESULT_OK, result) @@ -130,7 +130,7 @@ class DrawingFragment : Fragment(R.layout.fragment_drawing) { } companion object { - const val IMAGE_PATH_KEY = "path" + const val EXTRA_IMAGE_PATH = "path" fun getIntent(context: Context): Intent = SingleFragmentActivity.getIntent(context, DrawingFragment::class) } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/IntroductionActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/IntroductionActivity.kt index c7e8bd5cc6ae..9509ad4f4ce3 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/IntroductionActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/IntroductionActivity.kt @@ -20,7 +20,7 @@ import com.ichi2.anki.common.preferences.sharedPrefs import com.ichi2.anki.introduction.CollectionPermissionScreenLauncher import com.ichi2.anki.introduction.SetupCollectionFragment import com.ichi2.anki.introduction.SetupCollectionFragment.CollectionSetupOption -import com.ichi2.anki.introduction.SetupCollectionFragment.Companion.FRAGMENT_KEY +import com.ichi2.anki.introduction.SetupCollectionFragment.Companion.REQUEST_COLLECTION_SETUP import com.ichi2.anki.introduction.SetupCollectionFragment.Companion.RESULT_KEY import com.ichi2.anki.introduction.hasCollectionStoragePermissions import com.ichi2.anki.utils.ext.setFragmentResultListener @@ -63,7 +63,7 @@ class IntroductionActivity : super.onCreate(savedInstanceState) enableEdgeToEdge(statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT)) - setFragmentResultListener(FRAGMENT_KEY) { _, bundle -> + setFragmentResultListener(REQUEST_COLLECTION_SETUP) { _, bundle -> val option = BundleCompat.getParcelable(bundle, RESULT_KEY, CollectionSetupOption::class.java) ?: error("Missing introduction option!") when (option) { @@ -86,7 +86,7 @@ class IntroductionActivity : val deckPicker = Intent(this, DeckPicker::class.java) deckPicker.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) if (result == RESULT_SYNC_PROFILE) { - deckPicker.putExtra(DeckPicker.INTENT_SYNC_FROM_LOGIN, true) + deckPicker.putExtra(DeckPicker.EXTRA_SYNC_FROM_LOGIN, true) } startActivity(deckPicker) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/MediaRegistration.kt b/AnkiDroid/src/main/java/com/ichi2/anki/MediaRegistration.kt index 39889d4771f3..07d23332199a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/MediaRegistration.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/MediaRegistration.kt @@ -197,7 +197,8 @@ object MediaRegistration { } // register media for webView - if (!registerMediaForWebView(tempFilePath)) { + val importedFile = registerMediaForWebView(tempFilePath) + if (importedFile == null) { clipCopy.delete() return null } @@ -205,8 +206,7 @@ object MediaRegistration { val field = if (isImage) ImageField() else MediaClipField() - field.hasTemporaryMedia = true - field.mediaFile = tempFilePath + field.mediaFile = importedFile return field.formattedValue } @@ -231,25 +231,22 @@ object MediaRegistration { } /** + * @return the imported file, including any filename changes made by the backend, or null on failure + * * @throws OutOfMemoryError if the file could not be copied to a contiguous block of memory (or is >= 2GB) */ @CheckResult - fun registerMediaForWebView(mediaPath: File?): Boolean { - if (mediaPath == null) { - // Nothing to register - continue with execution. - return true - } + private fun registerMediaForWebView(mediaPath: File): File? { Timber.i("Adding media to collection: %s", mediaPath) - val f = mediaPath return try { - CollectionManager.getColUnsafe().media.addFile(f) - true + val media = CollectionManager.getColUnsafe().media + File(media.dir, media.addFile(mediaPath)) } catch (e: IOException) { Timber.w(e, "Failed to add file") - false + null } catch (e: EmptyMediaException) { Timber.w(e, "Failed to add file") - false + null } } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NavigationDrawerActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NavigationDrawerActivity.kt index b4c4adbed9b4..f3936f4ccff7 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/NavigationDrawerActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/NavigationDrawerActivity.kt @@ -306,7 +306,9 @@ abstract class NavigationDrawerActivity( override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) // Pass any configuration change to the drawer toggles - drawerToggle.onConfigurationChanged(newConfig) + if (::drawerToggle.isInitialized) { + drawerToggle.onConfigurationChanged(newConfig) + } } /** diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt index baee819a2a3a..1a8f7281fe0f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt @@ -145,6 +145,7 @@ import com.ichi2.anki.noteeditor.NoteEditorMultimediaController import com.ichi2.anki.noteeditor.Toolbar import com.ichi2.anki.noteeditor.Toolbar.TextFormatListener import com.ichi2.anki.noteeditor.Toolbar.TextWrapper +import com.ichi2.anki.noteeditor.data.ToolbarPreferences import com.ichi2.anki.observability.undoableOp import com.ichi2.anki.pages.ImageOcclusion import com.ichi2.anki.pages.viewmodel.ImageOcclusionArgs @@ -168,7 +169,7 @@ import com.ichi2.anki.utils.ext.showDialogFragment import com.ichi2.anki.utils.insetsOf import com.ichi2.anki.utils.openUrl import com.ichi2.imagecropper.ImageCropper -import com.ichi2.imagecropper.ImageCropper.Companion.CROP_IMAGE_RESULT +import com.ichi2.imagecropper.ImageCropper.Companion.EXTRA_CROP_IMAGE_RESULT import com.ichi2.imagecropper.ImageCropperLauncher import com.ichi2.utils.AndroidUiUtils.showSoftInput import com.ichi2.utils.ClipboardUtil @@ -199,6 +200,7 @@ import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt import com.ichi2.anki.common.android.R as CommonR +import com.ichi2.anki.noteeditor.R as NoteEditorR const val CALLER_KEY = "caller" @@ -299,6 +301,7 @@ class NoteEditorFragment : private var sourceText: Array? = null private val fieldState = FieldState.fromEditor(this) private lateinit var toolbar: Toolbar + private val toolbarPreferences by lazy { ToolbarPreferences(sharedPrefs()) } // save field index as key and text as value when toggle sticky clicked in Field Edit Text @VisibleForTesting @@ -323,7 +326,7 @@ class NoteEditorFragment : * If true, this fragment is on the trailing side of the card browser. */ private val inCardBrowserActivity - get() = requireArguments().getBoolean(IN_CARD_BROWSER_ACTIVITY) + get() = requireArguments().getBoolean(EXTRA_IN_CARD_BROWSER_ACTIVITY) private val requestAddLauncher = registerForActivityResult( @@ -351,9 +354,7 @@ class NoteEditorFragment : } @VisibleForTesting - internal val multimediaController: NoteEditorMultimediaController by lazy { - NoteEditorMultimediaController(this, multimediaFragmentLauncher) - } + internal val multimediaController: NoteEditorMultimediaController = NoteEditorMultimediaController(this, multimediaFragmentLauncher) private val requestTemplateEditLauncher = registerForActivityResult( @@ -928,7 +929,7 @@ class NoteEditorFragment : result.data?.let { val cropResultData = - it.getParcelableExtraCompat(CROP_IMAGE_RESULT) + it.getParcelableExtraCompat(EXTRA_CROP_IMAGE_RESULT) Timber.d("Cropped image data: $cropResultData") if (cropResultData?.uriPath == null) return@registerForActivityResult setupImageOcclusionEditor(cropResultData.uriPath) @@ -1501,15 +1502,12 @@ class NoteEditorFragment : } } } - menu.findItem(R.id.action_show_toolbar).isChecked = - !shouldHideToolbar() + menu.findItem(R.id.action_show_toolbar).isChecked = toolbarPreferences.isVisible menu.findItem(R.id.action_capitalize).isChecked = sharedPrefs().getBoolean(PREF_NOTE_EDITOR_CAPITALIZE, true) menu.findItem(R.id.action_scroll_toolbar).apply { - isChecked = - sharedPrefs().getBoolean(PREF_NOTE_EDITOR_SCROLL_TOOLBAR, true) - isEnabled = - !shouldHideToolbar() + isChecked = toolbarPreferences.isScrollable + isEnabled = toolbarPreferences.isVisible } } @@ -1587,9 +1585,7 @@ class NoteEditorFragment : } R.id.action_show_toolbar -> { item.isChecked = !item.isChecked - this.sharedPrefs().edit { - putBoolean(PREF_NOTE_EDITOR_SHOW_TOOLBAR, item.isChecked) - } + toolbarPreferences.isVisible = item.isChecked updateToolbar() // Update the overflow action menu in order to switch the enable/disable status of the "Scroll toolbar" item on the spot @@ -1603,9 +1599,7 @@ class NoteEditorFragment : } R.id.action_scroll_toolbar -> { item.isChecked = !item.isChecked - this.sharedPrefs().edit { - putBoolean(PREF_NOTE_EDITOR_SCROLL_TOOLBAR, item.isChecked) - } + toolbarPreferences.isScrollable = item.isChecked updateToolbar() } } @@ -2352,7 +2346,7 @@ class NoteEditorFragment : } private fun updateToolbar() { - toolbar.isVisible = !shouldHideToolbar() + toolbar.isVisible = toolbarPreferences.isVisible applyBottomInset() if (!toolbar.isVisible) return toolbar.clearCustomItems() @@ -2398,7 +2392,7 @@ class NoteEditorFragment : // Let the user add more buttons (always at the end). // Sets the add custom tag icon color. - val drawable = ResourcesCompat.getDrawable(resources, R.drawable.ic_add_toolbar_icon, null) + val drawable = ResourcesCompat.getDrawable(resources, NoteEditorR.drawable.ic_add_toolbar_icon, null) drawable!!.setTint(MaterialColors.getColor(requireContext(), R.attr.toolbarIconColor, 0)) val addButton = toolbar.insertItem(0, drawable) { displayAddToolbarDialog() } addButton.contentDescription = resources.getString(CommonString.add_toolbar_item) @@ -2853,7 +2847,7 @@ class NoteEditorFragment : const val EXTRA_NOTE_CHANGED = "noteChanged" const val EXTRA_RELOAD_REQUIRED = "reloadRequired" const val EXTRA_IMG_OCCLUSION = "image_uri" - const val IN_CARD_BROWSER_ACTIVITY = "inCardBrowserActivity" + const val EXTRA_IN_CARD_BROWSER_ACTIVITY = "inCardBrowserActivity" const val EXTRA_CARD_IDS = "EXTRA_CARD_IDS" const val REQUEST_DECK_SELECTION_NOTE_EDITOR = "request_deck_selection_note_editor" @@ -2883,8 +2877,6 @@ class NoteEditorFragment : const val RESULT_UPDATED_IO_NOTE = 11 // preferences keys - const val PREF_NOTE_EDITOR_SCROLL_TOOLBAR = "noteEditorScrollToolbar" - private const val PREF_NOTE_EDITOR_SHOW_TOOLBAR = "noteEditorShowToolbar" private const val PREF_NOTE_EDITOR_NEWLINE_REPLACE = "noteEditorNewlineReplace" private const val PREF_NOTE_EDITOR_CAPITALIZE = "note_editor_capitalize" private const val PREF_NOTE_EDITOR_FONT_SIZE = "note_editor_font_size" @@ -2913,10 +2905,5 @@ class NoteEditorFragment : if (ImportUtils.isInvalidViewIntent(intent)) return false return intent.resolveMimeType()?.startsWith("image/") == true } - - private fun shouldHideToolbar(): Boolean = - !appContext - .sharedPrefs() - .getBoolean(PREF_NOTE_EDITOR_SHOW_TOOLBAR, true) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NoteTypeFieldEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NoteTypeFieldEditor.kt index 4ae9410e5dd8..1a865ccb55a4 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/NoteTypeFieldEditor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/NoteTypeFieldEditor.kt @@ -36,8 +36,8 @@ import com.ichi2.anki.databinding.ActivityNoteTypeFieldEditorBinding import com.ichi2.anki.databinding.ItemNotetypeFieldBinding import com.ichi2.anki.dialogs.ConfirmationDialog import com.ichi2.anki.dialogs.LocaleSelectionDialog -import com.ichi2.anki.dialogs.LocaleSelectionDialog.Companion.KEY_SELECTED_LOCALE import com.ichi2.anki.dialogs.LocaleSelectionDialog.Companion.REQUEST_HINT_LOCALE_SELECTION +import com.ichi2.anki.dialogs.LocaleSelectionDialog.Companion.RESULT_SELECTED_LOCALE import com.ichi2.anki.dialogs.NoteTypeFieldEditorContextMenu.Companion.newInstance import com.ichi2.anki.dialogs.NoteTypeFieldEditorContextMenu.NoteTypeFieldEditorContextMenuAction import com.ichi2.anki.libanki.Collection @@ -123,7 +123,7 @@ class NoteTypeFieldEditor : AnkiActivity(R.layout.activity_note_type_field_edito val selectedLocale = BundleCompat.getSerializable( bundle, - KEY_SELECTED_LOCALE, + RESULT_SELECTED_LOCALE, Locale::class.java, ) if (selectedLocale != null) { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt b/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt index bce8c357ca36..b8abe1e04b16 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt @@ -19,6 +19,7 @@ import com.ichi2.anki.observability.ChangeManager.notifySubscribersAllValuesChan import com.ichi2.anki.settings.Prefs import com.ichi2.anki.settings.enums.ShouldFetchMedia import com.ichi2.anki.snackbar.showSnackbar +import com.ichi2.anki.sync.MediaSync import com.ichi2.anki.sync.MeteredSyncPermission import com.ichi2.anki.sync.MeteredSyncPolicy import com.ichi2.anki.sync.SyncAuth @@ -284,7 +285,12 @@ private suspend fun handleDownload( Timber.i("Sync: Backup skipped before full download: collection unchanged") } close(downgrade = false, forFullSync = true) - fullUploadOrDownload(auth, upload = false, serverUsn = mediaUsn) + // Media starts through SyncMediaWorker so WorkManager can enforce network constraints. + fullUploadOrDownload( + auth, + upload = false, + mediaSync = MediaSync.Skip, + ) } finally { reopen(afterFullSync = true) } @@ -318,7 +324,12 @@ private suspend fun handleUpload( ) { close(downgrade = false, forFullSync = true) try { - fullUploadOrDownload(auth, upload = true, serverUsn = mediaUsn) + // Media starts through SyncMediaWorker so WorkManager can enforce network constraints. + fullUploadOrDownload( + auth, + upload = true, + mediaSync = MediaSync.Skip, + ) } finally { reopen(afterFullSync = true) } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/TtsVoices.kt b/AnkiDroid/src/main/java/com/ichi2/anki/TtsVoices.kt index 977fbc5851de..4c91cfff2d72 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/TtsVoices.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/TtsVoices.kt @@ -55,8 +55,8 @@ object TtsVoices { /** Published before scanning other engines, so legacy TTS need not wait for them. */ private val engineLocaleData = AtomicReference(CompletableDeferred()) - /** An immutable list of voices available for TTS */ - private lateinit var availableVoices: Set + /** Voices in engine-preference order: the default engine first, then the other engines. */ + private lateinit var availableVoices: List /** A job which populates both the default-engine languages and all available voices. */ private var buildLocalesJob: Job? = null @@ -105,9 +105,10 @@ object TtsVoices { } /** - * Returns the list of available voices for use in TTS + * Returns distinct voices from the default engine first, then other engines in discovery order. + * Voice order within each engine is preserved. Playback uses this order for language fallback. */ - suspend fun allTtsVoices(): Set { + suspend fun allTtsVoices(): List { if (this::availableVoices.isInitialized) { return this.availableVoices } @@ -150,7 +151,7 @@ object TtsVoices { internal suspend fun loadVoices( defaultLocales: CompletableDeferred, createTts: suspend (String?) -> TextToSpeech?, - ): Set { + ): List { // A default-engine instance is needed first to enumerate the installed engines val probeTts = try { @@ -163,16 +164,16 @@ object TtsVoices { if (probeTts == null) { Timber.e("Unable to build list of TTS Voices") defaultLocales.complete(EngineLocales(null, emptyList())) - return emptySet() + return emptyList() } val enginePackages = try { val engine = probeTts.defaultEngine defaultLocales.complete(EngineLocales(engine, readLocales(probeTts))) - // `engines` lists every installed engine; include the default defensively + // Put the default first, preserving discovery order for the remaining engines. try { - (probeTts.engines.map { it.name } + listOfNotNull(engine)).distinct() + (listOfNotNull(engine) + probeTts.engines.map { it.name }).distinct() } catch (e: Exception) { Timber.w(e, "unable to list TTS engines") listOfNotNull(engine) @@ -209,15 +210,16 @@ object TtsVoices { * * Each engine is initialised independently so a single misbehaving engine cannot prevent the * others from being listed. + * Voices are distinct by engine and voice name, keeping the first occurrence. * * @param createTts builds a [TextToSpeech] bound to the provided engine package, or `null` on failure - * @return the union of all voices across [enginePackages] + * @return all voices in [enginePackages] order, preserving each engine's voice order */ internal suspend fun loadVoicesFromEngines( enginePackages: List, createTts: suspend (engine: String) -> TextToSpeech?, - ): Set { - val voices = mutableSetOf() + ): List { + val voices = mutableListOf() for (engine in enginePackages) { val tts = createTts(engine) if (tts == null) { @@ -234,7 +236,7 @@ object TtsVoices { tts.shutdown() } } - return voices + return voices.distinctBy { it.engine to it.voice.name } } /** diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt index dc837e4f137e..d3d951d8f14b 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt @@ -99,6 +99,7 @@ import com.ichi2.anki.browser.search.SearchString import com.ichi2.anki.browser.search.SortOrderBottomSheetFragment import com.ichi2.anki.browser.search.StandardSearchFragment import com.ichi2.anki.browser.search.formatChipDescription +import com.ichi2.anki.browser.search.getIcon import com.ichi2.anki.browser.search.iconRes import com.ichi2.anki.browser.search.savedFilters import com.ichi2.anki.common.ALL_DECKS_ID @@ -109,7 +110,7 @@ import com.ichi2.anki.common.utils.ext.ifNotZero import com.ichi2.anki.dialogs.BrowserOptionsDialog import com.ichi2.anki.dialogs.ChangeNoteTypeDialog import com.ichi2.anki.dialogs.DeckSelectionDialog -import com.ichi2.anki.dialogs.DeckSelectionDialog.Companion.ARG_SELECTED_DECK +import com.ichi2.anki.dialogs.DeckSelectionDialog.Companion.RESULT_SELECTED_DECK import com.ichi2.anki.dialogs.GradeNowDialog import com.ichi2.anki.dialogs.SaveBrowserSearchDialogFragment import com.ichi2.anki.dialogs.SavedBrowserSearchesDialogFragment @@ -326,7 +327,7 @@ class CardBrowserFragment : // onSearchForDecks starts deck selection using childFragmentManager childFragmentManager.setFragmentResultListener(DeckSelectionDialog.REQUEST_SELECT_DECK, this) { _, bundle -> - val selectedDeck = bundle.getParcelableCompat(ARG_SELECTED_DECK) + val selectedDeck = bundle.getParcelableCompat(RESULT_SELECTED_DECK) activityViewModel.setSelectedDeck(requireNotNull(selectedDeck) { "Expected non-null deck" }) } @@ -1198,15 +1199,7 @@ class CardBrowserFragment : tagsChip?.hasCheckedBackground = filters.tags.any() cardStateChip?.text = formatChipDescription(filters.cardStates.map { it.label }, emptyValue = "Card state") - cardStateChip?.chipIcon = - ContextCompat.getDrawable(requireContext(), filters.cardStates.firstOrNull().iconRes)?.also { - if (filters.cardStates.isEmpty()) { - DrawableCompat.setTint( - it.mutate(), - MaterialColors.getColor(requireContext(), androidx.appcompat.R.attr.colorPrimary, 0), - ) - } - } + cardStateChip?.chipIcon = filters.cardStates.firstOrNull().getIcon(requireContext()) cardStateChip?.hasCheckedBackground = filters.cardStates.any() // Flags: Use the icon/checked state to explain what the filter is. @@ -1300,10 +1293,10 @@ class CardBrowserFragment : private fun setupFragmentResultListeners() { ankiActivity.setFragmentResultListener(REQUEST_REPOSITION_NEW_CARDS) { _, bundle -> repositionCardsNoValidation( - position = bundle.getInt(RepositionCardFragment.ARG_POSITION), - step = bundle.getInt(RepositionCardFragment.ARG_STEP), - shuffle = bundle.getBoolean(RepositionCardFragment.ARG_RANDOM), - shift = bundle.getBoolean(RepositionCardFragment.ARG_SHIFT), + position = bundle.getInt(RepositionCardFragment.RESULT_POSITION), + step = bundle.getInt(RepositionCardFragment.RESULT_STEP), + shuffle = bundle.getBoolean(RepositionCardFragment.RESULT_RANDOM), + shift = bundle.getBoolean(RepositionCardFragment.RESULT_SHIFT), ) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/FindAndReplaceDialogFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/FindAndReplaceDialogFragment.kt index 4cf9d5c73744..85bbd31be294 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/FindAndReplaceDialogFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/FindAndReplaceDialogFragment.kt @@ -20,13 +20,13 @@ import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.CommonString import com.ichi2.anki.R import com.ichi2.anki.analytics.AnalyticsDialogFragment -import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.ARG_FIELD -import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.ARG_MATCH_CASE -import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.ARG_ONLY_SELECTED_NOTES -import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.ARG_REGEX -import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.ARG_REPLACEMENT -import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.ARG_SEARCH import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.REQUEST_FIND_AND_REPLACE +import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.RESULT_FIELD +import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.RESULT_MATCH_CASE +import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.RESULT_ONLY_SELECTED_NOTES +import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.RESULT_REGEX +import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.RESULT_REPLACEMENT +import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.RESULT_SEARCH import com.ichi2.anki.databinding.FragmentFindReplaceBinding import com.ichi2.anki.libanki.NoteId import com.ichi2.anki.notetype.ManageNotetypes @@ -147,13 +147,13 @@ class FindAndReplaceDialogFragment : AnalyticsDialogFragment() { setFragmentResult( REQUEST_FIND_AND_REPLACE, Bundle().apply { - putString(ARG_SEARCH, search.toString()) - putString(ARG_REPLACEMENT, replacement.toString()) - putString(ARG_FIELD, selectedField) - putBoolean(ARG_ONLY_SELECTED_NOTES, onlyInSelectedNotes) + putString(RESULT_SEARCH, search.toString()) + putString(RESULT_REPLACEMENT, replacement.toString()) + putString(RESULT_FIELD, selectedField) + putBoolean(RESULT_ONLY_SELECTED_NOTES, onlyInSelectedNotes) // "Ignore case" checkbox text => when it's checked we pass false to the backend - putBoolean(ARG_MATCH_CASE, !ignoreCase) - putBoolean(ARG_REGEX, inputAsRegex) + putBoolean(RESULT_MATCH_CASE, !ignoreCase) + putBoolean(RESULT_REGEX, inputAsRegex) }, ) } @@ -167,21 +167,21 @@ class FindAndReplaceDialogFragment : AnalyticsDialogFragment() { const val TAG = "FindAndReplaceDialogFragment" const val REQUEST_FIND_AND_REPLACE = "request_find_and_replace" const val ARG_IDS = "arg_ids" - const val ARG_SEARCH = "arg_search" - const val ARG_REPLACEMENT = "arg_replacement" - const val ARG_FIELD = "arg_field" - const val ARG_ONLY_SELECTED_NOTES = "arg_only_selected_notes" - const val ARG_MATCH_CASE = "arg_match_case" - const val ARG_REGEX = "arg_regex" + const val RESULT_SEARCH = "arg_search" + const val RESULT_REPLACEMENT = "arg_replacement" + const val RESULT_FIELD = "arg_field" + const val RESULT_ONLY_SELECTED_NOTES = "arg_only_selected_notes" + const val RESULT_MATCH_CASE = "arg_match_case" + const val RESULT_REGEX = "arg_regex" /** - * Receiving this value in the result [Bundle] for the [ARG_FIELD] entry means that + * Receiving this value in the result [Bundle] for the [RESULT_FIELD] entry means that * the user selected "All fields" as the field target for the find and replace action. */ const val ALL_FIELDS_AS_FIELD = "find_and_replace_dialog_fragment_all_fields_as_field" /** - * Receiving this value in the result [Bundle] for the [ARG_FIELD] entry means that + * Receiving this value in the result [Bundle] for the [RESULT_FIELD] entry means that * the user selected "Tags" as the field target for the find and replace action. */ const val TAGS_AS_FIELD = "find_and_replace_dialog_fragment_tags_as_field" @@ -205,12 +205,12 @@ fun CardBrowser.registerFindReplaceHandler(action: (FindReplaceResult) -> Unit) setFragmentResultListener(REQUEST_FIND_AND_REPLACE) { _, bundle -> action( FindReplaceResult( - search = bundle.getString(ARG_SEARCH) ?: error("Missing required argument: search"), - replacement = bundle.getString(ARG_REPLACEMENT) ?: error("Missing required argument: replacement"), - field = bundle.getString(ARG_FIELD) ?: error("Missing required argument: field"), - onlyOnSelectedNotes = bundle.getBoolean(ARG_ONLY_SELECTED_NOTES, true), - matchCase = bundle.getBoolean(ARG_MATCH_CASE, false), - regex = bundle.getBoolean(ARG_REGEX, false), + search = bundle.getString(RESULT_SEARCH) ?: error("Missing required argument: search"), + replacement = bundle.getString(RESULT_REPLACEMENT) ?: error("Missing required argument: replacement"), + field = bundle.getString(RESULT_FIELD) ?: error("Missing required argument: field"), + onlyOnSelectedNotes = bundle.getBoolean(RESULT_ONLY_SELECTED_NOTES, true), + matchCase = bundle.getBoolean(RESULT_MATCH_CASE, false), + regex = bundle.getBoolean(RESULT_REGEX, false), ), ) } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/RepositionCardFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/RepositionCardFragment.kt index a4b5bb17cda8..7b59431ab7ec 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/RepositionCardFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/RepositionCardFragment.kt @@ -75,10 +75,10 @@ class RepositionCardFragment : DialogFragment() { setFragmentResult( REQUEST_REPOSITION_NEW_CARDS, Bundle().apply { - putInt(ARG_POSITION, position) - putInt(ARG_STEP, step) - putBoolean(ARG_RANDOM, binding.randomizeOrderCheck.isChecked) - putBoolean(ARG_SHIFT, binding.shiftPositionCheck.isChecked) + putInt(RESULT_POSITION, position) + putInt(RESULT_STEP, step) + putBoolean(RESULT_RANDOM, binding.randomizeOrderCheck.isChecked) + putBoolean(RESULT_SHIFT, binding.shiftPositionCheck.isChecked) }, ) } @@ -124,10 +124,13 @@ class RepositionCardFragment : DialogFragment() { const val REQUEST_REPOSITION_NEW_CARDS = "request_repositions_new_cards" private const val ARG_QUEUE_TOP = "arg_queue_top" private const val ARG_QUEUE_BOTTOM = "arg_queue_bottom" - const val ARG_POSITION = "arg_position" - const val ARG_STEP = "arg_step" - const val ARG_RANDOM = "arg_random" - const val ARG_SHIFT = "arg_shift" + const val RESULT_POSITION = "arg_position" + const val RESULT_STEP = "arg_step" + const val RESULT_RANDOM = "arg_random" + const val RESULT_SHIFT = "arg_shift" + + private const val ARG_RANDOM = "arg_random" + private const val ARG_SHIFT = "arg_shift" fun newInstance( queueTop: Int, diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/CardStateBottomSheetFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/CardStateBottomSheetFragment.kt index 680551fecfae..57ac08ad292a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/CardStateBottomSheetFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/CardStateBottomSheetFragment.kt @@ -2,15 +2,19 @@ package com.ichi2.anki.browser.search +import android.content.Context +import android.graphics.drawable.Drawable import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.annotation.DrawableRes +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.DrawableCompat import androidx.fragment.app.activityViewModels import androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.color.MaterialColors import com.ichi2.anki.R import com.ichi2.anki.databinding.FragmentBottomSheetListBinding import com.ichi2.anki.databinding.ItemBrowserFilterBottomSheetBinding @@ -92,7 +96,7 @@ class CardStateBottomSheetFragment : BottomSheetDialogFragment(R.layout.fragment val state = this.states[position] holder.binding.text.text = state.label holder.binding.checkbox.contentDescription = state.label - holder.binding.icon.setImageResource(state.iconRes) + holder.binding.icon.setImageDrawable(state.getIcon(holder.itemView.context)) holder.binding.root.setOnClickListener { onItemClickedListener(state) } holder.binding.checkbox.apply { this.isChecked = checkedItems.contains(state) @@ -121,14 +125,19 @@ class CardStateBottomSheetFragment : BottomSheetDialogFragment(R.layout.fragment } } -@get:DrawableRes -val CardState?.iconRes - get() = +/** Returns the card-state icon tinted with the current theme's state color. */ +fun CardState?.getIcon(context: Context): Drawable? { + val iconRes = if (this == null) R.drawable.ic_card_state_default else R.drawable.ic_card_state + val colorAttr = when (this) { - null -> R.drawable.ic_card_state_default - CardState.New -> R.drawable.ic_card_state_new - CardState.Learning -> R.drawable.ic_card_state_learning - CardState.Review -> R.drawable.ic_card_state_review - CardState.Buried -> R.drawable.ic_card_state_buried - CardState.Suspended -> R.drawable.ic_card_state_suspended + null -> androidx.appcompat.R.attr.colorPrimary + CardState.New -> R.attr.newCountColor + CardState.Learning -> R.attr.learnCountColor + CardState.Review -> R.attr.reviewCountColor + CardState.Buried -> R.attr.buriedColor + CardState.Suspended -> R.attr.suspendedColor } + return ContextCompat.getDrawable(context, iconRes)?.mutate()?.also { + DrawableCompat.setTint(it, MaterialColors.getColor(context, colorAttr, 0)) + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/StandardSearchFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/StandardSearchFragment.kt index bbfbf70c7e57..1a131aa6ed40 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/StandardSearchFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/StandardSearchFragment.kt @@ -132,15 +132,7 @@ class StandardSearchFragment : binding.decksChip.text = it.decks.firstOrNull()?.name ?: TR.sentenceCase.allDecks binding.tagsChip.text = formatChipDescription(it.tags, emptyValue = "Tags") binding.cardStateChip.text = formatChipDescription(it.cardStates.map { it.label }, emptyValue = "Card state") - binding.cardStateChip.chipIcon = - ContextCompat.getDrawable(requireContext(), it.cardStates.firstOrNull().iconRes)?.also { drawable -> - if (it.cardStates.isEmpty()) { - DrawableCompat.setTint( - drawable.mutate(), - MaterialColors.getColor(requireContext(), androidx.appcompat.R.attr.colorPrimary, 0), - ) - } - } + binding.cardStateChip.chipIcon = it.cardStates.firstOrNull().getIcon(requireContext()) binding.flagsChip.text = formatChipDescription( diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswer.kt index 074ae75fa06d..89a6bd612cb4 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswer.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswer.kt @@ -153,14 +153,7 @@ class TypeAnswer( """
    0) { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswerKeyboard.kt b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswerKeyboard.kt new file mode 100644 index 000000000000..8787ef1676bf --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswerKeyboard.kt @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.cardviewer + +/** Appends the HTML answer's keyboard attributes, including a trailing space. */ +internal fun StringBuilder.appendNoSuggestAttributes() { + append("data-ankidroid-nosuggest=\"true\"") + append(' ') + append("enterkeyhint=\"done\"") + append(' ') +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswerWebView.kt b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswerWebView.kt index 42327e1e81c5..9de1937c4ff9 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswerWebView.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswerWebView.kt @@ -3,12 +3,14 @@ package com.ichi2.anki.cardviewer import android.content.Context +import android.util.AttributeSet import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputConnection import android.view.inputmethod.InputMethodManager import android.webkit.JavascriptInterface import android.webkit.WebView import androidx.core.content.getSystemService +import com.ichi2.anki.workarounds.SafeWebViewLayout import com.ichi2.ui.applyNoSuggest /** Applies `nosuggest` to the focused HTML answer field's keyboard connection. */ @@ -47,3 +49,11 @@ open class TypeAnswerWebView( super.destroy() } } + +/** Uses the same keyboard support when the new reviewer recreates its WebView after a crash. */ +class TypeAnswerWebViewLayout( + context: Context, + attrs: AttributeSet?, +) : SafeWebViewLayout(context, attrs) { + override fun createWebView(): WebView = TypeAnswerWebView(context) +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckPickerContextMenu.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckPickerContextMenu.kt index 85f24b7ec1de..850022f79f6b 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckPickerContextMenu.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckPickerContextMenu.kt @@ -134,19 +134,19 @@ data class DeckPickerContextMenuResult( ) { fun toBundle(): Bundle = Bundle().apply { - putLong(ARG_DECK_ID, deckId) - putSerializable(ARG_OPTION, option) + putLong(RESULT_DECK_ID, deckId) + putSerializable(RESULT_CONTEXT_MENU_OPTION, option) } companion object { const val REQUEST_KEY = "request_key_deck_picker_context_menu" - private const val ARG_DECK_ID = "deck_id" - private const val ARG_OPTION = "option" + private const val RESULT_DECK_ID = "deck_id" + private const val RESULT_CONTEXT_MENU_OPTION = "option" fun fromBundle(bundle: Bundle) = DeckPickerContextMenuResult( - deckId = bundle.requireLong(ARG_DECK_ID), - option = bundle.requireSerializableCompat(ARG_OPTION), + deckId = bundle.requireLong(RESULT_DECK_ID), + option = bundle.requireSerializableCompat(RESULT_CONTEXT_MENU_OPTION), ) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckSelectionDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckSelectionDialog.kt index 44a83beb5447..4758eb35abfb 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckSelectionDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckSelectionDialog.kt @@ -38,8 +38,8 @@ import com.ichi2.anki.common.annotations.NeedsTest import com.ichi2.anki.databinding.DialogDeckPickerBinding import com.ichi2.anki.databinding.ItemDeckPickerDialogBinding import com.ichi2.anki.deckpicker.DeckFilters -import com.ichi2.anki.dialogs.DeckSelectionDialog.Companion.ARG_SELECTED_DECK import com.ichi2.anki.dialogs.DeckSelectionDialog.Companion.REQUEST_SELECT_DECK +import com.ichi2.anki.dialogs.DeckSelectionDialog.Companion.RESULT_SELECTED_DECK import com.ichi2.anki.launchCatchingTask import com.ichi2.anki.libanki.Consts.DEFAULT_DECK_ID import com.ichi2.anki.libanki.DeckId @@ -187,7 +187,7 @@ class DeckSelectionDialog : AnalyticsDialogFragment() { parentFragmentManager.setFragmentResult( requestKey, Bundle().apply { - putParcelable(ARG_SELECTED_DECK, deck) + putParcelable(RESULT_SELECTED_DECK, deck) }, ) } @@ -414,7 +414,7 @@ class DeckSelectionDialog : AnalyticsDialogFragment() { companion object { const val TAG = "DeckSelectionDialog" const val REQUEST_SELECT_DECK = "request_select_deck" - const val ARG_SELECTED_DECK = "arg_selected_deck" + const val RESULT_SELECTED_DECK = "arg_selected_deck" const val ARG_REQUEST_KEY = "arg_request_key" const val ARG_ALLOW_ALL = "arg_allow_all" const val ARG_ALLOW_FILTERED = "arg_allow_filtered" @@ -462,7 +462,7 @@ fun FragmentActivity.registerDeckSelectedHandler( action: (deck: SelectableDeck?) -> Unit, ) { setFragmentResultListener(requestKey) { _, bundle -> - val selectedDeck = bundle.getParcelableCompat(ARG_SELECTED_DECK) + val selectedDeck = bundle.getParcelableCompat(RESULT_SELECTED_DECK) action(selectedDeck) } } @@ -479,7 +479,7 @@ fun Fragment.registerDeckSelectedHandler( action: (deck: SelectableDeck?) -> Unit, ) { setFragmentResultListener(requestKey) { _, bundle -> - val selectedDeck = bundle.getParcelableCompat(ARG_SELECTED_DECK) + val selectedDeck = bundle.getParcelableCompat(RESULT_SELECTED_DECK) action(selectedDeck) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ExportReadyDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ExportReadyDialog.kt index ea40a215ccee..e9db081842ee 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ExportReadyDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ExportReadyDialog.kt @@ -16,7 +16,7 @@ import timber.log.Timber class ExportReadyDialog : DialogFragment() { private val exportPath - get() = requireArguments().requireString(KEY_EXPORT_PATH) + get() = requireArguments().requireString(ARG_EXPORT_PATH) private val asText: Boolean get() = requireArguments().getBoolean(ARG_SHARE_AS_TEXT, false) @@ -28,14 +28,14 @@ class ExportReadyDialog : DialogFragment() { .positiveButton(CommonString.export_choice_save_to) { parentFragmentManager.setFragmentResult( REQUEST_EXPORT_SAVE, - Bundle().apply { putString(KEY_EXPORT_PATH, exportPath) }, + Bundle().apply { putString(RESULT_EXPORT_PATH, exportPath) }, ) }.negativeButton(CommonString.export_choice_share) { parentFragmentManager.setFragmentResult( REQUEST_EXPORT_SHARE, Bundle().apply { - putString(KEY_EXPORT_PATH, exportPath) - putBoolean(ARG_SHARE_AS_TEXT, asText) + putString(RESULT_EXPORT_PATH, exportPath) + putBoolean(RESULT_SHARE_AS_TEXT, asText) }, ) } @@ -46,8 +46,11 @@ class ExportReadyDialog : DialogFragment() { companion object { const val REQUEST_EXPORT_SAVE = "request_export_save" const val REQUEST_EXPORT_SHARE = "request_export_share" - const val KEY_EXPORT_PATH = "key_export_path" - const val ARG_SHARE_AS_TEXT = "arg_share_as_text" + const val RESULT_EXPORT_PATH = "key_export_path" + const val RESULT_SHARE_AS_TEXT = "arg_share_as_text" + + private const val ARG_EXPORT_PATH = "key_export_path" + private const val ARG_SHARE_AS_TEXT = "arg_share_as_text" fun newInstance( exportPath: String, @@ -55,7 +58,7 @@ class ExportReadyDialog : DialogFragment() { ) = ExportReadyDialog().apply { arguments = Bundle().apply { - putString(KEY_EXPORT_PATH, exportPath) + putString(ARG_EXPORT_PATH, exportPath) putBoolean(ARG_SHARE_AS_TEXT, asText) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt index d0710c2dd91f..59172fcae434 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt @@ -107,7 +107,7 @@ class InsertFieldDialog : DialogFragment(R.layout.dialog_insert_field) { if (field == null) return@collect parentFragmentManager.setFragmentResult( requestKey, - Bundle().apply { putString(KEY_INSERTED_FIELD, field.renderToTemplateTag()) }, + Bundle().apply { putString(RESULT_INSERTED_FIELD, field.renderToTemplateTag()) }, ) dismiss() } @@ -132,7 +132,7 @@ class InsertFieldDialog : DialogFragment(R.layout.dialog_insert_field) { * * Represents the template tag for the selected field: `{{Front}}` */ - const val KEY_INSERTED_FIELD = "key_inserted_field" + const val RESULT_INSERTED_FIELD = "key_inserted_field" /** * Creates a new instance of [InsertFieldDialog] diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/LocaleSelectionDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/LocaleSelectionDialog.kt index 3f9dfb1d318b..96eb5a1dd79f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/LocaleSelectionDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/LocaleSelectionDialog.kt @@ -87,7 +87,7 @@ class LocaleSelectionDialog : AnalyticsDialogFragment() { private fun sendSelectionResult(locale: Locale? = null) { parentFragmentManager.setFragmentResult( REQUEST_HINT_LOCALE_SELECTION, - Bundle().apply { putSerializable(KEY_SELECTED_LOCALE, locale) }, + Bundle().apply { putSerializable(RESULT_SELECTED_LOCALE, locale) }, ) } @@ -151,7 +151,7 @@ class LocaleSelectionDialog : AnalyticsDialogFragment() { companion object { const val REQUEST_HINT_LOCALE_SELECTION = "request_hint_locale_selection" - const val KEY_SELECTED_LOCALE = "key_selected_locale" + const val RESULT_SELECTED_LOCALE = "key_selected_locale" /** * Language identifier for International Phonetic Alphabet. This isn't available from [Locale.getAvailableLocales], but diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/SaveBrowserSearchDialogFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/SaveBrowserSearchDialogFragment.kt index eef776c9af4d..6094786aae10 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/SaveBrowserSearchDialogFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/SaveBrowserSearchDialogFragment.kt @@ -13,8 +13,8 @@ import com.ichi2.anki.CardBrowser import com.ichi2.anki.CommonString import com.ichi2.anki.R import com.ichi2.anki.browser.search.SavedSearch -import com.ichi2.anki.dialogs.SaveBrowserSearchDialogFragment.Companion.ARG_SEARCH_QUERY -import com.ichi2.anki.dialogs.SaveBrowserSearchDialogFragment.Companion.ARG_SEARCH_QUERY_NAME +import com.ichi2.anki.dialogs.SaveBrowserSearchDialogFragment.Companion.RESULT_SEARCH_QUERY +import com.ichi2.anki.dialogs.SaveBrowserSearchDialogFragment.Companion.RESULT_SEARCH_QUERY_NAME import com.ichi2.anki.launchCatchingTask import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.utils.input @@ -53,8 +53,8 @@ class SaveBrowserSearchDialogFragment : DialogFragment() { setFragmentResult( REQUEST_SAVE_SEARCH, Bundle().apply { - putString(ARG_SEARCH_QUERY, searchQuery) - putCharSequence(ARG_SEARCH_QUERY_NAME, name) + putString(RESULT_SEARCH_QUERY, searchQuery) + putCharSequence(RESULT_SEARCH_QUERY_NAME, name) }, ) dialog.dismiss() @@ -67,12 +67,14 @@ class SaveBrowserSearchDialogFragment : DialogFragment() { /** * The actual search query that the user is saving to use later. */ - const val ARG_SEARCH_QUERY = "arg_search_query" + const val RESULT_SEARCH_QUERY = "arg_search_query" + + private const val ARG_SEARCH_QUERY = "arg_search_query" /** * The name given by the user to the saved search. */ - const val ARG_SEARCH_QUERY_NAME = "arg_search_query_name" + const val RESULT_SEARCH_QUERY_NAME = "arg_search_query_name" fun newInstance(searchQuery: String): SaveBrowserSearchDialogFragment = SaveBrowserSearchDialogFragment().apply { @@ -94,7 +96,7 @@ fun CardBrowser.registerSaveSearchHandler() { this, ) { _, bundle -> val savedSearchName = - bundle.getString(ARG_SEARCH_QUERY_NAME) + bundle.getString(RESULT_SEARCH_QUERY_NAME) if (savedSearchName.isNullOrEmpty()) { showSnackbar( CommonString.card_browser_list_my_searches_new_search_error_empty_name, @@ -105,7 +107,7 @@ fun CardBrowser.registerSaveSearchHandler() { val toSave = SavedSearch( name = savedSearchName, - query = bundle.getString(ARG_SEARCH_QUERY) ?: return@setFragmentResultListener, + query = bundle.getString(RESULT_SEARCH_QUERY) ?: return@setFragmentResultListener, ) launchCatchingTask { @@ -125,7 +127,7 @@ fun Fragment.registerSaveSearchHandler(onSaveSearch: (SavedSearch) -> Unit) { this, ) { _, bundle -> val savedSearchName = - bundle.getString(ARG_SEARCH_QUERY_NAME) + bundle.getString(RESULT_SEARCH_QUERY_NAME) if (savedSearchName.isNullOrEmpty()) { showSnackbar( CommonString.card_browser_list_my_searches_new_search_error_empty_name, @@ -137,7 +139,7 @@ fun Fragment.registerSaveSearchHandler(onSaveSearch: (SavedSearch) -> Unit) { val savedSearch = SavedSearch( name = savedSearchName, - query = bundle.getString(ARG_SEARCH_QUERY) ?: return@setFragmentResultListener, + query = bundle.getString(RESULT_SEARCH_QUERY) ?: return@setFragmentResultListener, ) onSaveSearch(savedSearch) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/SavedBrowserSearchesDialogFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/SavedBrowserSearchesDialogFragment.kt index bb007d5974b2..0883f6903375 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/SavedBrowserSearchesDialogFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/SavedBrowserSearchesDialogFragment.kt @@ -16,7 +16,7 @@ import com.ichi2.anki.browser.search.SavedSearches import com.ichi2.anki.browser.search.toMap import com.ichi2.anki.compat.CompatHelper.Companion.getSerializableCompat import com.ichi2.anki.databinding.ItemSavedSearchBinding -import com.ichi2.anki.dialogs.SavedBrowserSearchesDialogFragment.Companion.ARG_SAVED_SEARCH +import com.ichi2.anki.dialogs.SavedBrowserSearchesDialogFragment.Companion.RESULT_SAVED_SEARCH import com.ichi2.anki.dialogs.SavedBrowserSearchesDialogFragment.Companion.TYPE_SEARCH_REMOVED import com.ichi2.anki.dialogs.SavedBrowserSearchesDialogFragment.Companion.TYPE_SEARCH_SELECTED import com.ichi2.anki.launchCatchingTask @@ -51,8 +51,8 @@ class SavedBrowserSearchesDialogFragment : AnalyticsDialogFragment() { parentFragmentManager.setFragmentResult( REQUEST_SAVED_SEARCH_ACTION, Bundle().apply { - putInt(ARG_TYPE, TYPE_SEARCH_SELECTED) - putString(ARG_SAVED_SEARCH, searchName) + putInt(RESULT_SAVED_SEARCH_ACTION, TYPE_SEARCH_SELECTED) + putString(RESULT_SAVED_SEARCH, searchName) }, ) dismiss() @@ -77,8 +77,8 @@ class SavedBrowserSearchesDialogFragment : AnalyticsDialogFragment() { parentFragmentManager.setFragmentResult( REQUEST_SAVED_SEARCH_ACTION, Bundle().apply { - putInt(ARG_TYPE, TYPE_SEARCH_REMOVED) - putString(ARG_SAVED_SEARCH, searchName) + putInt(RESULT_SAVED_SEARCH_ACTION, TYPE_SEARCH_REMOVED) + putString(RESULT_SAVED_SEARCH, searchName) }, ) dialog?.dismiss() // Dismiss the root dialog @@ -127,14 +127,14 @@ class SavedBrowserSearchesDialogFragment : AnalyticsDialogFragment() { /** * The user given name for the saved search that can be used as an identifier. */ - const val ARG_SAVED_SEARCH = "arg_saved_search" + const val RESULT_SAVED_SEARCH = "arg_saved_search" /** * [Int] number identifying the type of action for the saved search specified by - * [ARG_SAVED_SEARCH]. Currently there are just two possible values [TYPE_SEARCH_SELECTED] + * [RESULT_SAVED_SEARCH]. Currently there are just two possible values [TYPE_SEARCH_SELECTED] * and [TYPE_SEARCH_REMOVED]. */ - const val ARG_TYPE = "arg_type" + const val RESULT_SAVED_SEARCH_ACTION = "arg_type" private const val ARG_SAVED_FILTERS = "arg_saved_filters" fun newInstance(savedFilters: List): SavedBrowserSearchesDialogFragment = @@ -156,8 +156,8 @@ fun CardBrowser.registerSavedSearchActionHandler(action: (Int, String?) -> Unit) SavedBrowserSearchesDialogFragment.REQUEST_SAVED_SEARCH_ACTION, this, ) { _, bundle -> - val type = bundle.getInt(SavedBrowserSearchesDialogFragment.ARG_TYPE) - val searchName = bundle.getString(ARG_SAVED_SEARCH) + val type = bundle.getInt(SavedBrowserSearchesDialogFragment.RESULT_SAVED_SEARCH_ACTION) + val searchName = bundle.getString(RESULT_SAVED_SEARCH) Timber.d("On user saved search selection named: %s", searchName) action(type, searchName) } @@ -172,8 +172,8 @@ fun Fragment.registerSavedSearchActionHandler(action: (ManageSavedSearchAction) SavedBrowserSearchesDialogFragment.REQUEST_SAVED_SEARCH_ACTION, this, ) { _, bundle -> - val type = bundle.getInt(SavedBrowserSearchesDialogFragment.ARG_TYPE) - val searchName = bundle.getString(SavedBrowserSearchesDialogFragment.ARG_SAVED_SEARCH) ?: return@setFragmentResultListener + val type = bundle.getInt(SavedBrowserSearchesDialogFragment.RESULT_SAVED_SEARCH_ACTION) + val searchName = bundle.getString(SavedBrowserSearchesDialogFragment.RESULT_SAVED_SEARCH) ?: return@setFragmentResultListener Timber.d("On user saved search selection named: %s", searchName) launchCatchingTask { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/CustomStudyDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/CustomStudyDialog.kt index b8a2216b11c3..abd0fe502d97 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/CustomStudyDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/CustomStudyDialog.kt @@ -55,8 +55,8 @@ import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog.ContextMenuOption.ST import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog.ContextMenuOption.STUDY_TAGS import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog.CustomStudyDefaults.Companion.toDomainModel import com.ichi2.anki.dialogs.tags.TagsDialog -import com.ichi2.anki.dialogs.tags.TagsDialogListener.Companion.ON_SELECTED_TAGS_KEY -import com.ichi2.anki.dialogs.tags.TagsDialogListener.Companion.ON_SELECTED_TAGS__SELECTED_TAGS +import com.ichi2.anki.dialogs.tags.TagsDialogListener.Companion.REQUEST_SELECT_TAGS +import com.ichi2.anki.dialogs.tags.TagsDialogListener.Companion.RESULT_SELECTED_TAGS import com.ichi2.anki.launchCatchingTask import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.observability.undoableOp @@ -137,8 +137,8 @@ class CustomStudyDialog : AnalyticsDialogFragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - parentFragmentManager.setFragmentResultListener(ON_SELECTED_TAGS_KEY, this) { _, bundle -> - val tagsToInclude = bundle.getStringArrayList(ON_SELECTED_TAGS__SELECTED_TAGS) ?: emptyList() + parentFragmentManager.setFragmentResultListener(REQUEST_SELECT_TAGS, this) { _, bundle -> + val tagsToInclude = bundle.getStringArrayList(RESULT_SELECTED_TAGS) ?: emptyList() val option = selectedSubDialog ?: return@setFragmentResultListener val selectedCardStateIndex = viewModel.selectedCardStateIndex if (selectedCardStateIndex == AdapterView.INVALID_POSITION) return@setFragmentResultListener @@ -539,7 +539,10 @@ class CustomStudyDialog : AnalyticsDialogFragment() { STUDY_FORGOT, STUDY_AHEAD, STUDY_PREVIEW, STUDY_TAGS -> CustomStudyAction.CUSTOM_STUDY_SESSION } - setFragmentResult(CustomStudyAction.REQUEST_KEY, Bundle().apply { putInt(CustomStudyAction.BUNDLE_KEY, action.ordinal) }) + setFragmentResult( + CustomStudyAction.REQUEST_KEY, + Bundle().apply { putInt(CustomStudyAction.RESULT_CUSTOM_STUDY_ACTION, action.ordinal) }, + ) // save the default values (not in upstream) when (contextMenuOption) { @@ -640,11 +643,11 @@ class CustomStudyDialog : AnalyticsDialogFragment() { companion object { const val REQUEST_KEY = "CustomStudyDialog" - const val BUNDLE_KEY = "action" + const val RESULT_CUSTOM_STUDY_ACTION = "action" /** Extracts a [CustomStudyAction] from a [Bundle] */ fun fromBundle(bundle: Bundle): CustomStudyAction = - bundle.getInt(CustomStudyAction.BUNDLE_KEY).let { actionOrdinal -> + bundle.getInt(CustomStudyAction.RESULT_CUSTOM_STUDY_ACTION).let { actionOrdinal -> entries.first { it.ordinal == actionOrdinal } } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/TagLimitFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/TagLimitFragment.kt index 3260ebd6f8e8..228b73280737 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/TagLimitFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/TagLimitFragment.kt @@ -98,8 +98,8 @@ class TagLimitFragment : DialogFragment() { setFragmentResult( REQUEST_CUSTOM_STUDY_TAGS, Bundle().apply { - putStringArrayList(KEY_INCLUDED_TAGS, ArrayList(tagsToInclude)) - putStringArrayList(KEY_EXCLUDED_TAGS, ArrayList(tagsToExclude)) + putStringArrayList(RESULT_INCLUDED_TAGS, ArrayList(tagsToInclude)) + putStringArrayList(RESULT_EXCLUDED_TAGS, ArrayList(tagsToExclude)) }, ) dismiss() @@ -133,8 +133,8 @@ class TagLimitFragment : DialogFragment() { companion object { const val TAG = "TagLimitFragment" const val REQUEST_CUSTOM_STUDY_TAGS = "request_custom_study_tags" - const val KEY_INCLUDED_TAGS = "key_included_tags" - const val KEY_EXCLUDED_TAGS = "key_excluded_tags" + const val RESULT_INCLUDED_TAGS = "key_included_tags" + const val RESULT_EXCLUDED_TAGS = "key_excluded_tags" private const val ARG_DECK_ID = "arg_deck_id" fun newInstance(deckId: DeckId) = diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/help/HelpDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/help/HelpDialog.kt index 8e0ad1126150..a041873b652d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/help/HelpDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/help/HelpDialog.kt @@ -82,7 +82,7 @@ class HelpDialog : AnalyticsDialogFragment() { val selectedItem = BundleCompat.getParcelable( from, - ARG_SELECTED_MENU_ITEM, + RESULT_SELECTED_MENU_ITEM, HelpItem::class.java, ) ?: return when (selectedItem.action) { @@ -173,7 +173,7 @@ internal fun Fragment.requireArgsHelpEntries(): Array { internal const val ARG_MENU_ITEMS = "arg_menu_items" internal const val REQUEST_HELP_PAGE = "request_help_page" -internal const val ARG_SELECTED_MENU_ITEM = " selected_menu_item" +internal const val RESULT_SELECTED_MENU_ITEM = " selected_menu_item" /** * This fragment is responsible for showing a list of menu items in the application's [HelpDialog]. @@ -196,7 +196,7 @@ class HelpPageFragment : Fragment(R.layout.fragment_help_page) { Analytics.send(LinkClicked(menuItem.analyticsId)) parentFragmentManager.setFragmentResult( REQUEST_HELP_PAGE, - Bundle().apply { putParcelable(ARG_SELECTED_MENU_ITEM, menuItem) }, + Bundle().apply { putParcelable(RESULT_SELECTED_MENU_ITEM, menuItem) }, ) } binding.pageContent.addView(this) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsArrayAdapter.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsArrayAdapter.kt index 83e4d4ada92e..263bc8f55dd1 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsArrayAdapter.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsArrayAdapter.kt @@ -25,6 +25,7 @@ import com.ichi2.utils.TypedFilter import java.util.Locale import java.util.Stack import java.util.TreeSet +import com.ichi2.anki.common.android.R as CommonR /** * @param tags A reference to the [TagsList] @@ -412,7 +413,7 @@ class TagsArrayAdapter( // For example, display some icon if the node is a leaf? (assets required) when (node.isExpanded) { true -> button.setBackgroundResource(R.drawable.ic_expand_more_black_24dp_xml) - false -> button.setBackgroundResource(R.drawable.ic_baseline_chevron_right_24) + false -> button.setBackgroundResource(CommonR.drawable.ic_baseline_chevron_right_24) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialogListener.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialogListener.kt index 3de706901c52..3c2c2efd8e5c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialogListener.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialogListener.kt @@ -30,14 +30,14 @@ interface TagsDialogListener { fun F.registerFragmentResultReceiver() where F : Fragment, F : TagsDialogListener { parentFragmentManager.setFragmentResultListener( - ON_SELECTED_TAGS_KEY, + REQUEST_SELECT_TAGS, this, ) { _: String?, bundle: Bundle -> val selectedTags: List = - bundle.getStringArrayList(ON_SELECTED_TAGS__SELECTED_TAGS)!! + bundle.getStringArrayList(RESULT_SELECTED_TAGS)!! val indeterminateTags: List = - bundle.getStringArrayList(ON_SELECTED_TAGS__INDETERMINATE_TAGS)!! - val option = bundle.getSerializableCompat(ON_SELECTED_TAGS__OPTION)!! + bundle.getStringArrayList(RESULT_INDETERMINATE_TAGS)!! + val option = bundle.getSerializableCompat(RESULT_CARD_STATE_FILTER)!! onSelectedTags(selectedTags, indeterminateTags, option) } } @@ -52,17 +52,17 @@ interface TagsDialogListener { ) { val bundle = Bundle().apply { - putStringArrayList(ON_SELECTED_TAGS__SELECTED_TAGS, ArrayList(selectedTags)) - putStringArrayList(ON_SELECTED_TAGS__INDETERMINATE_TAGS, ArrayList(indeterminateTags)) - putSerializable(ON_SELECTED_TAGS__OPTION, stateFilter) + putStringArrayList(RESULT_SELECTED_TAGS, ArrayList(selectedTags)) + putStringArrayList(RESULT_INDETERMINATE_TAGS, ArrayList(indeterminateTags)) + putSerializable(RESULT_CARD_STATE_FILTER, stateFilter) } - fragmentManager.setFragmentResult(ON_SELECTED_TAGS_KEY, bundle) + fragmentManager.setFragmentResult(REQUEST_SELECT_TAGS, bundle) } } - const val ON_SELECTED_TAGS_KEY = "ON_SELECTED_TAGS_KEY" - const val ON_SELECTED_TAGS__SELECTED_TAGS = "SELECTED_TAGS" - const val ON_SELECTED_TAGS__INDETERMINATE_TAGS = "INDETERMINATE_TAGS" - const val ON_SELECTED_TAGS__OPTION = "OPTION" + const val REQUEST_SELECT_TAGS = "ON_SELECTED_TAGS_KEY" + const val RESULT_SELECTED_TAGS = "SELECTED_TAGS" + const val RESULT_INDETERMINATE_TAGS = "INDETERMINATE_TAGS" + const val RESULT_CARD_STATE_FILTER = "OPTION" } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/introduction/SetupCollectionFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/introduction/SetupCollectionFragment.kt index 1398ab3a170e..61f491a2b583 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/introduction/SetupCollectionFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/introduction/SetupCollectionFragment.kt @@ -64,7 +64,7 @@ class SetupCollectionFragment : Fragment(R.layout.fragment_introduction) { } private fun setResult(option: CollectionSetupOption) { - setFragmentResult(FRAGMENT_KEY, Bundle().apply { putParcelable(RESULT_KEY, option) }) + setFragmentResult(REQUEST_COLLECTION_SETUP, Bundle().apply { putParcelable(RESULT_KEY, option) }) } @Parcelize @@ -77,7 +77,7 @@ class SetupCollectionFragment : Fragment(R.layout.fragment_introduction) { } companion object { - const val FRAGMENT_KEY = "collectionSetup" + const val REQUEST_COLLECTION_SETUP = "collectionSetup" const val RESULT_KEY = "result" } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckFragment.kt index c1f8e8a9fab6..d9d18f6fbe4a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckFragment.kt @@ -10,7 +10,8 @@ import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View -import android.webkit.WebViewClient +import android.webkit.WebView +import androidx.annotation.CallSuper import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity @@ -32,6 +33,8 @@ import com.ichi2.anki.progress.observeProgress import com.ichi2.anki.ui.internationalization.sentenceCase import com.ichi2.anki.utils.observeMessages import com.ichi2.anki.utils.showAndAwaitDismissal +import com.ichi2.anki.workarounds.OnWebViewRecreatedListener +import com.ichi2.anki.workarounds.SafeWebViewClient import com.ichi2.utils.cancelable import com.ichi2.utils.message import com.ichi2.utils.negativeButton @@ -47,11 +50,15 @@ import kotlinx.coroutines.launch * MediaCheckFragment for displaying a list of media files that are either unused or missing. * It allows users to tag missing media files or delete unused ones. **/ -class MediaCheckFragment : Fragment(R.layout.fragment_media_check) { +class MediaCheckFragment : + Fragment(R.layout.fragment_media_check), + OnWebViewRecreatedListener { private val viewModel: MediaCheckViewModel by viewModels() private val binding by viewBinding(FragmentMediaCheckBinding::bind) + private var lastReport: String? = null + override fun onViewCreated( view: View, savedInstanceState: Bundle?, @@ -123,6 +130,7 @@ class MediaCheckFragment : Fragment(R.layout.fragment_media_check) { } private fun updateWebView(report: String) { + lastReport = report val backgroundColor = getColorFromAttr(requireContext(), android.R.attr.colorBackground) val textColor = getColorFromAttr(requireContext(), android.R.attr.textColorPrimary) @@ -142,10 +150,20 @@ class MediaCheckFragment : Fragment(R.layout.fragment_media_check) { """.trimIndent() - binding.webView.webViewClient = WebViewClient() + binding.webView.setWebViewClient(object : SafeWebViewClient() {}) binding.webView.loadDataWithBaseURL(null, html, "text/html", "UTF-8", null) } + override fun onWebViewRecreated(webView: WebView) { + lastReport?.let { updateWebView(it) } + } + + @CallSuper + override fun onDestroyView() { + binding.webView.safeDestroy() + super.onDestroyView() + } + private fun setupButtonListeners() { binding.tagMissingMediaButton.apply { // mediaCheckAddTag => "Tag Missing" diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaImageFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaImageFragment.kt index 8a6b54c8cc0c..38c0221b4ccc 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaImageFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaImageFragment.kt @@ -14,6 +14,7 @@ import android.graphics.drawable.BitmapDrawable import android.net.Uri import android.os.Bundle import android.provider.MediaStore +import android.text.format.Formatter import android.view.View import android.webkit.WebView import androidx.activity.result.contract.ActivityResultContracts @@ -50,7 +51,7 @@ import com.ichi2.anki.workarounds.SafeWebViewClient import com.ichi2.anki.workarounds.SafeWebViewLayout import com.ichi2.compose.theme.AnkiDroidTheme import com.ichi2.imagecropper.ImageCropper -import com.ichi2.imagecropper.ImageCropper.Companion.CROP_IMAGE_RESULT +import com.ichi2.imagecropper.ImageCropper.Companion.EXTRA_CROP_IMAGE_RESULT import com.ichi2.utils.BitmapUtil import com.ichi2.utils.ExifUtil import com.ichi2.utils.FileUtil @@ -177,7 +178,7 @@ class MultimediaImageFragment : Activity.RESULT_OK -> { result.data?.let { val cropResultData = - it.getParcelableExtraCompat(CROP_IMAGE_RESULT) + it.getParcelableExtraCompat(EXTRA_CROP_IMAGE_RESULT) Timber.d("Cropped image data: $cropResultData") if (cropResultData?.uriPath == null || cropResultData.uriContent == null) return@registerForActivityResult @@ -284,7 +285,12 @@ class MultimediaImageFragment : } private fun handleImageUri() { - if (imageUri != null) { + val currentImageUri = viewModel.currentMultimediaUri.value + if (currentImageUri != null) { + // The launch URI is only the initial image; cropping or replacement may have changed it. + previewImage(currentImageUri) + binding.imageFileSize.text = Formatter.formatFileSize(requireContext(), viewModel.selectedMediaFileSize) + } else if (imageUri != null) { // a content:// or a file:// already in our cache; resolveUriToFile handles both handleSelectImageIntent(imageUri) } else { @@ -379,7 +385,7 @@ class MultimediaImageFragment : val imageUri = BundleCompat.getParcelable( intent.extras!!, - DrawingFragment.IMAGE_PATH_KEY, + DrawingFragment.EXTRA_IMAGE_PATH, Uri::class.java, ) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/AudioRecordingViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/AudioRecordingViewModel.kt index cf11c0fd590d..8b1db2d55ee7 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/AudioRecordingViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/AudioRecordingViewModel.kt @@ -26,7 +26,7 @@ import kotlin.time.Duration.Companion.seconds class AudioRecordingViewModel( audioRecorder: AudioRecorder = AudioRecorder(appContext), createPlayer: () -> Player = { VoicePlayer.createExoPlayer(appContext) }, - recordingsDirectory: File = File(appContext.cacheDir, VoiceRecorder.RECORDINGS_DIRECTORY), + recordingsDirectory: File = VoiceRecorder.recordingsDirectory(appContext), ) : ViewModel() { private val recorder = VoiceRecorder(audioRecorder, recordingsDirectory, viewModelScope) private val player = VoicePlayer(viewModelScope, createPlayer) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/NoteEditorDestination.kt b/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/NoteEditorDestination.kt index fc937d84d9c4..e734297da16b 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/NoteEditorDestination.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/NoteEditorDestination.kt @@ -44,7 +44,7 @@ fun NoteEditorDestination.toIntent(context: Context): Intent = Intent(context, NoteEditorActivity::class.java).also { intent -> intent.putExtra(NoteEditorFragment.EXTRA_CALLER, NoteEditorCaller.CARDBROWSER_ADD.value) intent.putExtra(NoteEditorFragment.EXTRA_TEXT_FROM_SEARCH_VIEW, searchTerms) - intent.putExtra(NoteEditorFragment.IN_CARD_BROWSER_ACTIVITY, false) + intent.putExtra(NoteEditorFragment.EXTRA_IN_CARD_BROWSER_ACTIVITY, false) deckId?.takeIf { it > 0 }?.let { intent.putExtra(NoteEditorFragment.EXTRA_DID, it) } } is NoteEditorDestination.EditSelection -> @@ -55,7 +55,7 @@ fun NoteEditorDestination.toIntent(context: Context): Intent = // To handle multi select and note edit intent.putExtra(NoteEditorFragment.EXTRA_CARD_IDS, cardIds.toLongArray()) intent.putExtra(AnkiActivity.EXTRA_FINISH_ANIMATION, animation as Parcelable) - intent.putExtra(NoteEditorFragment.IN_CARD_BROWSER_ACTIVITY, inCardBrowserActivity) + intent.putExtra(NoteEditorFragment.EXTRA_IN_CARD_BROWSER_ACTIVITY, inCardBrowserActivity) } is NoteEditorDestination.CopyNote -> Intent(context, NoteEditorActivity::class.java).also { intent -> diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/NoteEditorGalleryPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/NoteEditorGalleryPicker.kt new file mode 100644 index 000000000000..07163f372680 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/NoteEditorGalleryPicker.kt @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: 2026 David Allison <62114487+david-allison@users.noreply.github.com> +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.noteeditor + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.provider.MediaStore +import androidx.activity.result.contract.ActivityResultContracts +import androidx.fragment.app.Fragment +import com.ichi2.anki.CommonString +import com.ichi2.anki.multimedia.PickedImage +import com.ichi2.anki.snackbar.showSnackbar +import timber.log.Timber + +/** + * Picks an image before opening the image editor, retaining the target note field across recreation. + * Must be constructed during fragment initialization to register its activity result launcher. + */ +internal class NoteEditorGalleryPicker( + private val fragment: Fragment, + onImagePicked: (fieldIndex: Int, imageUri: Uri) -> Unit, +) { + private var pendingFieldIndex: Int? = null + + private val launcher = + fragment.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val fieldIndex = pendingFieldIndex ?: return@registerForActivityResult + pendingFieldIndex = null + if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult + + val imageUri = result.data?.let { PickedImage(it).trustedUri } + if (imageUri == null) { + fragment.showSnackbar(CommonString.select_image_failed) + return@registerForActivityResult + } + onImagePicked(fieldIndex, imageUri) + } + + fun launch(fieldIndex: Int) { + pendingFieldIndex = fieldIndex + try { + launcher.launch(Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI)) + } catch (e: ActivityNotFoundException) { + pendingFieldIndex = null + Timber.w(e, "No app found to select image") + fragment.showSnackbar(CommonString.activity_start_failed) + } + } + + fun onSaveInstanceState(outState: Bundle) { + pendingFieldIndex?.let { outState.putInt(STATE_KEY_FIELD_INDEX, it) } + } + + fun onRestoreInstanceState(state: Bundle) { + pendingFieldIndex = state.getInt(STATE_KEY_FIELD_INDEX, -1).takeIf { it >= 0 } + } + + companion object { + private const val STATE_KEY_FIELD_INDEX = "galleryFieldIndex" + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/NoteEditorMultimediaController.kt b/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/NoteEditorMultimediaController.kt index 9ee364fcbf1f..d927485fb0fc 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/NoteEditorMultimediaController.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/NoteEditorMultimediaController.kt @@ -24,6 +24,7 @@ import com.ichi2.anki.multimedia.MultimediaActivityExtra import com.ichi2.anki.multimedia.MultimediaResult import com.ichi2.anki.multimediacard.fields.EFieldType import com.ichi2.anki.multimediacard.fields.IField +import com.ichi2.anki.multimediacard.impl.MultimediaEditableNote import com.ichi2.anki.servicelayer.NoteService import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.utils.ContentResolverUtil @@ -36,9 +37,8 @@ import java.io.File /** * Owns the multimedia capture lifecycle for a [NoteEditorFragment]. * - * The [launcher] must already be registered on the fragment (an - * `ActivityResultLauncher` can only be created during fragment initialisation); - * its callback forwards to [handleActions] / [handleResult]. + * Must be created during fragment initialization to register the Gallery picker. + * The [launcher] must already be registered; its callback forwards to [handleActions] / [handleResult]. */ internal class NoteEditorMultimediaController( private val fragment: NoteEditorFragment, @@ -46,6 +46,15 @@ internal class NoteEditorMultimediaController( ) { private var actionJob: Job? = null + private val galleryPicker = + NoteEditorGalleryPicker(fragment) { fieldIndex, imageUri -> + fragment.launchCatchingTask { + val note = fragment.getCurrentMultimediaEditableNote() + if (note.isEmpty) return@launchCatchingTask + launchEditor(MultimediaActionHandler.ImageFile, fieldIndex, note, imageUri) + } + } + // Use the same HTML if the same image is pasted multiple times. private var pastedImageCache: HashMap = HashMap() @@ -64,18 +73,37 @@ internal class NoteEditorMultimediaController( fragment.multimediaViewModel.multimediaAction.first { action -> Timber.i("Selected multimedia action: %s", action) val handler = MultimediaActionHandler.forAction(action) - val field = handler.createField().also { note.setField(fieldIndex, it) } - val intent = - handler.buildIntent( - fragment.requireContext(), - MultimediaActivityExtra(fieldIndex, field, note), - ) - launcher.launch(intent) + if (handler == MultimediaActionHandler.ImageFile) { + // An empty image editor would be exposed during the picker's back animation. + galleryPicker.launch(fieldIndex) + } else { + launchEditor(handler, fieldIndex, note) + } true } } } + private fun launchEditor( + handler: MultimediaActionHandler, + fieldIndex: Int, + note: MultimediaEditableNote, + imageUri: Uri? = null, + ) { + val field = handler.createField().also { note.setField(fieldIndex, it) } + val intent = + handler.buildIntent( + fragment.requireContext(), + MultimediaActivityExtra(fieldIndex, field, note, imageUri?.toString()), + ) + if (imageUri != null) { + // The URI in the serialized arguments does not carry the picker's read grant. + intent.data = imageUri + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } + launcher.launch(intent) + } + /** * Handles an image-share intent: extracts the source [Uri], persists URI permission, * copies it into the app's cache, and launches the image picker seeded with the @@ -183,12 +211,14 @@ internal class NoteEditorMultimediaController( fun onSaveInstanceState(outState: Bundle) { outState.putSerializable(STATE_KEY_IMAGE_CACHE, pastedImageCache) + galleryPicker.onSaveInstanceState(outState) } fun onRestoreInstanceState(state: Bundle) { pastedImageCache = state.getSerializableCompat>(STATE_KEY_IMAGE_CACHE) ?: HashMap() + galleryPicker.onRestoreInstanceState(state) } /** diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/Toolbar.kt b/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/Toolbar.kt index 1b8393b1c903..6ac3ad7a224e 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/Toolbar.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/Toolbar.kt @@ -38,11 +38,10 @@ import androidx.core.view.isVisible import androidx.core.view.updatePadding import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat import com.ichi2.anki.CommonString -import com.ichi2.anki.NoteEditorFragment import com.ichi2.anki.R -import com.ichi2.anki.common.android.appContext import com.ichi2.anki.common.preferences.sharedPrefs import com.ichi2.anki.compat.CompatHelper +import com.ichi2.anki.noteeditor.data.ToolbarPreferences import com.ichi2.utils.AndroidUiUtils.showSoftInput import com.ichi2.utils.ViewGroupUtils import com.ichi2.utils.ViewGroupUtils.getAllChildrenRecursive @@ -64,12 +63,13 @@ import kotlin.math.ceil * * Handles generating the 'icon' for these custom buttons: [createDrawableForString] * * Handles CTRL+ the tag of the button: [onKeyUp]. Allows for Ctrl+1..9 shortcuts * * Handles adding a dynamic number of buttons and aligning them into rows: [insertItem] - * * And handles whether these should be stacked or scrollable: [shouldScrollToolbar] + * * And handles whether these should be stacked or scrollable: [ToolbarPreferences.isScrollable] */ class Toolbar : FrameLayout { var formatListener: TextFormatListener? = null private val toolbar: LinearLayout private val toolbarLayout: LinearLayout + private val toolbarPreferences = ToolbarPreferences(context.sharedPrefs()) @VisibleForTesting internal val scrollView: HorizontalScrollView @@ -229,11 +229,7 @@ class Toolbar : FrameLayout { val twoDp = ceil((2 / context.resources.displayMetrics.density).toDouble()).toInt() button.setPaddingRelative(twoDp, twoDp, twoDp, twoDp) // end apply style - val shouldScroll = - appContext - .sharedPrefs() - .getBoolean(NoteEditorFragment.PREF_NOTE_EDITOR_SCROLL_TOOLBAR, true) - if (shouldScroll) { + if (toolbarPreferences.isScrollable) { toolbar.addView(button, toolbar.childCount) } else { addViewToToolbar(button) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNoteTypesState.kt b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNoteTypesState.kt index 345764127f64..1d87cb41cce4 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNoteTypesState.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNoteTypesState.kt @@ -16,8 +16,6 @@ import com.ichi2.anki.utils.Destination /** Encapsulates the entire state for [ManageNotetypes] */ data class ManageNoteTypesState( - /** Indicator if the UI should show a "loading" view to the user */ - val isLoading: Boolean = true, /** List of [Notetype] to show */ val noteTypes: List = emptyList(), /** User entered string to use for filtering the [noteTypes] list */ diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNoteTypesViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNoteTypesViewModel.kt index 22fee2a794b5..0ea8ce636fcb 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNoteTypesViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNoteTypesViewModel.kt @@ -23,6 +23,8 @@ import com.ichi2.anki.notetype.ManageNoteTypesState.FieldsEditor import com.ichi2.anki.notetype.ManageNoteTypesState.UserMessage import com.ichi2.anki.notetype.NoteTypeItemState.Companion.asModel import com.ichi2.anki.observability.undoableOp +import com.ichi2.anki.progress.HasProgress +import com.ichi2.anki.progress.ProgressManager import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -30,7 +32,11 @@ import kotlinx.coroutines.launch import net.ankiweb.rsdroid.BackendException import timber.log.Timber -class ManageNoteTypesViewModel : ViewModel() { +class ManageNoteTypesViewModel : + ViewModel(), + HasProgress { + override val progressManager = ProgressManager() + val state: StateFlow field = MutableStateFlow(ManageNoteTypesState()) @@ -40,18 +46,19 @@ class ManageNoteTypesViewModel : ViewModel() { fun refreshNoteTypes() { Timber.i("Refreshing list of notetypes") - state.update { oldState -> oldState.copy(isLoading = true) } viewModelScope.launch { - withCol { safeGetNotetypeNameIdUseCount() } - .onFailure { - state.update { oldState -> - oldState.copy(isLoading = false, error = ReportableException(it)) - } - }.onSuccess { - state.update { oldState -> - oldState.copy(isLoading = false, noteTypes = it) + progressManager.withProgress { + withCol { safeGetNotetypeNameIdUseCount() } + .onFailure { + state.update { oldState -> + oldState.copy(error = ReportableException(it)) + } + }.onSuccess { + state.update { oldState -> + oldState.copy(noteTypes = it) + } } - } + } } } @@ -62,7 +69,7 @@ class ManageNoteTypesViewModel : ViewModel() { oldState.noteTypes.map { it.copy(shouldBeDisplayed = it.name.contains(query, ignoreCase = true)) } - oldState.copy(isLoading = false, noteTypes = matchedNoteTypes, searchQuery = query) + oldState.copy(noteTypes = matchedNoteTypes, searchQuery = query) } } @@ -71,30 +78,30 @@ class ManageNoteTypesViewModel : ViewModel() { name: String, ) { Timber.i("Renaming notetype with id $ntid") - state.update { oldState -> oldState.copy(isLoading = true) } viewModelScope.launch { - undoableOp { - safeRenameNoteType(ntid, name) - .onSuccess { changes -> - state.update { oldState -> - val updatedNoteTypes = - oldState.noteTypes.withUpdatedItem(ntid) { old -> old.copy(name = name) } - oldState.copy(isLoading = false, noteTypes = updatedNoteTypes) - } - return@undoableOp changes - }.onFailure { - // TODO: Change to CardTypeException: https://github.com/ankidroid/Anki-Android-Backend/issues/537 - // Card template 1 in note type 'character' has a problem. - // Expected to find a field replacement on the front of the card template. - state.update { oldState -> - oldState.copy( - isLoading = false, - error = ReportableException(it, it !is BackendException), - ) + progressManager.withProgress { + undoableOp { + safeRenameNoteType(ntid, name) + .onSuccess { changes -> + state.update { oldState -> + val updatedNoteTypes = + oldState.noteTypes.withUpdatedItem(ntid) { old -> old.copy(name = name) } + oldState.copy(noteTypes = updatedNoteTypes) + } + return@undoableOp changes + }.onFailure { + // TODO: Change to CardTypeException: https://github.com/ankidroid/Anki-Android-Backend/issues/537 + // Card template 1 in note type 'character' has a problem. + // Expected to find a field replacement on the front of the card template. + state.update { oldState -> + oldState.copy( + error = ReportableException(it, it !is BackendException), + ) + } + OpChanges.getDefaultInstance() } - OpChanges.getDefaultInstance() - } - OpChanges.getDefaultInstance() + OpChanges.getDefaultInstance() + } } } } @@ -102,34 +109,31 @@ class ManageNoteTypesViewModel : ViewModel() { /** Deletes the note type with [ntid] and also updates the multi select mode status if needed */ fun delete(ntid: NoteTypeId) { Timber.i("Deleting notetype with id $ntid") - state.update { oldState -> oldState.copy(isLoading = true) } - val noteTypesCount = state.value.noteTypes.size + if (state.value.noteTypes.size <= 1) { + state.update { oldState -> oldState.copy(message = UserMessage.DeletingLastModel) } + return + } viewModelScope.launch { - undoableOp { - if (noteTypesCount <= 1) { - state.update { oldState -> - oldState.copy(isLoading = false, message = UserMessage.DeletingLastModel) - } - return@undoableOp OpChanges.getDefaultInstance() - } - safeRemoveNoteType(ntid) - .onSuccess { changes -> - state.update { oldState -> - val updatedNoteTypes = oldState.noteTypes.filterNot { it.id == ntid } - oldState.copy( - isLoading = false, - noteTypes = updatedNoteTypes, - isInMultiSelectMode = updatedNoteTypes.multiSelectModeStatus, - ) - } - return@undoableOp changes - }.onFailure { - state.update { oldState -> - oldState.copy(isLoading = false, error = ReportableException(it)) + progressManager.withProgress { + undoableOp { + safeRemoveNoteType(ntid) + .onSuccess { changes -> + state.update { oldState -> + val updatedNoteTypes = oldState.noteTypes.filterNot { it.id == ntid } + oldState.copy( + noteTypes = updatedNoteTypes, + isInMultiSelectMode = updatedNoteTypes.multiSelectModeStatus, + ) + } + return@undoableOp changes + }.onFailure { + state.update { oldState -> + oldState.copy(error = ReportableException(it)) + } + return@undoableOp OpChanges.getDefaultInstance() } - return@undoableOp OpChanges.getDefaultInstance() - } - OpChanges.getDefaultInstance() + OpChanges.getDefaultInstance() + } } } } @@ -225,39 +229,39 @@ class ManageNoteTypesViewModel : ViewModel() { noteType.copy(isSelected = false) } oldState.copy( - isLoading = true, noteTypes = updateNotetypes, isInMultiSelectMode = false, ) } viewModelScope.launch { - val errors = mutableMapOf() - noteTypesToDelete.forEach { noteType -> - undoableOp { - safeRemoveNoteType(noteType.id) - .onFailure { exception -> - errors[noteType] = exception - OpChanges.getDefaultInstance() - }.onSuccess { return@undoableOp it } - OpChanges.getDefaultInstance() + progressManager.withProgress { + val errors = mutableMapOf() + noteTypesToDelete.forEach { noteType -> + undoableOp { + safeRemoveNoteType(noteType.id) + .onFailure { exception -> + errors[noteType] = exception + OpChanges.getDefaultInstance() + }.onSuccess { return@undoableOp it } + OpChanges.getDefaultInstance() + } + } + // look through any errors we might have and remove from our list of note types the ones + // that were in noteTypesToDelete but not in errors map(which presumably weren't deleted) + val removedIds = noteTypesToDelete.map { it.id } - errors.keys.map { it.id }.toSet() + val updatedNoteTypes = state.value.noteTypes.filterNot { it.id in removedIds } + val combinedException = + CombinedException.from( + errors.map { (state, throwable) -> + "${state.name} - $throwable" to throwable + }, + ) + state.update { oldState -> + oldState.copy( + noteTypes = updatedNoteTypes, + error = combinedException?.let { throwable -> ReportableException(throwable) }, + ) } - } - // look through any errors we might have and remove from our list of note types the ones - // that were in noteTypesToDelete but not in errors map(which presumably weren't deleted) - val removedIds = noteTypesToDelete.map { it.id } - errors.keys.map { it.id }.toSet() - val updatedNoteTypes = state.value.noteTypes.filterNot { it.id in removedIds } - val combinedException = - CombinedException.from( - errors.map { (state, throwable) -> - "${state.name} - $throwable" to throwable - }, - ) - state.update { oldState -> - oldState.copy( - isLoading = false, - noteTypes = updatedNoteTypes, - error = combinedException?.let { throwable -> ReportableException(throwable) }, - ) } } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNotetypes.kt b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNotetypes.kt index 681ca558fe51..b27260afbfe9 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNotetypes.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNotetypes.kt @@ -41,10 +41,9 @@ import com.ichi2.anki.CommonString import com.ichi2.anki.R import com.ichi2.anki.common.crashreporting.CrashReportService import com.ichi2.anki.databinding.ActivityManageNoteTypesBinding -import com.ichi2.anki.dialogs.dismissLoadingDialog -import com.ichi2.anki.dialogs.showLoadingDialog import com.ichi2.anki.launchCatchingTask import com.ichi2.anki.notetype.ManageNoteTypesState.UserMessage +import com.ichi2.anki.progress.observeProgress import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.startup.ensureStorageIsReady import com.ichi2.anki.sync.userAcceptsSchemaChange @@ -107,6 +106,7 @@ class ManageNotetypes : AnkiActivity(R.layout.activity_manage_note_types) { setTransparentStatusBar() enableToolbar() binding.noteTypesList.adapter = notetypesAdapter + observeProgress(viewModel) { it } binding.floatingActionButton.apply { setOnClickListener { val addNewNotesType = AddNewNotesType(this@ManageNotetypes) @@ -205,11 +205,6 @@ class ManageNotetypes : AnkiActivity(R.layout.activity_manage_note_types) { viewModel.clearError() return } - if (state.isLoading) { - showLoadingDialog() - } else { - dismissLoadingDialog() - } // send only the items that should be displayed notetypesAdapter.submitList(state.noteTypes.filter { it.shouldBeDisplayed }) notetypesAdapter.isInMultiSelectMode = state.isInMultiSelectMode diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerFragment.kt index b6b381339832..a5255a09f487 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerFragment.kt @@ -312,15 +312,22 @@ abstract class CardViewerFragment( Timber.w("URL blocked") return true } - if (!appPermission.checkCanLaunchExternalApps()) { + val intent = appPermission.createUrlIntent(url) + if (intent == null) { Timber.w("URL blocked: canLaunchExternalApps") return true } try { - openUrl(url) - } catch (_: Throwable) { - Timber.w("Could not open url") - return false + if (intent.hasCategory(Intent.CATEGORY_BROWSABLE)) { + startActivity(intent) + } else { + // Preserve the existing browser check for custom schemes. + openUrl(url) + } + } catch (e: Exception) { + Timber.w(e, "Could not open url") + showSnackbar(CommonString.activity_start_failed) + // Consume failed launches: external pages must not load inside the card. } } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerViewModel.kt index 26af26daee03..4c86c9ab1a1d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerViewModel.kt @@ -43,7 +43,8 @@ abstract class CardViewerViewModel( val showingAnswer = savedStateHandle.getMutableStateFlow(KEY_SHOWING_ANSWER, false) - protected val cardMediaPlayer = + @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) + val cardMediaPlayer = CardMediaPlayer( javascriptEvaluator = { launchCatchingIO { eval.emit(it) } }, mediaErrorListener = mediaErrorHandler, diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerViewModel.kt index 1665e4f862a3..cf4cf14737ab 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerViewModel.kt @@ -69,7 +69,6 @@ class PreviewerViewModel( /** Call this after the webView has finished loading the page */ @NeedsTest("16302 - a sound-only card on the back/flipped with 'don't keep activities'") - @NeedsTest("16302 - on config changes, sound continues to play") override fun onPageFinished(isAfterRecreation: Boolean) { launchCatchingIO { if (isAfterRecreation) { @@ -92,9 +91,11 @@ class PreviewerViewModel( launchCatchingIO { backSideOnly.emit(!backSideOnly.value) if (!backSideOnly.value && showingAnswer.value) { + cardMediaPlayer.stop() showQuestion() cardMediaPlayer.autoplayAllForSide(CardSide.QUESTION) } else if (backSideOnly.value && !showingAnswer.value) { + cardMediaPlayer.stop() showAnswer() cardMediaPlayer.autoplayAllForSide(CardSide.ANSWER) } @@ -135,6 +136,7 @@ class PreviewerViewModel( fun onNextButtonClick() { launchCatchingIO { if (!showingAnswer.value && !backSideOnly.value) { + cardMediaPlayer.stop() showAnswer() cardMediaPlayer.autoplayAllForSide(CardSide.ANSWER) } else { @@ -152,6 +154,7 @@ class PreviewerViewModel( if (currentIndex.value > 0) { updateCurrentIndex { it - 1 } } else if (showingAnswer.value && !backSideOnly.value) { + cardMediaPlayer.stop() showQuestion() } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/TypeAnswer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/TypeAnswer.kt index 86ea68b09870..e783909a25e4 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/TypeAnswer.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/TypeAnswer.kt @@ -5,7 +5,6 @@ package com.ichi2.anki.previewer import android.os.LocaleList import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.cardviewer.TypeAnswerModifiers -import com.ichi2.anki.common.annotations.NeedsTest import com.ichi2.anki.libanki.Card import com.ichi2.anki.libanki.Field import com.ichi2.anki.servicelayer.LanguageHintService @@ -20,13 +19,11 @@ import org.jetbrains.annotations.VisibleForTesting * @see [imeHintLocales] * @see [noSuggest] * */ -@NeedsTest("combining and non combining answers are properly parsed") -@NeedsTest("nosuggest modifier is parsed and composes with nc/cloze") class TypeAnswer private constructor( private val text: String, /** whether combining characters should be compared. Defined by the presence of the * `nc:` specifier in the type answer tag */ - private val combining: Boolean, + val combining: Boolean, /** * Whether keyboard suggestions, swiping and autocorrect should be disabled (#10352). * @@ -88,7 +85,6 @@ class TypeAnswer private constructor( * @param isCloze whether the placeholder was a `cloze:` type filter * @param fieldName the name of the field in the card template */ - @NeedsTest("cloze type-in-answer are properly parsed") private suspend fun getExpectedTypeInAnswer( card: Card, isCloze: Boolean, diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/progress/ProgressObserver.kt b/AnkiDroid/src/main/java/com/ichi2/anki/progress/ProgressObserver.kt index 3d20f930496b..3524785dd899 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/progress/ProgressObserver.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/progress/ProgressObserver.kt @@ -6,6 +6,8 @@ package com.ichi2.anki.progress import android.content.Context import android.view.Window import android.view.WindowManager +import androidx.activity.OnBackPressedCallback +import androidx.activity.addCallback import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope @@ -23,7 +25,7 @@ import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds /** - * Blocks touches and shows/dismisses a loading dialog driven by [viewModel]'s progress flow. + * Blocks touches and back, and shows/dismisses a loading dialog driven by [viewModel]'s progress flow. * [delayMillis] defers the initial show so quick operations don't flash a dialog. * [resolveMessage] maps the ViewModel's message to the text shown in the dialog. */ @@ -34,7 +36,20 @@ fun AnkiActivity.observeProgress( ) { val dialogTag = "${LoadingDialogFragment.TAG}:${viewModel.javaClass.name}" var dialogVisible = supportFragmentManager.findFragmentByTag(dialogTag) != null - var blockingTouches = false + var backBlock: OnBackPressedCallback? = null + + fun blockInput() { + if (backBlock != null) return + window.blockTouches() + backBlock = onBackPressedDispatcher.addCallback(this) { Timber.i("Ignoring back while an operation runs") } + } + + fun unblockInput() { + val block = backBlock ?: return + backBlock = null + block.remove() + window.unblockTouches() + } lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { @@ -49,16 +64,10 @@ fun AnkiActivity.observeProgress( pendingShow = null dialogVisible = false dismissLoadingDialog(dialogTag) - if (blockingTouches) { - blockingTouches = false - window.unblockTouches() - } + unblockInput() } is ViewModelProgress.Active -> { - if (!blockingTouches) { - blockingTouches = true - window.blockTouches() - } + blockInput() if (dialogVisible) { showLoadingDialog( message = formatMessage(state, resolveMessage), diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/recorder/VoiceRecorder.kt b/AnkiDroid/src/main/java/com/ichi2/anki/recorder/VoiceRecorder.kt index 62ec5c768230..3932c04a1537 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/recorder/VoiceRecorder.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/recorder/VoiceRecorder.kt @@ -3,7 +3,9 @@ package com.ichi2.anki.recorder +import android.content.Context import androidx.annotation.MainThread +import com.ichi2.anki.common.time.TimeManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow @@ -15,6 +17,7 @@ import java.io.Closeable import java.io.File import java.io.IOException import kotlin.time.Duration +import kotlin.time.Duration.Companion.days import kotlin.time.TimeSource sealed interface RecorderState { @@ -183,5 +186,23 @@ class VoiceRecorder( const val RECORDINGS_DIRECTORY = "recordings" private const val FILE_PREFIX = "ankidroid_audiorec" private const val FILE_SUFFIX = ".3gp" + private val RECORDING_RETENTION = 7.days + + fun recordingsDirectory(context: Context): File = File(context.noBackupFilesDir, RECORDINGS_DIRECTORY) + + fun removeExpiredRecordings( + directory: File, + now: Long = TimeManager.time.intTimeMS(), + ) { + val cutoff = now - RECORDING_RETENTION.inWholeMilliseconds + directory + .listFiles { _, name -> name.startsWith(FILE_PREFIX) && name.endsWith(FILE_SUFFIX) } + ?.forEach { file -> + val modified = file.lastModified() + if (file.isFile && modified > 0 && modified < cutoff && !file.delete()) { + Timber.w("Could not delete %s", file) + } + } + } } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt index e5a5c558642a..2ba88f8ad62a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt @@ -47,6 +47,7 @@ import com.ichi2.utils.neutralButton import com.ichi2.utils.positiveButton import kotlinx.parcelize.Parcelize import timber.log.Timber +import com.ichi2.anki.common.android.R as CommonR class AddEditReminderDialog : DialogFragment() { /** @@ -359,7 +360,7 @@ class AddEditReminderDialog : DialogFragment() { /** * Icon that shows next to the advanced settings section when the dropdown is closed. */ - private val DROPDOWN_COLLAPSED_CHEVRON = R.drawable.ic_baseline_chevron_right_24 + private val DROPDOWN_COLLAPSED_CHEVRON = CommonR.drawable.ic_baseline_chevron_right_24 /** * Arguments key for the dialog mode to open this dialog in. diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/ForgetCardsDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/ForgetCardsDialog.kt index 4817c673c5d3..c7a8de1077e3 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/ForgetCardsDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/ForgetCardsDialog.kt @@ -114,8 +114,8 @@ class ForgetCardsDialog : AnalyticsDialogFragment() { parentFragmentManager.setFragmentResult( REQUEST_KEY_FORGET, Bundle().apply { - putBoolean(ARG_RESTORE_ORIGINAL, restoreOriginalPositionIfPossible) - putBoolean(ARG_RESET_REPETITION, resetRepetitionAndLapseCounts) + putBoolean(RESULT_RESTORE_ORIGINAL, restoreOriginalPositionIfPossible) + putBoolean(RESULT_RESET_REPETITION, resetRepetitionAndLapseCounts) }, ) } @@ -132,8 +132,11 @@ class ForgetCardsDialog : AnalyticsDialogFragment() { companion object { const val REQUEST_KEY_FORGET = "request_key_forget_cards" - const val ARG_RESTORE_ORIGINAL = "key_restore_original" - const val ARG_RESET_REPETITION = "key_reset_repetition" + const val RESULT_RESTORE_ORIGINAL = "key_restore_original" + const val RESULT_RESET_REPETITION = "key_reset_repetition" + + private const val ARG_RESTORE_ORIGINAL = "key_restore_original" + private const val ARG_RESET_REPETITION = "key_reset_repetition" } } @@ -147,8 +150,8 @@ internal fun FragmentActivity.registerOnForgetHandler(cardsIdsProducer: suspend setFragmentResultListener(ForgetCardsDialog.REQUEST_KEY_FORGET) { _, bundle -> forgetCards( cardsIdsProducer = cardsIdsProducer, - restoreOriginalPositionIfPossible = bundle.getBoolean(ForgetCardsDialog.ARG_RESTORE_ORIGINAL), - resetRepetitionAndLapseCounts = bundle.getBoolean(ForgetCardsDialog.ARG_RESET_REPETITION), + restoreOriginalPositionIfPossible = bundle.getBoolean(ForgetCardsDialog.RESULT_RESTORE_ORIGINAL), + resetRepetitionAndLapseCounts = bundle.getBoolean(ForgetCardsDialog.RESULT_RESET_REPETITION), ) } } @@ -163,8 +166,8 @@ internal fun Fragment.registerOnForgetHandler(cardsIdsProducer: suspend () -> Li setFragmentResultListener(ForgetCardsDialog.REQUEST_KEY_FORGET) { _, bundle -> activity?.forgetCards( cardsIdsProducer = cardsIdsProducer, - restoreOriginalPositionIfPossible = bundle.getBoolean(ForgetCardsDialog.ARG_RESTORE_ORIGINAL), - resetRepetitionAndLapseCounts = bundle.getBoolean(ForgetCardsDialog.ARG_RESET_REPETITION), + restoreOriginalPositionIfPossible = bundle.getBoolean(ForgetCardsDialog.RESULT_RESTORE_ORIGINAL), + resetRepetitionAndLapseCounts = bundle.getBoolean(ForgetCardsDialog.RESULT_RESET_REPETITION), ) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/SetDueDateDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/SetDueDateDialog.kt index 4298b0897805..bbe751971a45 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/SetDueDateDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/SetDueDateDialog.kt @@ -113,8 +113,8 @@ class SetDueDateDialog : AnalyticsDialogFragment() { Timber.d("Set due date dialog: %d card(s)", cardIds.size) this.initialRotation = getScreenRotation() - childFragmentManager.setFragmentResultListener(RESULT_SUBMIT_DUE_DATE, this) { _, _ -> - Timber.i(RESULT_SUBMIT_DUE_DATE) + childFragmentManager.setFragmentResultListener(REQUEST_SUBMIT_DUE_DATE, this) { _, _ -> + Timber.i(REQUEST_SUBMIT_DUE_DATE) requireActivity().launchCatchingTask { if (launchUpdateDueDate(showError = false).await() == null) { Timber.w("unsuccessful updating dates; not dismissing dialog") @@ -266,7 +266,7 @@ class SetDueDateDialog : AnalyticsDialogFragment() { const val ARG_IDS_FILE = "ARGS_IDS_FILE" const val MAX_WIDTH_DP = 450f - private const val RESULT_SUBMIT_DUE_DATE = "SubmitDueDate" + private const val REQUEST_SUBMIT_DUE_DATE = "SubmitDueDate" /** Keeps the existing dialog and its input when another request arrives. */ @MainThread @@ -348,7 +348,7 @@ class SetDueDateDialog : AnalyticsDialogFragment() { event?.keyCode == KeyEvent.KEYCODE_ENTER ) { parentFragmentManager.setFragmentResult( - RESULT_SUBMIT_DUE_DATE, + REQUEST_SUBMIT_DUE_DATE, Bundle(), ) true @@ -423,7 +423,7 @@ class SetDueDateDialog : AnalyticsDialogFragment() { event?.keyCode == KeyEvent.KEYCODE_ENTER ) { parentFragmentManager.setFragmentResult( - RESULT_SUBMIT_DUE_DATE, + REQUEST_SUBMIT_DUE_DATE, Bundle(), ) true diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/security/AppPermissions.kt b/AnkiDroid/src/main/java/com/ichi2/anki/security/AppPermissions.kt index 2d44b55f6866..aa865a99ac75 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/security/AppPermissions.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/security/AppPermissions.kt @@ -3,6 +3,8 @@ package com.ichi2.anki.security import android.content.Context +import android.content.Intent +import android.net.Uri import androidx.annotation.VisibleForTesting import com.ichi2.anki.CommonString import com.ichi2.anki.R @@ -34,6 +36,20 @@ class AppPermissions( private val context: Context, private val showSnackbar: (String) -> Unit = {}, ) { + /** + * Creates a new URL navigation intent, or `null` when permission is denied. + * + * HTTP(S) links do not require external app launches to be enabled and are returned with + * `CATEGORY_BROWSABLE`. + * + * Other URL schemes require [external app launches][checkCanLaunchExternalApps] to be enabled. + */ + fun createUrlIntent(url: Uri): Intent? = + when (url.scheme) { + "http", "https" -> Intent(Intent.ACTION_VIEW, url).addCategory(Intent.CATEGORY_BROWSABLE) + else -> if (checkCanLaunchExternalApps()) Intent(Intent.ACTION_VIEW, url) else null + } + /** * Returns true if cards may launch external apps; * shows a rate-limited snackbar on denial. diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/LegacyNotificationsPermissionFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/LegacyNotificationsPermissionFragment.kt index b036fa9f1a4c..4cd2d9c86e4c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/LegacyNotificationsPermissionFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/LegacyNotificationsPermissionFragment.kt @@ -35,7 +35,7 @@ class LegacyNotificationsPermissionFragment : PermissionsFragment(R.layout.fragm // onResume is called after returning from the OS settings if (canPostNotifications(requireContext())) { // Post a fragment result to indicate that the bottom sheet can be dismissed - setFragmentResult(PermissionsBottomSheet.RESULT_DISMISS, Bundle()) + setFragmentResult(PermissionsBottomSheet.REQUEST_DISMISS_PERMISSIONS_SHEET, Bundle()) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/NotificationsPermissionFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/NotificationsPermissionFragment.kt index b92018e72a56..de783617e6de 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/NotificationsPermissionFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/NotificationsPermissionFragment.kt @@ -45,7 +45,7 @@ class NotificationsPermissionFragment : PermissionsFragment(R.layout.fragment_no // onResume is called after returning from both the OS settings and the OS permission request dialog if (canPostNotifications(requireContext())) { // Post a fragment result to indicate that the bottom sheet can be dismissed - setFragmentResult(PermissionsBottomSheet.RESULT_DISMISS, Bundle()) + setFragmentResult(PermissionsBottomSheet.REQUEST_DISMISS_PERMISSIONS_SHEET, Bundle()) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsActivity.kt index af0be0f60549..595d1c2c66ea 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsActivity.kt @@ -17,8 +17,8 @@ import com.ichi2.anki.StoragePermissionSet import com.ichi2.anki.common.utils.android.showThemedToast import com.ichi2.anki.common.utils.ext.getParcelableExtraCompat import com.ichi2.anki.databinding.ActivityPermissionsBinding -import com.ichi2.anki.ui.windows.permissions.PermissionsFragment.Companion.HAS_ALL_PERMISSIONS_KEY -import com.ichi2.anki.ui.windows.permissions.PermissionsFragment.Companion.PERMISSIONS_FRAGMENT_RESULT_KEY +import com.ichi2.anki.ui.windows.permissions.PermissionsFragment.Companion.REQUEST_PERMISSIONS +import com.ichi2.anki.ui.windows.permissions.PermissionsFragment.Companion.RESULT_HAS_ALL_PERMISSIONS import com.ichi2.anki.utils.ext.setFragmentResultListener import dev.androidbroadcast.vbpd.viewBinding import timber.log.Timber @@ -59,11 +59,11 @@ class PermissionsActivity : AnkiActivity(R.layout.activity_permissions) { return } val permissionsFragment = permissionSet.permissionsFragment.getDeclaredConstructor().newInstance() - setFragmentResultListener(PERMISSIONS_FRAGMENT_RESULT_KEY) { _, bundle -> - val hasAllPermissions = bundle.getBoolean(HAS_ALL_PERMISSIONS_KEY) + setFragmentResultListener(REQUEST_PERMISSIONS) { _, bundle -> + val hasAllPermissions = bundle.getBoolean(RESULT_HAS_ALL_PERMISSIONS) setContinueButtonEnabled(hasAllPermissions) } - setFragmentResultListener(RESULT_COMPLETE) { _, _ -> decideStorageAndFinish() } + setFragmentResultListener(REQUEST_PERMISSIONS_GRANTED) { _, _ -> decideStorageAndFinish() } supportFragmentManager.commit { replace(R.id.fragment_container, permissionsFragment) @@ -80,7 +80,7 @@ class PermissionsActivity : AnkiActivity(R.layout.activity_permissions) { * Records the storage decision and closes the screen: the granted permissions * determine the default collection path. * - * Called on 'Continue', or when the fragment reports [RESULT_COMPLETE]. + * Called on 'Continue', or when the fragment reports [REQUEST_PERMISSIONS_GRANTED]. * Preserves an existing collection path. * * @see InitialActivity.decideStorageIfUndecided @@ -99,7 +99,7 @@ class PermissionsActivity : AnkiActivity(R.layout.activity_permissions) { * Signals the activity to persist the * [storage decision][com.ichi2.anki.common.storage.StorageDecision], and then close. */ - const val RESULT_COMPLETE = "result_complete" + const val REQUEST_PERMISSIONS_GRANTED = "result_complete" fun getIntent( context: Context, diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsBottomSheet.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsBottomSheet.kt index cdeb5a437f67..0c281fd7e468 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsBottomSheet.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsBottomSheet.kt @@ -47,7 +47,7 @@ class PermissionsBottomSheet : BottomSheetDialogFragment() { } binding.closeButton.setOnClickListener { dismiss() } - childFragmentManager.setFragmentResultListener(RESULT_DISMISS, this) { _, _ -> + childFragmentManager.setFragmentResultListener(REQUEST_DISMISS_PERMISSIONS_SHEET, this) { _, _ -> dismiss() } @@ -75,7 +75,7 @@ class PermissionsBottomSheet : BottomSheetDialogFragment() { * Fragment result request key for dismissing this BottomSheet. * Public so that child fragments can set it. */ - const val RESULT_DISMISS = "result_dismiss" + const val REQUEST_DISMISS_PERMISSIONS_SHEET = "result_dismiss" /** * Starts this BottomSheet with the provided [OptionalPermissionSet]. diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsFragment.kt index bb3cade2921f..899b59d38a33 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsFragment.kt @@ -61,8 +61,8 @@ abstract class PermissionsFragment( super.onResume() permissionsItems.forEach { it.updateSwitchCheckedStatus() } setFragmentResult( - PERMISSIONS_FRAGMENT_RESULT_KEY, - Bundle().apply { putBoolean(HAS_ALL_PERMISSIONS_KEY, hasAllPermissions()) }, + REQUEST_PERMISSIONS, + Bundle().apply { putBoolean(RESULT_HAS_ALL_PERMISSIONS, hasAllPermissions()) }, ) } @@ -135,7 +135,7 @@ abstract class PermissionsFragment( } companion object { - const val PERMISSIONS_FRAGMENT_RESULT_KEY = "PERMISSION_FRAGMENT_RESULT" - const val HAS_ALL_PERMISSIONS_KEY = "HAS_ALL_PERMISSIONS" + const val REQUEST_PERMISSIONS = "PERMISSION_FRAGMENT_RESULT" + const val RESULT_HAS_ALL_PERMISSIONS = "HAS_ALL_PERMISSIONS" } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsStartingAt30Fragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsStartingAt30Fragment.kt index 53b1c927c064..8b8700b373f3 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsStartingAt30Fragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsStartingAt30Fragment.kt @@ -31,7 +31,7 @@ class PermissionsStartingAt30Fragment : PermissionsFragment(R.layout.fragment_pe ) { if (hasAllPermissions()) { // the activity records the storage decision and finishes - setFragmentResult(PermissionsActivity.RESULT_COMPLETE, Bundle()) + setFragmentResult(PermissionsActivity.REQUEST_PERMISSIONS_GRANTED, Bundle()) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt index 690fd7f110a5..1b2bc75109af 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt @@ -133,7 +133,7 @@ class ReviewerFragment : override fun onLoadInitialHtml(): String = stdHtml( context = requireContext(), - extraJsAssets = listOf("scripts/ankidroid-reviewer.js"), + extraJsAssets = listOf("scripts/ankidroid-reviewer.js", "scripts/type-answer.js"), nightMode = Themes.isNightTheme, ) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModel.kt index 3329fd0eda9f..49f2295a632c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModel.kt @@ -15,6 +15,7 @@ import com.ichi2.anki.Flag import com.ichi2.anki.Reviewer import com.ichi2.anki.asyncIO import com.ichi2.anki.cardviewer.SingleCardSide +import com.ichi2.anki.cardviewer.appendNoSuggestAttributes import com.ichi2.anki.common.annotations.NeedsTest import com.ichi2.anki.common.destinations.BrowserDestination import com.ichi2.anki.common.destinations.CardInfoDestination @@ -631,12 +632,18 @@ class ReviewerViewModel( text: String, typeAnswer: TypeAnswer, ): String { + val keyboardAttributes = + buildString { + if (typeAnswer.noSuggest) appendNoSuggestAttributes() + } + @Suppress("XmlDeprecatedElement") // upstream uses `
    ` @Language("HTML") val repl = """
    """.trimIndent() diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/audiorecord/CheckPronunciationViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/audiorecord/CheckPronunciationViewModel.kt index 7e839ba4ac3c..2c3c22724bf3 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/audiorecord/CheckPronunciationViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/audiorecord/CheckPronunciationViewModel.kt @@ -23,7 +23,7 @@ import kotlin.time.Duration class CheckPronunciationViewModel( audioRecorder: AudioRecorder = AudioRecorder(appContext), createPlayer: () -> Player = { VoicePlayer.createExoPlayer(appContext) }, - recordingsDirectory: File = File(appContext.cacheDir, VoiceRecorder.RECORDINGS_DIRECTORY), + recordingsDirectory: File = VoiceRecorder.recordingsDirectory(appContext), ) : ViewModel() { private val recorder = VoiceRecorder(audioRecorder, recordingsDirectory, viewModelScope) private val player = VoicePlayer(viewModelScope, createPlayer) diff --git a/AnkiDroid/src/main/java/com/ichi2/compose/ui/preview/AnkiDroidPreview.kt b/AnkiDroid/src/main/java/com/ichi2/compose/ui/preview/AnkiDroidPreview.kt index 55dd0abba7c5..581ec6247f3a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/compose/ui/preview/AnkiDroidPreview.kt +++ b/AnkiDroid/src/main/java/com/ichi2/compose/ui/preview/AnkiDroidPreview.kt @@ -8,20 +8,24 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext -import com.ichi2.anki.R +import com.ichi2.anki.settings.enums.DayTheme +import com.ichi2.anki.settings.enums.NightTheme +import com.ichi2.anki.settings.enums.Theme import com.ichi2.compose.theme.AnkiDroidTheme /** - * Applies the AnkiDroid theme matching the preview's light or dark UI mode. + * Applies [theme] to preview content, defaulting to the preview's light or dark UI mode. * - * Use this wrapper with [ThemePreviews]. + * Use [AnkiDroidThemeProvider] to preview every app theme, or [ThemePreviews] for light and dark. */ @Composable -fun AnkiDroidPreview(content: @Composable () -> Unit) { +fun AnkiDroidPreview( + theme: Theme = if (isSystemInDarkTheme()) NightTheme.DARK else DayTheme.LIGHT, + content: @Composable () -> Unit, +) { // AnkiDroidTheme reads XML attributes, so changing only the preview's UI mode is insufficient. val context = LocalContext.current - val theme = if (isSystemInDarkTheme()) R.style.Theme_Dark else R.style.Theme_Light - val themedContext = remember(context, theme) { ContextThemeWrapper(context, theme) } + val themedContext = remember(context, theme) { ContextThemeWrapper(context, theme.styleResId) } CompositionLocalProvider(LocalContext provides themedContext) { AnkiDroidTheme(content = content) } diff --git a/AnkiDroid/src/main/java/com/ichi2/compose/ui/preview/AnkiDroidThemeProvider.kt b/AnkiDroid/src/main/java/com/ichi2/compose/ui/preview/AnkiDroidThemeProvider.kt new file mode 100644 index 000000000000..b90acf3c0a41 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/compose/ui/preview/AnkiDroidThemeProvider.kt @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.compose.ui.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.ichi2.anki.settings.enums.DayTheme +import com.ichi2.anki.settings.enums.NightTheme +import com.ichi2.anki.settings.enums.Theme + +/** Supplies every app theme to a preview wrapped in [AnkiDroidPreview]. */ +class AnkiDroidThemeProvider : PreviewParameterProvider { + override val values: Sequence = DayTheme.entries.asSequence() + NightTheme.entries.asSequence() + + override fun getDisplayName(index: Int): String = + when (val theme = values.elementAt(index)) { + DayTheme.EINK -> "E-ink" + else -> theme.toString().lowercase().replaceFirstChar(Char::titlecase) + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/imagecropper/ImageCropper.kt b/AnkiDroid/src/main/java/com/ichi2/imagecropper/ImageCropper.kt index cf8e5e2ced30..c4e685a48e9c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/imagecropper/ImageCropper.kt +++ b/AnkiDroid/src/main/java/com/ichi2/imagecropper/ImageCropper.kt @@ -194,7 +194,7 @@ class ImageCropper : if (result.error == null) { val resultIntent = Intent() resultIntent.putExtra( - CROP_IMAGE_RESULT, + EXTRA_CROP_IMAGE_RESULT, CropResultData( uriContent = result.uriContent, uriPath = context?.let { result.getUriFilePath(it) }, @@ -225,7 +225,7 @@ class ImageCropper : /** * The key for the cropped image path sent back in the result Intent. */ - const val CROP_IMAGE_RESULT = "crop_image_result" + const val EXTRA_CROP_IMAGE_RESULT = "crop_image_result" } @Parcelize diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/ImportUtils.kt b/AnkiDroid/src/main/java/com/ichi2/utils/ImportUtils.kt index b5443720bb4c..03434a14a076 100644 --- a/AnkiDroid/src/main/java/com/ichi2/utils/ImportUtils.kt +++ b/AnkiDroid/src/main/java/com/ichi2/utils/ImportUtils.kt @@ -20,7 +20,6 @@ import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CommonString import com.ichi2.anki.R import com.ichi2.anki.common.android.appContext -import com.ichi2.anki.common.annotations.NeedsTest import com.ichi2.anki.common.coroutines.applicationScope import com.ichi2.anki.common.crashreporting.CrashReportService import com.ichi2.anki.common.exception.ManuallyReportedException @@ -311,7 +310,6 @@ object ImportUtils { } } - @NeedsTest("Add test for the fallback, ensure the fallback filename \"file_.\" is produced when decoding fails") private fun validateFileName(fileName: String): String { // #6137 - filenames can be too long when URLEncoded val sanitized = sanitizeFileName(fileName) diff --git a/AnkiDroid/src/main/res/drawable/ic_card_state_buried.xml b/AnkiDroid/src/main/res/drawable/ic_card_state.xml similarity index 75% rename from AnkiDroid/src/main/res/drawable/ic_card_state_buried.xml rename to AnkiDroid/src/main/res/drawable/ic_card_state.xml index f0164c0333b4..13df71f57488 100644 --- a/AnkiDroid/src/main/res/drawable/ic_card_state_buried.xml +++ b/AnkiDroid/src/main/res/drawable/ic_card_state.xml @@ -4,7 +4,7 @@ SPDX-FileComment: Style: Filled Source: https://fonts.gstatic.com/s/i/materialicons/circle/v11/24px.xml --> - + diff --git a/AnkiDroid/src/main/res/drawable/ic_card_state_learning.xml b/AnkiDroid/src/main/res/drawable/ic_card_state_learning.xml deleted file mode 100644 index 175cb40916ca..000000000000 --- a/AnkiDroid/src/main/res/drawable/ic_card_state_learning.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/AnkiDroid/src/main/res/drawable/ic_card_state_new.xml b/AnkiDroid/src/main/res/drawable/ic_card_state_new.xml deleted file mode 100644 index 419a80c930b4..000000000000 --- a/AnkiDroid/src/main/res/drawable/ic_card_state_new.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/AnkiDroid/src/main/res/drawable/ic_card_state_review.xml b/AnkiDroid/src/main/res/drawable/ic_card_state_review.xml deleted file mode 100644 index 0a48a8c49228..000000000000 --- a/AnkiDroid/src/main/res/drawable/ic_card_state_review.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/AnkiDroid/src/main/res/drawable/ic_card_state_suspended.xml b/AnkiDroid/src/main/res/drawable/ic_card_state_suspended.xml deleted file mode 100644 index 7179f5c0f8f7..000000000000 --- a/AnkiDroid/src/main/res/drawable/ic_card_state_suspended.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/AnkiDroid/src/main/res/layout/fragment_media_check.xml b/AnkiDroid/src/main/res/layout/fragment_media_check.xml index 81612f3c0697..20d3a28bf029 100644 --- a/AnkiDroid/src/main/res/layout/fragment_media_check.xml +++ b/AnkiDroid/src/main/res/layout/fragment_media_check.xml @@ -18,7 +18,7 @@ app:navigationContentDescription="@string/abc_action_bar_up_description" app:navigationIcon="?attr/homeAsUpIndicator" /> - - - \ No newline at end of file + diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/AbstractFlashcardViewerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/AbstractFlashcardViewerTest.kt index e539bf9654a7..832e60f0480b 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/AbstractFlashcardViewerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/AbstractFlashcardViewerTest.kt @@ -31,8 +31,12 @@ import com.ichi2.anki.reviewer.AutomaticAnswer import com.ichi2.anki.reviewer.AutomaticAnswerAction import com.ichi2.anki.reviewer.AutomaticAnswerSettings import com.ichi2.anki.servicelayer.LanguageHintService +import com.ichi2.testutils.assertNoActivityStarted import com.ichi2.testutils.common.Flaky import com.ichi2.testutils.common.OS +import com.ichi2.testutils.ext.snackbarText +import com.ichi2.testutils.mockWebResourceRequest +import com.ichi2.testutils.registerWebBrowser import kotlinx.coroutines.runBlocking import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.containsString @@ -49,10 +53,13 @@ import org.junit.jupiter.params.provider.MethodSource import org.junit.runner.RunWith import org.mockito.Mockito.mock import org.robolectric.Robolectric +import org.robolectric.Shadows.shadowOf import org.robolectric.android.controller.ActivityController import timber.log.Timber import java.util.Locale import java.util.stream.Stream +import kotlin.test.assertNotNull +import kotlin.test.assertTrue @Suppress("SameParameterValue") @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) // getImeHintLocales, toLanguageTags, onRenderProcessGone, RenderProcessGoneDetail @@ -62,8 +69,14 @@ class AbstractFlashcardViewerTest : RobolectricTest() { class NonAbstractFlashcardViewer : AbstractFlashcardViewer() { var answered: Rating? = null + var startActivityFailure: Exception? = null private var lastTime = 0 + override fun startActivity(intent: Intent) { + startActivityFailure?.let { throw it } + super.startActivity(intent) + } + override fun performReload() { // intentionally blank } @@ -123,6 +136,25 @@ class AbstractFlashcardViewerTest : RobolectricTest() { } } + @Test + fun `rejected web link launch shows an error and consumes navigation`() { + editPreferences { putBoolean(targetContext.getString(R.string.pref_allow_card_external_launch_key), false) } + targetContext.registerWebBrowser() + val viewer = getViewer(addCard = true) + viewer.startActivityFailure = SecurityException("The handler requires a permission") + val webView = assertNotNull(viewer.webView) + + assertTrue( + shadowOf(webView).webViewClient.shouldOverrideUrlLoading( + webView, + mockWebResourceRequest("https://example.org"), + ), + ) + advanceRobolectricLooper() + targetContext.assertNoActivityStarted() + assertEquals(viewer.getString(CommonString.activity_start_failed), viewer.snackbarText) + } + @ParameterizedTest @MethodSource("getSignalFromUrlTest_args") fun getSignalFromUrlTest( diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/AndroidTtsPlayerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/AndroidTtsPlayerTest.kt index 15bc8c8540c5..d8e0676097aa 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/AndroidTtsPlayerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/AndroidTtsPlayerTest.kt @@ -33,6 +33,59 @@ import kotlin.time.Duration.Companion.seconds /** Test for [AndroidTtsPlayer] */ @RunWith(AndroidJUnit4::class) class AndroidTtsPlayerTest { + @Test + fun `locale fallback respects engine preference order`() { + val first = AndroidTtsVoice(fakeVoice("first", Locale.FRANCE), ENGINE_A) + val second = AndroidTtsVoice(fakeVoice("second", Locale.FRANCE), ENGINE_B) + + for (voices in listOf(listOf(first, second), listOf(second, first))) { + val player = AndroidTtsPlayer(voices) + assertThat(player.voiceForTag(frenchTag()), equalTo(TtsVoiceMatch(voices.first(), rank = -100))) + } + } + + @Test + fun `unavailable voices follow installed voices with engine order preserved in both groups`() { + val installedB = AndroidTtsVoice(fakeVoice("installed-b", Locale.FRANCE), ENGINE_B) + val unavailableB = AndroidTtsVoice(fakeVoice("unavailable-b", Locale.FRANCE, installed = false), ENGINE_B) + val installedA = AndroidTtsVoice(fakeVoice("installed-a", Locale.FRANCE), ENGINE_A) + val unavailableA = AndroidTtsVoice(fakeVoice("unavailable-a", Locale.FRANCE, installed = false), ENGINE_A) + val installedC = AndroidTtsVoice(fakeVoice("installed-c", Locale.FRANCE), ENGINE_C) + val unavailableC = AndroidTtsVoice(fakeVoice("unavailable-c", Locale.FRANCE, installed = false), ENGINE_C) + val player = AndroidTtsPlayer(listOf(unavailableB, installedB, unavailableA, installedA, unavailableC, installedC)) + + assertThat(player.voices(), equalTo(listOf(installedB, installedA, installedC, unavailableB, unavailableA, unavailableC))) + assertThat(player.voiceForTag(frenchTag()), equalTo(TtsVoiceMatch(installedB, rank = -100))) + } + + @Test + fun `locale fallback uses another engine when the default lacks the language`() { + val french = AndroidTtsVoice(fakeVoice("french", Locale.FRANCE), ENGINE_A) + val english = AndroidTtsVoice(fakeVoice("english", Locale.US), ENGINE_B) + val player = AndroidTtsPlayer(listOf(english, french)) + + assertThat(player.voiceForTag(frenchTag()), equalTo(TtsVoiceMatch(french, rank = -100))) + } + + @Test + fun `installed voice from another engine precedes unavailable default engine voice`() { + val installed = AndroidTtsVoice(fakeVoice("installed", Locale.FRANCE), ENGINE_A) + val unavailable = AndroidTtsVoice(fakeVoice("unavailable", Locale.FRANCE, installed = false), ENGINE_B) + val player = AndroidTtsPlayer(listOf(unavailable, installed)) + + assertThat(player.voiceForTag(frenchTag()), equalTo(TtsVoiceMatch(installed, rank = -100))) + } + + @Test + fun `explicit voice from another engine takes precedence over the default engine`() { + val requested = AndroidTtsVoice(fakeVoice("requested", Locale.FRANCE), ENGINE_A) + val preferred = AndroidTtsVoice(fakeVoice("preferred", Locale.FRANCE), ENGINE_B) + val player = AndroidTtsPlayer(listOf(preferred, requested)) + + assertThat(player.voiceForTag(frenchTag(requested.name)), equalTo(TtsVoiceMatch(requested, rank = 0))) + assertThat(player.voiceForTag(frenchTag("missing")), equalTo(TtsVoiceMatch(preferred, rank = -100))) + } + @Test fun `network timeout is reported with its specific error code`() { val error = AndroidTtsError.fromErrorCode(TextToSpeech.ERROR_NETWORK_TIMEOUT) @@ -265,6 +318,7 @@ class AndroidTtsPlayerTest { companion object { private const val ENGINE_A = "com.example.engine.a" private const val ENGINE_B = "com.example.engine.b" + private const val ENGINE_C = "com.example.engine.c" private const val ENGINE_FAILING = "com.example.engine.failing" private val PLAYBACK_TIMEOUT = 5.seconds } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt index f58abe595ff7..17d325e6813f 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt @@ -57,13 +57,13 @@ import com.ichi2.anki.browser.CardBrowserViewModelTest import com.ichi2.anki.browser.CardOrNoteId import com.ichi2.anki.browser.FindAndReplaceDialogFragment import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.ALL_FIELDS_AS_FIELD -import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.ARG_FIELD -import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.ARG_MATCH_CASE -import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.ARG_ONLY_SELECTED_NOTES -import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.ARG_REGEX -import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.ARG_REPLACEMENT -import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.ARG_SEARCH import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.REQUEST_FIND_AND_REPLACE +import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.RESULT_FIELD +import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.RESULT_MATCH_CASE +import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.RESULT_ONLY_SELECTED_NOTES +import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.RESULT_REGEX +import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.RESULT_REPLACEMENT +import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.RESULT_SEARCH import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.TAGS_AS_FIELD import com.ichi2.anki.browser.IdsFile import com.ichi2.anki.browser.column1 @@ -832,8 +832,8 @@ class CardBrowserTest : RobolectricTest() { b.supportFragmentManager.setFragmentResult( ForgetCardsDialog.REQUEST_KEY_FORGET, Bundle().apply { - putBoolean(ForgetCardsDialog.ARG_RESTORE_ORIGINAL, true) - putBoolean(ForgetCardsDialog.ARG_RESET_REPETITION, false) + putBoolean(ForgetCardsDialog.RESULT_RESTORE_ORIGINAL, true) + putBoolean(ForgetCardsDialog.RESULT_RESET_REPETITION, false) }, ) @@ -2112,13 +2112,13 @@ class CardBrowserTest : RobolectricTest() { supportFragmentManager.setFragmentResult( REQUEST_FIND_AND_REPLACE, Bundle().apply { - putString(ARG_SEARCH, search) - putString(ARG_REPLACEMENT, replacement) - putString(ARG_FIELD, field) - putBoolean(ARG_ONLY_SELECTED_NOTES, onlyInSelectedNotes) + putString(RESULT_SEARCH, search) + putString(RESULT_REPLACEMENT, replacement) + putString(RESULT_FIELD, field) + putBoolean(RESULT_ONLY_SELECTED_NOTES, onlyInSelectedNotes) // "Ignore case" checkbox text => when it's checked we pass false to the backend - putBoolean(ARG_MATCH_CASE, matchCase) - putBoolean(ARG_REGEX, regex) + putBoolean(RESULT_MATCH_CASE, matchCase) + putBoolean(RESULT_REGEX, regex) }, ) } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CardTemplateEditorTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CardTemplateEditorTest.kt index ab14fc41ff5d..e49568f1b347 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CardTemplateEditorTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CardTemplateEditorTest.kt @@ -47,6 +47,7 @@ import kotlin.test.junit5.JUnit5Asserter.assertNotEquals import kotlin.test.junit5.JUnit5Asserter.assertNotNull import kotlin.test.junit5.JUnit5Asserter.assertNull import kotlin.test.junit5.JUnit5Asserter.assertTrue +import kotlin.time.Duration @RunWith(AndroidJUnit4::class) class CardTemplateEditorTest : RobolectricTest() { @@ -908,8 +909,8 @@ class CardTemplateEditorTest : RobolectricTest() { shadowEditor.nextStartedActivity, Activity.RESULT_OK, Intent() - .putExtra(CardTemplateBrowserAppearanceEditor.INTENT_QUESTION_FORMAT, "q") - .putExtra(CardTemplateBrowserAppearanceEditor.INTENT_ANSWER_FORMAT, "a"), + .putExtra(CardTemplateBrowserAppearanceEditor.EXTRA_QUESTION_FORMAT, "q") + .putExtra(CardTemplateBrowserAppearanceEditor.EXTRA_ANSWER_FORMAT, "a"), ) assertDiscardChangesDialogShown(shadowEditor) } @@ -1069,7 +1070,7 @@ class CardTemplateEditorTest : RobolectricTest() { advanceRobolectricLooper() val resultBundle = Bundle() - resultBundle.putString(InsertFieldDialog.KEY_INSERTED_FIELD, expectedFieldText) + resultBundle.putString(InsertFieldDialog.RESULT_INSERTED_FIELD, expectedFieldText) testEditor.supportFragmentManager.setFragmentResult(firstFragmentAgain.insertFieldRequestKey, resultBundle) advanceRobolectricLooper() @@ -1142,6 +1143,185 @@ class CardTemplateEditorTest : RobolectricTest() { } } + @Test + fun `adding a second card type still counts the notes`() { + addBasicNote() + val intent = Intent(Intent.ACTION_VIEW).putExtra("noteTypeId", col.notetypes.byName("Basic")!!.id) + val controller = + Robolectric + .buildActivity(CardTemplateEditor::class.java, intent) + .create() + .start() + .resume() + .visible() + saveControllerForCleanup(controller) + val editor = controller.get() + + addCardType(editor, shadowOf(editor)) + assertTrue("Unable to click?", shadowOf(editor).clickMenuItem(R.id.action_add)) + advanceRobolectricLooper() + assertEquals( + "Adding from a new card type should count the notes", + getQuantityString(CommonPlurals.card_template_editor_confirm_add, 1, 1), + getAlertDialogText(true), + ) + } + + @Test + fun `adding an optional reverse template with an empty condition creates no cards`() { + val noteType = col.notetypes.byName("Basic (optional reversed card)")!! + val note = col.newNote(noteType) + note.setField(0, "Front") + note.setField(1, "Back") + note.setField(2, "") + col.addNote(note) + assertEquals("Only the forward card should exist", 1, getNoteTypeCardCount(noteType)) + + withCardTemplateEditor(noteType) { + selectTab(1) + advanceRobolectricLooper() + assertEquals("The optional reverse template should be selected", 1, selectedTabPosition) + + assertTrue("Unable to add a card type", shadowOf(this).clickMenuItem(R.id.action_add)) + advanceRobolectricLooper() + val confirmation = getAlertDialogText(true) + clickAlertDialogButton { positiveButton } + assertEquals("The duplicate template should be added", 3, tempNoteType!!.templateCount) + + // Saving rejects identical fronts. Keep the empty condition while making the new front distinct. + advanceRobolectricLooper() + templateFragment(2).binding.editText.setText("{{#Add Reverse}}{{Front}} {{Back}}{{/Add Reverse}}") + + assertTrue("Unable to save the templates", shadowOf(this).clickMenuItem(R.id.action_confirm)) + advanceRobolectricLooper() + val savedNoteType = col.notetypes.get(noteType.id)!! + assertEquals("The duplicate template should be saved", 3, savedNoteType.templates.length()) + assertEquals("An empty Add Reverse field should prevent generating another card", 1, getNoteTypeCardCount(noteType)) + assertEquals( + "The confirmation must not promise a card that will not be generated", + getQuantityString(CommonPlurals.card_template_editor_confirm_add, 0, 0), + confirmation, + ) + } + } + + @Test + fun `adding from an unsaved optional reverse template creates no cards`() { + val noteType = col.notetypes.byName("Basic (optional reversed card)")!! + val note = col.newNote(noteType) + note.setField(0, "Front") + note.setField(1, "Back") + note.setField(2, "") + col.addNote(note) + assertEquals("Only the forward card should exist", 1, getNoteTypeCardCount(noteType)) + + withCardTemplateEditor(noteType) { + selectTab(1) + advanceRobolectricLooper() + addCardType(this, shadowOf(this)) + advanceRobolectricLooper() + assertEquals("The first duplicate should be selected", 2, selectedTabPosition) + assertTrue( + "The selected template must be unsaved", + CardTemplateNotetype.isOrdinalPendingAdd(tempNoteType!!, selectedTabPosition), + ) + + // Keep the empty condition, but make each front distinct so saving succeeds. + templateFragment(2).binding.editText.setText("{{#Add Reverse}}{{Front}} {{Back}}{{/Add Reverse}}") + assertTrue("Unable to add a card type", shadowOf(this).clickMenuItem(R.id.action_add)) + advanceRobolectricLooper() + val confirmation = getAlertDialogText(true) + clickAlertDialogButton { positiveButton } + advanceRobolectricLooper() + assertEquals("The second duplicate should be selected", 3, selectedTabPosition) + templateFragment(3).binding.editText.setText("{{#Add Reverse}}{{Back}} {{Front}}{{/Add Reverse}}") + + assertTrue("Unable to save the templates", shadowOf(this).clickMenuItem(R.id.action_confirm)) + advanceRobolectricLooper() + assertEquals( + "Both new templates should be saved", + 4, + col.notetypes + .get(noteType.id)!! + .templates + .length(), + ) + assertEquals("Neither conditional template should generate cards", 1, getNoteTypeCardCount(noteType)) + assertEquals( + "Adding from an unsaved conditional template must not promise a card that will not be generated", + getQuantityString(CommonPlurals.card_template_editor_confirm_add, 0, 0), + confirmation, + ) + } + } + + @Test + fun `adding from a broken template does not crash`() { + addBasicAndReversedNote() + withCardTemplateEditor(col.notetypes.basicAndReversed) { + templateFragment(0).binding.editText.setText("{{#Front}}") + assertTrue("Unable to add a card type", shadowOf(this).clickMenuItem(R.id.action_add)) + advanceRobolectricLooper() + assertEquals( + "A broken template should not create cards", + getQuantityString(CommonPlurals.card_template_editor_confirm_add, 0, 0), + getAlertDialogText(true), + ) + } + } + + @Test + fun `adding a template with a card number condition counts generated cards`() { + addBasicAndReversedNote() + val noteType = col.notetypes.basicAndReversed + val initialCardCount = getNoteTypeCardCount(noteType) + assertEquals("Both original cards should exist", 2, initialCardCount) + + withCardTemplateEditor(noteType) { + // Preview supplies c3 for the new template, but card generation does not. + templateFragment(0).binding.editText.setText("{{#c3}}{{Front}}{{/c3}}") + assertTrue("Unable to add a card type", shadowOf(this).clickMenuItem(R.id.action_add)) + advanceRobolectricLooper() + val confirmation = getAlertDialogText(true) + clickAlertDialogButton { positiveButton } + advanceRobolectricLooper() + assertEquals("The new template should be selected", 2, selectedTabPosition) + + // Avoid identical fronts without changing the condition or field requirements. + templateFragment(2).binding.editText.setText("{{#c3}}{{Front}} extra{{/c3}}") + assertTrue("Unable to save the templates", shadowOf(this).clickMenuItem(R.id.action_confirm)) + advanceRobolectricLooper() + assertEquals( + "The new template should be saved", + 3, + col.notetypes + .get(noteType.id)!! + .templates + .length(), + ) + val createdCards = getNoteTypeCardCount(noteType) - initialCardCount + assertEquals("The card number condition should prevent generation", 0, createdCards) + assertEquals( + "The confirmation must count generated cards, not nonempty previews", + getQuantityString(CommonPlurals.card_template_editor_confirm_add, createdCards, createdCards), + confirmation, + ) + } + } + + @Test + fun `counting falls back to the note count when it times out`() { + val noteType = col.notetypes.byName("Basic (optional reversed card)")!! + val note = col.newNote(noteType) + note.setField(0, "Front") + note.setField(1, "Back") + col.addNote(note) + val reverseTemplate = noteType.templates[1] + + assertEquals("No reverse card is created", 0, col.countCardsCreatedBy(noteType, reverseTemplate)) + assertEquals("A timeout counts every note", 1, col.countCardsCreatedBy(noteType, reverseTemplate, timeout = Duration.ZERO)) + } + private fun CardTemplateEditor.templateFragment(ord: Int) = supportFragmentManager.findFragmentByTag("f$ord") as CardTemplateFragment private fun addCardType( diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerImportTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerImportTest.kt index 5c8c9039a222..5a356b616b59 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerImportTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerImportTest.kt @@ -3,17 +3,119 @@ package com.ichi2.anki import android.content.Intent -import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.appcompat.app.AlertDialog +import androidx.core.content.edit +import androidx.lifecycle.ViewModelProvider +import com.ichi2.anki.deckpicker.DeckPickerViewModel +import com.ichi2.anki.deckpicker.DeckPickerViewModel.AnkiDroidEnvironment import com.ichi2.anki.dialogs.AsyncDialogFragment +import com.ichi2.anki.dialogs.DialogHandler import com.ichi2.anki.dialogs.ImportDialog +import com.ichi2.anki.testutils.SingleViewModelFactory +import com.ichi2.anki.utils.ext.DIALOG_FRAGMENT_TAG +import com.ichi2.testutils.BackupManagerTestUtilities +import com.ichi2.utils.ImportUtils import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers +import org.junit.After import org.junit.Assert.fail +import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doCallRealMethod +import org.mockito.kotlin.spy +import org.mockito.kotlin.whenever +import org.robolectric.ParameterizedRobolectricTestRunner +import org.robolectric.Robolectric +import org.robolectric.RuntimeEnvironment +import kotlin.test.assertNotNull +import kotlin.test.assertTrue -@RunWith(AndroidJUnit4::class) +@RunWith(ParameterizedRobolectricTestRunner::class) class DeckPickerImportTest : RobolectricTest() { + @ParameterizedRobolectricTestRunner.Parameter + @JvmField + var qualifiers: String? = null + + companion object { + @ParameterizedRobolectricTestRunner.Parameters + @JvmStatic + fun configurations(): Collection = listOf("normal", "xlarge") + } + + @Before + fun configureScreen() { + RuntimeEnvironment.setQualifiers(qualifiers) + setIntroductionSlidesShown(true) + InitialActivity.setUpgradedToLatestVersion(getPreferences()) + getPreferences().edit { putBoolean("backupPromptDisabled", true) } + BackupManagerTestUtilities.setupSpaceForBackup(targetContext) + } + + @After + fun resetBackupSpace() { + BackupManagerTestUtilities.reset() + } + + @Test + fun `import confirmation survives delayed startup`() { + val viewModel = spy(DeckPickerViewModel()) + lateinit var startupEnvironment: AnkiDroidEnvironment + doAnswer { invocation -> + startupEnvironment = invocation.getArgument(0) + null + }.whenever(viewModel).handleStartup(any()) + + DialogHandler.storeMessage(ImportUtils.CollectionImportAdd("deck.apkg").toMessage()) + val controller = Robolectric.buildActivity(DeckPicker::class.java) + saveControllerForCleanup(controller) + val activity = controller.get() + ViewModelProvider(activity, SingleViewModelFactory.create(viewModel))[DeckPickerViewModel::class.java] + controller + .create() + .start() + .resume() + .visible() + advanceRobolectricLooper() + activity.assertImportConfirmationVisible() + + // Opening the collection at startup can finish after the pending import dialog is shown. + doCallRealMethod().whenever(viewModel).handleStartup(any()) + viewModel.handleStartup(startupEnvironment) + viewModel.awaitStartup() + advanceRobolectricLooper() + + activity.assertImportConfirmationVisible() + } + + @Test + fun `import confirmation survives startup`() { + DialogHandler.storeMessage(ImportUtils.CollectionImportAdd("deck.apkg").toMessage()) + + deckPicker { + assertImportConfirmationVisible() + } + } + + @Test + fun `import confirmation survives returning from file picker`() { + val controller = startActivityControllerNormallyOpenCollectionWithIntent(DeckPicker::class.java, Intent()) + controller.pause().stop() + DialogHandler.storeMessage(ImportUtils.CollectionImportAdd("deck.apkg").toMessage()) + + controller.start().resume().visible() + advanceRobolectricLooper() + + controller.get().assertImportConfirmationVisible() + } + + private fun DeckPicker.assertImportConfirmationVisible() { + val fragment = assertNotNull(supportFragmentManager.findFragmentByTag(DIALOG_FRAGMENT_TAG) as? ImportDialog) + assertTrue(assertNotNull(fragment.dialog as? AlertDialog).isShowing) + } + @Test fun importAddShowsImportDialog() { val deckPicker = super.startActivityNormallyOpenCollectionWithIntent(DeckPickerImport::class.java, Intent()) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerInsetsTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerInsetsTest.kt index 3c887b6bf074..318487a7a615 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerInsetsTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerInsetsTest.kt @@ -37,10 +37,14 @@ class DeckPickerInsetsTest : RobolectricTest() { addDeck("Test Deck") val controller = startActivityControllerNormallyOpenCollectionWithIntent(DeckPicker::class.java, Intent()) val deckPicker = controller.get() + advanceRobolectricLooperUntil(lazyMessage = { "the deck list should be shown" }) { + deckPicker.deckPickerBinding.deckPickerContent.isVisible + } deckPicker.dispatchInsets(navBarBottom = 48.dp) deckPicker.layoutForTest() val list = deckPicker.deckPickerBinding.decks val observer = deckPicker.deckPickerBinding.root.viewTreeObserver + check(list.height > 0) { "the deck list must be laid out, or the layout listener is a no-op" } val padding = list.paddingBottom check(padding > 0) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt index ee059f4d911e..43a69b7a0055 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt @@ -8,6 +8,7 @@ import android.content.DialogInterface import android.content.Intent import android.content.SharedPreferences import android.content.pm.PackageManager +import android.content.res.Configuration import android.database.sqlite.SQLiteDatabaseCorruptException import android.os.Bundle import android.os.SystemClock @@ -1434,6 +1435,24 @@ class DeckPickerTest : RobolectricTest() { ) } + @Test + fun `FAB menu closes when clicking background inside menu container`() = + deckPicker { + floatingActionMenu.showFloatingActionMenu() + assertThat("menu is open", floatingActionMenu.isFABOpen, equalTo(true)) + + // Click the background inside the FAB menu container + // (simulating, for example, tapping to the left of narrower buttons such as the "Add" button) + val linearLayout = floatingActionButtonBinding.fabLinearLayout + linearLayout.performClick() + + assertThat( + "clicking the background inside the FAB menu container closes the menu", + floatingActionMenu.isFABOpen, + equalTo(false), + ) + } + @Test fun `expanded FAB menu is restored after recreation`() { ActivityScenario.launch(DeckPicker::class.java).use { scenario -> @@ -1663,7 +1682,7 @@ class DeckPickerTest : RobolectricTest() { browser.childFragmentManager.setFragmentResult( DeckSelectionDialog.REQUEST_SELECT_DECK, Bundle().apply { - putParcelable(DeckSelectionDialog.ARG_SELECTED_DECK, SelectableDeck.AllDecks) + putParcelable(DeckSelectionDialog.RESULT_SELECTED_DECK, SelectableDeck.AllDecks) }, ) } @@ -1716,6 +1735,24 @@ class DeckPickerTest : RobolectricTest() { ) } + @Test + fun `rotating while finishing to show the App Intro does not crash`() { + setIntroductionSlidesShown(false) + val controller = Robolectric.buildActivity(DeckPicker::class.java, Intent()).create() + saveControllerForCleanup(controller) + val deckPicker = controller.get() + assertTrue(deckPicker.isFinishing, "DeckPicker should finish to show the App Intro") + + val current = deckPicker.resources.configuration + val rotated = + Configuration(current).apply { + orientation = Configuration.ORIENTATION_LANDSCAPE + screenWidthDp = current.screenHeightDp + screenHeightDp = current.screenWidthDp + } + assertDoesNotThrow { controller.configurationChange(rotated, deckPicker.resources.displayMetrics) } + } + @Test fun `startup response is cleared after handling so it does not re-run on resume`() = deckPicker { diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ForegroundMediaNetworkTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ForegroundMediaNetworkTest.kt index 1b0ead8b4aeb..fc26f3fe7956 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ForegroundMediaNetworkTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ForegroundMediaNetworkTest.kt @@ -8,6 +8,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.work.NetworkType import androidx.work.WorkInfo import androidx.work.WorkManager +import anki.sync.FullUploadOrDownloadRequest import anki.sync.SyncCollectionResponse import anki.sync.syncCollectionResponse import com.ichi2.anki.dialogs.SyncErrorDialog @@ -15,6 +16,7 @@ import com.ichi2.anki.settings.Prefs import com.ichi2.anki.settings.enums.ShouldFetchMedia import com.ichi2.anki.utils.ext.DIALOG_FRAGMENT_TAG import com.ichi2.anki.worker.UniqueWorkNames +import com.ichi2.testutils.BackupManagerTestUtilities import com.ichi2.utils.NetworkUtils import io.mockk.every import io.mockk.mockkObject @@ -38,6 +40,9 @@ import kotlin.test.assertNotNull class ForegroundMediaNetworkTest : RobolectricTest() { private var metered = false private var syncRequired = SyncCollectionResponse.ChangesRequired.NO_CHANGES + private var fullSyncRequest: FullUploadOrDownloadRequest? = null + + override fun getCollectionStorageMode() = CollectionStorageMode.ON_DISK override fun setupTestDispatcher(dispatcher: TestDispatcher) { super.setupTestDispatcher(dispatcher) @@ -47,6 +52,7 @@ class ForegroundMediaNetworkTest : RobolectricTest() { @Before fun prepareSync() { + BackupManagerTestUtilities.setupSpaceForBackup(targetContext) Prefs.hkey = "test" Prefs.lastSyncTime = 0 Prefs.allowSyncOnMeteredConnections = false @@ -65,13 +71,16 @@ class ForegroundMediaNetworkTest : RobolectricTest() { return syncCollectionResponse { required = syncRequired } } - override fun fullUploadOrDownload(input: anki.sync.FullUploadOrDownloadRequest) = Unit + override fun fullUploadOrDownload(input: FullUploadOrDownloadRequest) { + fullSyncRequest = input + } } } } @After fun resetSync() { + BackupManagerTestUtilities.reset() BackendFactory.setOverride(null) unmockkObject(NetworkUtils) } @@ -127,9 +136,10 @@ class ForegroundMediaNetworkTest : RobolectricTest() { deckPicker { syncRequired = SyncCollectionResponse.ChangesRequired.FULL_SYNC - val request = syncAndGetMediaWork(resolveConflict = true) + val request = syncAndGetMediaWork(resolveConflict = ConflictResolution.FULL_UPLOAD) assertThat(request.constraints.requiredNetworkType, equalTo(NetworkType.UNMETERED)) + assertFullSyncDoesNotStartMedia(upload = true) } @Test @@ -138,15 +148,56 @@ class ForegroundMediaNetworkTest : RobolectricTest() { metered = true syncRequired = SyncCollectionResponse.ChangesRequired.FULL_SYNC - val request = syncAndGetMediaWork(confirmWarning = true, resolveConflict = true) + val request = syncAndGetMediaWork(confirmWarning = true, resolveConflict = ConflictResolution.FULL_UPLOAD) assertThat(Prefs.allowSyncOnMeteredConnections, equalTo(false)) assertThat(request.constraints.requiredNetworkType, equalTo(NetworkType.CONNECTED)) + assertFullSyncDoesNotStartMedia(upload = true) + } + + @Test + fun `conflict download leaves media startup to the constrained worker`() = + deckPicker { + syncRequired = SyncCollectionResponse.ChangesRequired.FULL_SYNC + + val request = syncAndGetMediaWork(resolveConflict = ConflictResolution.FULL_DOWNLOAD) + + assertThat(request.constraints.requiredNetworkType, equalTo(NetworkType.UNMETERED)) + assertFullSyncDoesNotStartMedia(upload = false) + } + + @Test + fun `direct full upload leaves media startup to the constrained worker`() = + deckPicker { + syncRequired = SyncCollectionResponse.ChangesRequired.FULL_UPLOAD + + val request = syncAndGetMediaWork() + + assertThat(request.constraints.requiredNetworkType, equalTo(NetworkType.UNMETERED)) + assertFullSyncDoesNotStartMedia(upload = true) } + @Test + fun `direct full download leaves media startup to the constrained worker`() = + deckPicker { + syncRequired = SyncCollectionResponse.ChangesRequired.FULL_DOWNLOAD + + val request = syncAndGetMediaWork() + + assertThat(request.constraints.requiredNetworkType, equalTo(NetworkType.UNMETERED)) + assertFullSyncDoesNotStartMedia(upload = false) + } + + private fun assertFullSyncDoesNotStartMedia(upload: Boolean) { + val request = requireNotNull(fullSyncRequest) + assertThat(request.upload, equalTo(upload)) + // Supplying serverUsn makes the Rust backend start media before WorkManager can gate it. + assertThat("the full sync must not start native media sync", request.hasServerUsn(), equalTo(false)) + } + private fun DeckPicker.syncAndGetMediaWork( confirmWarning: Boolean = false, - resolveConflict: Boolean = false, + resolveConflict: ConflictResolution? = null, ): WorkInfo { val manager = WorkManager.getInstance(this) sync() @@ -154,10 +205,14 @@ class ForegroundMediaNetworkTest : RobolectricTest() { (ShadowDialog.getLatestDialog() as AlertDialog).getButton(DialogInterface.BUTTON_POSITIVE).performClick() } advanceRobolectricLooperUntil { Prefs.lastSyncTime != 0L } - if (resolveConflict) { + if (resolveConflict != null) { Prefs.lastSyncTime = 0 - // Choose "Keep AnkiDroid" and confirm replacing the server collection. - conflictDialog().getButton(DialogInterface.BUTTON_POSITIVE).performClick() + val button = + when (resolveConflict) { + ConflictResolution.FULL_UPLOAD -> DialogInterface.BUTTON_POSITIVE + ConflictResolution.FULL_DOWNLOAD -> DialogInterface.BUTTON_NEGATIVE + } + conflictDialog().getButton(button).performClick() advanceRobolectricLooper() conflictDialog().getButton(DialogInterface.BUTTON_POSITIVE).performClick() advanceRobolectricLooperUntil { Prefs.lastSyncTime != 0L } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/IntroductionActivityTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/IntroductionActivityTest.kt index 80e798d484de..57e93c619498 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/IntroductionActivityTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/IntroductionActivityTest.kt @@ -72,7 +72,7 @@ class IntroductionActivityTest : RobolectricTest() { val activity = startRegularActivity(Intent()) activity.supportFragmentManager.setFragmentResult( - SetupCollectionFragment.FRAGMENT_KEY, + SetupCollectionFragment.REQUEST_COLLECTION_SETUP, Bundle().apply { putParcelable(SetupCollectionFragment.RESULT_KEY, DeckPickerWithNewCollection) }, ) @@ -83,7 +83,7 @@ class IntroductionActivityTest : RobolectricTest() { ) assertThat( "DeckPicker isn't asked to sync (Get Started, not Sync)", - intent.getBooleanExtra(DeckPicker.INTENT_SYNC_FROM_LOGIN, false), + intent.getBooleanExtra(DeckPicker.EXTRA_SYNC_FROM_LOGIN, false), equalTo(false), ) assertThat( @@ -95,7 +95,7 @@ class IntroductionActivityTest : RobolectricTest() { private fun IntroductionActivity.clickSync() { supportFragmentManager.setFragmentResult( - SetupCollectionFragment.FRAGMENT_KEY, + SetupCollectionFragment.REQUEST_COLLECTION_SETUP, Bundle().apply { putParcelable(SetupCollectionFragment.RESULT_KEY, SyncFromExistingAccount) }, ) } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/MediaRegistrationTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/MediaRegistrationTest.kt new file mode 100644 index 000000000000..7cd991b386d8 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/MediaRegistrationTest.kt @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki + +import android.content.ClipDescription +import android.content.ContentProvider +import android.database.MatrixCursor +import android.net.Uri +import android.provider.OpenableColumns +import androidx.core.net.toUri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.Shadows.shadowOf +import org.robolectric.shadows.ShadowContentResolver +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +class MediaRegistrationTest : RobolectricTest() { + override fun getCollectionStorageMode() = CollectionStorageMode.ON_DISK + + @Test + fun `pasted image references the normalized media filename`() { + assertPastedMedia("image.png", "image/png", """""") + } + + @Test + fun `pasted audio references the normalized media filename`() { + assertPastedMedia("audio.mp3", "audio/mpeg", "[sound:%s]") + } + + private fun assertPastedMedia( + filename: String, + mimeType: String, + expectedTag: String, + ) { + val data = "pasted media".toByteArray() + // Exceed the backend's filename length limit using characters valid on every OS. + val originalFilename = "a".repeat(120) + filename + val uri = stubClipboardMedia(originalFilename, data) + + val html = + MediaRegistration.loadMediaIntoCollection( + targetContext, + uri, + ClipDescription("media", arrayOf(mimeType)), + pasteAsPng = false, + showError = { error("Unexpected paste error: $it") }, + ) + + val importedFile = + col.media.dir + .listFiles()!! + .single() + assertTrue(importedFile.name.length < originalFilename.length, "Expected the backend to shorten the filename") + assertContentEquals(data, importedFile.readBytes()) + assertEquals(expectedTag.format(importedFile.name), html) + } + + private fun stubClipboardMedia( + filename: String, + data: ByteArray, + ): Uri { + val uri = "content://clipboard/media".toUri() + val cursor = MatrixCursor(arrayOf(OpenableColumns.DISPLAY_NAME)).apply { addRow(arrayOf(filename)) } + val provider = mock() + whenever(provider.query(uri, null, null, null, null)).thenReturn(cursor) + ShadowContentResolver.registerProviderInternal("clipboard", provider) + shadowOf(targetContext.contentResolver).registerInputStreamSupplier(uri) { data.inputStream() } + return uri + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerTest.kt index 0c8d226fce43..4e70ff531bc9 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerTest.kt @@ -30,6 +30,7 @@ import com.ichi2.anki.cardviewer.ViewerCommand.ANSWER_EASY import com.ichi2.anki.cardviewer.ViewerCommand.MARK import com.ichi2.anki.cardviewer.ViewerCommand.SHOW_ANSWER import com.ichi2.anki.common.preferences.sharedPrefs +import com.ichi2.anki.common.storage.CollectionHelper.getMediaDirectory import com.ichi2.anki.common.time.MockTime import com.ichi2.anki.common.time.TimeManager import com.ichi2.anki.common.ui.TransitionDirection @@ -48,12 +49,14 @@ import com.ichi2.anki.model.CardStateFilter import com.ichi2.anki.observability.undoableOp import com.ichi2.anki.preferences.PreferenceTestUtils import com.ichi2.anki.reviewer.ActionButtonStatus +import com.ichi2.anki.snackbar.SnackbarBuilder import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.testutils.RecordingInputMethodManager import com.ichi2.testutils.common.Flaky import com.ichi2.testutils.common.OS import com.ichi2.testutils.ext.addNoSuggestNote import com.ichi2.testutils.ext.createInputConnection +import com.ichi2.testutils.ext.text import com.ichi2.testutils.withBooleanPreference import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertFalse @@ -73,7 +76,12 @@ import org.robolectric.RuntimeEnvironment import org.robolectric.Shadows import org.robolectric.annotation.Config import org.robolectric.shadow.api.Shadow +import org.robolectric.shadows.ShadowMediaPlayer +import org.robolectric.shadows.util.DataSource import timber.log.Timber +import java.io.File +import java.io.IOException +import java.util.concurrent.ConcurrentLinkedQueue import kotlin.test.assertContains import kotlin.test.assertNotEquals import kotlin.test.junit5.JUnit5Asserter.assertNotNull @@ -261,15 +269,31 @@ class ReviewerTest : RobolectricTest() { } @Test - fun noErrorShouldOccurIfSoundFileNotPresent() { - addBasicNote("[[sound:not_on_file_system.mp3]]", "World") + fun `missing sound is reported without interrupting review`() { + val filename = "not_on_file_system.mp3" + addBasicNote("[sound:$filename]", "World") .firstCard() .moveToReviewQueue() - val reviewer = startReviewer() + val reviewer = startReviewer(ReviewerWithSnackbarRecording::class.java) + val mediaDirectory = getMediaDirectory(reviewer) + assertFalse(File(mediaDirectory, filename).exists()) + // The default media shadow accepts every file, including missing ones. + ShadowMediaPlayer.addException( + DataSource.toDataSource(AbstractFlashcardViewer.getMediaBaseUrl(mediaDirectory) + filename), + IOException("Missing sound file"), + ) reviewer.displayCardQuestion() - assertThat("If the sound file with given name is not present, then no error occurs", true) + // Playback runs on Dispatchers.IO; draining the main looper alone can miss it. + advanceRobolectricLooperUntil { reviewer.snackbarMessages.isNotEmpty() && !reviewer.cardMediaPlayer.isPlaying } + assertThat( + reviewer.snackbarMessages.toList(), + equalTo(listOf(reviewer.getString(CommonString.card_viewer_could_not_find_image, filename))), + ) + reviewer.displayCardAnswer() + assertTrue(reviewer.isDisplayingAnswer) + assertFalse(reviewer.isFinishing) } @Test @@ -628,6 +652,15 @@ class ReviewerTest : RobolectricTest() { private val defaultDeckConfig get() = col.decks.configDictForDeckId(Consts.DEFAULT_DECK_ID) + private class ReviewerWithSnackbarRecording : Reviewer() { + val snackbarMessages = ConcurrentLinkedQueue() + + override val baseSnackbarBuilder: SnackbarBuilder = { + super.baseSnackbarBuilder.invoke(this) + text?.let(snackbarMessages::add) + } + } + private class ReviewerForMenuItems : Reviewer() { var menu: Menu? = null private set diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt index 0a7d9c6c3d6f..ce178cca9958 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt @@ -81,8 +81,13 @@ import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeSource import com.ichi2.testutils.Robolectric as RobolectricActivities -open class RobolectricTest : - AnkiTest, +/** + * @param loadBackend Disable for tests using custom sandboxes which do not need the backend. + * Loading the native library into one sandbox can prevent later sandboxes from using it (Issue 14796). + */ +open class RobolectricTest( + private val loadBackend: Boolean = true, +) : AnkiTest, AndroidTest { @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN") private fun Any.wait(timeMs: Long) = (this as Object).wait(timeMs) @@ -168,7 +173,7 @@ open class RobolectricTest : CollectionManager.closeCollectionBlocking() - maybeSetupBackend() + if (loadBackend) maybeSetupBackend() // Reset static variable for custom tabs failure. CustomTabActivityHelper.resetFailed() diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt index f0dd6c3072fb..23f5ae505bd1 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt @@ -31,8 +31,6 @@ import org.robolectric.shadow.api.Shadow import org.robolectric.shadows.ShadowWindowManagerImpl import java.io.File -interface ScreenshotTestCategory - /** * Base class for [roborazzi](https://github.com/takahirom/roborazzi) screenshot tests */ diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/StudyOptionsViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/StudyOptionsViewModelTest.kt index 886d04a8ec93..964566d2c86e 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/StudyOptionsViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/StudyOptionsViewModelTest.kt @@ -197,19 +197,19 @@ class StudyOptionsViewModelTest : RobolectricTest() { } @Test - fun `refreshData - buried cards are counted`() = + fun `refreshData - buried siblings are counted`() = runTest { - addBasicNote("Front1", "Back1") - addBasicNote("Front2", "Back2") + addBasicAndReversedNote("Front", "Back") withCol { - val card = sched.card!! - sched.buryCards(listOf(card.id), true) + val config = decks.configDictForDeckId(decks.current().id) + config.new.bury = true + decks.save(config) } viewModel.refreshData() val state = assertIs(viewModel.state) - assertTrue(state.data.buriedNew > 0, "expected buried new cards") + assertEquals(1, state.data.buriedNew) assertEquals(1, state.data.newCardsToday) } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/TtsVoicesMultiEngineTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/TtsVoicesMultiEngineTest.kt index c59eab0ebdd0..0281adbecad4 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/TtsVoicesMultiEngineTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/TtsVoicesMultiEngineTest.kt @@ -103,7 +103,7 @@ class TtsVoicesMultiEngineTest { assertTrue(defaultLocales.isCompleted) assertEquals(emptyList(), defaultLocales.await().locales) - assertEquals(emptySet(), voices) + assertEquals(emptyList(), voices) } @Test @@ -147,6 +147,61 @@ class TtsVoicesMultiEngineTest { } } + @Test + fun `default engine comes first while other engines retain discovery order`() = + runTest { + val probe = probeTts() + every { probe.defaultEngine } returns ENGINE_B + every { probe.engines } returns + listOf(ENGINE_C, ENGINE_B, ENGINE_A).map { engine -> + TextToSpeech.EngineInfo().apply { name = engine } + } + + val voices = + TtsVoices.loadVoices(CompletableDeferred()) { engine -> + if (engine == null) { + probe + } else { + fakeTts(setOf(fakeVoice("first", Locale.US), fakeVoice("second", Locale.FRANCE)), emptySet()) + } + } + + assertEquals(listOf(ENGINE_B, ENGINE_B, ENGINE_C, ENGINE_C, ENGINE_A, ENGINE_A), voices.map { it.engine }) + assertEquals(listOf("first", "second", "first", "second", "first", "second"), voices.map { it.voice.name }) + } + + @Test + fun `default engine is included first even when missing from discovered engines`() = + runTest { + val probe = probeTts() + every { probe.defaultEngine } returns ENGINE_C + + val voices = + TtsVoices.loadVoices(CompletableDeferred()) { engine -> + if (engine == null) probe else fakeTts(setOf(fakeVoice("voice", Locale.US)), emptySet()) + } + + assertEquals(listOf(ENGINE_C, ENGINE_A, ENGINE_B), voices.map { it.engine }) + } + + @Test + fun `voices are distinct by engine and name while preserving their first occurrence`() = + runTest { + val first = fakeVoice("shared", Locale.US) + val duplicate = fakeVoice("shared", Locale.US) + val other = fakeVoice("other", Locale.FRANCE) + val engineB = fakeTts(setOf(first, duplicate, other), emptySet()) + val engineA = fakeTts(setOf(fakeVoice("shared", Locale.US)), emptySet()) + + val voices = + TtsVoices.loadVoicesFromEngines(listOf(ENGINE_B, ENGINE_A, ENGINE_B)) { engine -> + if (engine == ENGINE_B) engineB else engineA + } + + assertEquals(listOf("$ENGINE_B-shared", "$ENGINE_B-other", "$ENGINE_A-shared"), voices.map { it.name }) + assertSame(first, voices.first().voice) + } + @Test fun `voices are aggregated across multiple engines`() = runBlocking { @@ -222,7 +277,7 @@ class TtsVoicesMultiEngineTest { setUpFailingTts() val locales = CompletableDeferred() - assertEquals(emptySet(), TtsVoices.loadVoices(locales, TtsVoices::createTts)) + assertEquals(emptyList(), TtsVoices.loadVoices(locales, TtsVoices::createTts)) assertEquals(TtsVoices.EngineLocales(null, emptyList()), locales.await()) } @@ -320,5 +375,6 @@ class TtsVoicesMultiEngineTest { companion object { private const val ENGINE_A = "com.example.engine.a" private const val ENGINE_B = "com.example.engine.b" + private const val ENGINE_C = "com.example.engine.c" } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/DeckPickerBackgroundTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/DeckPickerBackgroundTest.kt new file mode 100644 index 000000000000..7f0528cc1bd0 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/DeckPickerBackgroundTest.kt @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.deckpicker + +import android.content.Intent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.CommonString +import com.ichi2.anki.DeckPicker +import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.common.storage.CollectionHelper +import com.ichi2.anki.setIntroductionSlidesShown +import com.ichi2.anki.settings.Prefs +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo +import org.hamcrest.Matchers.nullValue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.android.controller.ActivityController +import org.robolectric.shadows.ShadowToast +import java.awt.image.BufferedImage +import java.io.File +import javax.imageio.ImageIO + +@RunWith(AndroidJUnit4::class) +class DeckPickerBackgroundTest : RobolectricTest() { + @Test + fun `background is released while the deck picker is stopped`() { + saveBackgroundImage(width = 10, height = 10) + val controller = startDeckPicker() + val background = controller.get().deckPickerBinding.background + advanceRobolectricLooperUntil { background.drawable != null } + + controller.pause().stop() + assertThat(background.drawable, nullValue()) + + controller.start().resume() + advanceRobolectricLooperUntil { background.drawable != null } + } + + @Test + fun `a background that is too large is reported once`() { + saveBackgroundImage(width = 6_000, height = 5_000) + val controller = startDeckPicker() + advanceRobolectricLooperUntil { ShadowToast.shownToastCount() > 0 } + + controller.pause().stop() + controller.start().resume() + advanceRobolectricLooper() + + assertThat(ShadowToast.shownToastCount(), equalTo(1)) + assertThat(ShadowToast.getTextOfLatestToast(), equalTo(targetContext.getString(CommonString.background_image_too_large))) + } + + private fun saveBackgroundImage( + width: Int, + height: Int, + ) { + val directory = CollectionHelper.getCurrentAnkiDroidDirectory(targetContext).apply { mkdirs() } + ImageIO.write(BufferedImage(width, height, BufferedImage.TYPE_BYTE_BINARY), "png", File(directory, BackgroundImage.FILENAME)) + Prefs.isBackgroundEnabled = true + } + + private fun startDeckPicker(): ActivityController { + setIntroductionSlidesShown(true) + return startActivityControllerNormallyOpenCollectionWithIntent(DeckPicker::class.java, Intent()) + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/CustomStudyDialogTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/CustomStudyDialogTest.kt index e5d245ec0356..d58d9c52a8dd 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/CustomStudyDialogTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/CustomStudyDialogTest.kt @@ -27,8 +27,8 @@ import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog.ContextMenuOption import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog.CustomStudyDefaults.Companion.toDomainModel import com.ichi2.anki.dialogs.customstudy.CustomStudyViewModel -import com.ichi2.anki.dialogs.tags.TagsDialogListener.Companion.ON_SELECTED_TAGS_KEY -import com.ichi2.anki.dialogs.tags.TagsDialogListener.Companion.ON_SELECTED_TAGS__SELECTED_TAGS +import com.ichi2.anki.dialogs.tags.TagsDialogListener.Companion.REQUEST_SELECT_TAGS +import com.ichi2.anki.dialogs.tags.TagsDialogListener.Companion.RESULT_SELECTED_TAGS import com.ichi2.anki.dialogs.utils.performPositiveClick import com.ichi2.anki.libanki.CardType import com.ichi2.anki.libanki.Collection @@ -224,8 +224,8 @@ class CustomStudyDialogTest : RobolectricTest() { val selectedTags = ArrayList(1).apply { add("testTag") } // simulate tag selection studyDialog.parentFragmentManager.setFragmentResult( - ON_SELECTED_TAGS_KEY, - Bundle().apply { putStringArrayList(ON_SELECTED_TAGS__SELECTED_TAGS, selectedTags) }, + REQUEST_SELECT_TAGS, + Bundle().apply { putStringArrayList(RESULT_SELECTED_TAGS, selectedTags) }, ) val customStudyDeck = col.decks.customStudySession assertNotNull(customStudyDeck) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/mediacheck/MediaCheckFragmentTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/mediacheck/MediaCheckFragmentTest.kt index 07fd7a308094..88802481ea0b 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/mediacheck/MediaCheckFragmentTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/mediacheck/MediaCheckFragmentTest.kt @@ -2,7 +2,9 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 Ashish Yadav package com.ichi2.anki.mediacheck +import android.webkit.WebView import android.widget.Button +import androidx.fragment.app.commitNow import androidx.test.ext.junit.runners.AndroidJUnit4 import anki.backend.BackendError import anki.backend.backendError @@ -12,6 +14,7 @@ import com.ichi2.anki.CommonPlurals import com.ichi2.anki.R import com.ichi2.anki.RobolectricTest import com.ichi2.anki.SingleFragmentActivity +import com.ichi2.anki.workarounds.SafeWebViewLayout import com.ichi2.testutils.rules.BackendOverrideRule import com.ichi2.utils.positiveButton import kotlinx.coroutines.test.StandardTestDispatcher @@ -22,10 +25,13 @@ import net.ankiweb.rsdroid.exceptions.BackendIoException import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf import org.robolectric.android.controller.ActivityController import org.robolectric.shadows.ShadowDialog import java.io.File import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull import kotlin.test.assertNotSame import kotlin.test.assertNull import kotlin.test.assertSame @@ -61,6 +67,48 @@ class MediaCheckFragmentTest : RobolectricTest() { assertEquals(failure.localizedMessage, getAlertDialogText(checkDismissed = true)) } + @Test + fun `onRenderProcessGone recovers webview and reloads content`() = + runTest { + val mediaCheck = startMediaCheck() + advanceUntilIdle() + advanceRobolectricLooper() + + val layout = mediaCheck.get().findViewById(R.id.web_view) + val initialWebView = assertNotNull(layout.getChildAt(0) as? WebView) + + layout.onRenderProcessGone(initialWebView) + advanceRobolectricLooper() + + assertTrue(shadowOf(initialWebView).wasDestroyCalled(), "Dead WebView should be destroyed") + assertNull(initialWebView.parent, "Dead WebView should be removed from parent") + + val recoveredWebView = assertNotNull(layout.getChildAt(0) as? WebView) + assertNotSame(initialWebView, recoveredWebView, "A new WebView instance should be created") + assertFalse(shadowOf(recoveredWebView).wasDestroyCalled(), "Recovered WebView should not be destroyed") + } + + @Test + fun `onRenderProcessGone after fragment view is destroyed cleans up dead webview without recreating`() = + runTest { + val mediaCheck = startMediaCheck() + advanceUntilIdle() + advanceRobolectricLooper() + + val layout = mediaCheck.get().findViewById(R.id.web_view) + val initialWebView = assertNotNull(layout.getChildAt(0) as? WebView) + + val fragment = mediaCheck.get().supportFragmentManager.findFragmentById(R.id.fragment_container) as MediaCheckFragment + mediaCheck.get().supportFragmentManager.commitNow { detach(fragment) } + assertNull(fragment.view, "Fragment view should be null after detach") + + layout.onRenderProcessGone(initialWebView) + + assertTrue(shadowOf(initialWebView).wasDestroyCalled(), "Dead WebView should be destroyed") + assertNull(initialWebView.parent, "Dead WebView should be removed from parent") + assertEquals(0, layout.childCount, "No new WebView should be created when fragment view is gone") + } + @Test fun `deleting unused media shows the result`() = runTest { diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/multimedia/ImageEditorTestHelper.kt b/AnkiDroid/src/test/java/com/ichi2/anki/multimedia/ImageEditorTestHelper.kt new file mode 100644 index 000000000000..c40c4ca86aed --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/multimedia/ImageEditorTestHelper.kt @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.multimedia + +import android.app.Activity +import android.content.Intent +import android.graphics.Bitmap +import android.net.Uri +import androidx.annotation.StringRes +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.core.content.FileProvider +import androidx.core.graphics.createBitmap +import com.ichi2.anki.CommonString +import com.ichi2.anki.R +import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.RobolectricTest.Companion.advanceRobolectricLooper +import com.ichi2.anki.multimediacard.fields.ImageField +import com.ichi2.anki.multimediacard.impl.MultimediaEditableNote +import com.ichi2.imagecropper.ImageCropper +import org.junit.rules.ExternalResource +import org.robolectric.Shadows.shadowOf +import org.robolectric.android.controller.ActivityController +import org.robolectric.util.ReflectionHelpers +import java.io.File +import kotlin.test.assertIs +import kotlin.test.assertNotNull + +/** Launches the image editor and supplies results from its cropper and picker. */ +class ImageEditorTestHelper( + private val test: RobolectricTest, + private val compose: SemanticsNodeInteractionsProvider, +) : ExternalResource() { + private val context get() = test.targetContext + private lateinit var controller: ActivityController + + val fragment: MultimediaImageFragment + get() = controller.get().supportFragmentManager.findFragmentById(R.id.fragment_container) as MultimediaImageFragment + + override fun before() = clearFileProviderRoots() + + override fun after() = clearFileProviderRoots() + + private fun clearFileProviderRoots() { + // Each Robolectric test has its own cache directory; discard AndroidX's cached roots. + ReflectionHelpers.getStaticField>(FileProvider::class.java, "sCache").clear() + } + + fun open(uri: Uri) { + val field = ImageField() + val note = + MultimediaEditableNote().apply { + setNumFields(1) + setField(0, field) + freezeInitialFieldValues() + } + val intent = + MultimediaImageFragment.getIntent( + context, + MultimediaActivityExtra(0, field, note, uri.toString()), + MultimediaImageFragment.ImageOptions.GALLERY, + ) + controller = test.startActivityControllerNormallyOpenCollectionWithIntent(MultimediaActivity::class.java, intent) + } + + fun cropTo(image: File) { + val result = + Intent().putExtra( + ImageCropper.EXTRA_CROP_IMAGE_RESULT, + ImageCropper.CropResultData(contentUri(image), image.absolutePath), + ) + returnImage(CommonString.crop_button, result) + } + + fun replaceWith(image: File) = returnImage(CommonString.dialog_positive_replace, Intent().setData(contentUri(image))) + + private fun returnImage( + @StringRes button: Int, + result: Intent, + ) { + compose.onNodeWithText(context.getString(button)).performClick() + val activity = shadowOf(controller.get()) + val request = assertNotNull(activity.nextStartedActivityForResult) + activity.receiveResult(request.intent, Activity.RESULT_OK, result) + advanceRobolectricLooper() + } + + fun recreate() { + controller.recreate() + advanceRobolectricLooper() + } + + fun finish(): File { + fragment.onDone() + val activity = shadowOf(controller.get()) + val result = assertIs(MultimediaResultContract().parseResult(activity.resultCode, activity.resultIntent)) + return assertNotNull(result.field.mediaFile) + } + + fun createImage( + name: String, + color: Int, + ): File { + val image = File(context.cacheDir, name) + val bitmap = createBitmap(8, 8) + bitmap.eraseColor(color) + image.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + bitmap.recycle() + return image + } + + fun contentUri(file: File): Uri = FileProvider.getUriForFile(context, context.packageName + ".apkgfileprovider", file) +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/multimedia/MultimediaImageRecreationTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/multimedia/MultimediaImageRecreationTest.kt new file mode 100644 index 000000000000..9ff627a33d23 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/multimedia/MultimediaImageRecreationTest.kt @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.multimedia + +import android.graphics.Color +import android.net.Uri +import android.text.format.Formatter +import android.webkit.WebView +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.RobolectricTest +import com.ichi2.testutils.ShadowFileProviderPathStrategy +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import java.io.File +import kotlin.test.assertContains +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +@RunWith(AndroidJUnit4::class) +@Config(shadows = [ShadowFileProviderPathStrategy::class]) +class MultimediaImageRecreationTest : RobolectricTest(loadBackend = false) { + @get:Rule + val composeRule = createComposeRule() + + @get:Rule + val editor = ImageEditorTestHelper(this, composeRule) + + @Test + fun `cropped gallery image survives recreation`() { + val original = editor.createImage("original.png", Color.RED) + assertCropSurvivesRecreation(editor.contentUri(original)) + } + + @Test + fun `cropped shared image survives recreation`() { + val original = editor.createImage("original.png", Color.RED) + assertCropSurvivesRecreation(Uri.fromFile(original)) + } + + @Test + fun `replacement image survives recreation`() { + editor.open(editor.contentUri(editor.createImage("original.png", Color.RED))) + val replacement = editor.createImage("replacement.png", Color.BLUE) + editor.replaceWith(replacement) + + assertEditSurvivesRecreation(replacement) + } + + private fun assertCropSurvivesRecreation(original: Uri) { + editor.open(original) + val cropped = editor.createImage("cropped.png", Color.GREEN) + editor.cropTo(cropped) + + assertEditSurvivesRecreation(cropped) + } + + private fun assertEditSurvivesRecreation(expectedImage: File) { + val editedFile = assertNotNull(editor.fragment.viewModel.currentMultimediaPath.value) + val expectedUri = editor.contentUri(expectedImage) + assertEquals(expectedUri, editor.fragment.viewModel.currentMultimediaUri.value) + + editor.recreate() + + val fragment = editor.fragment + assertEquals(editedFile, fragment.viewModel.currentMultimediaPath.value) + assertEquals(expectedUri, fragment.viewModel.currentMultimediaUri.value) + assertEquals(expectedImage.length(), fragment.viewModel.selectedMediaFileSize) + assertEquals( + Formatter.formatFileSize(targetContext, expectedImage.length()), + fragment.binding.imageFileSize.text + .toString(), + ) + val webView = fragment.binding.multimediaWebView.getChildAt(0) as WebView + assertContains(shadowOf(webView).lastLoadDataWithBaseURL.data, editedFile.name) + assertNull(shadowOf(fragment.requireActivity()).nextStartedActivityForResult) + + val result = editor.finish() + assertEquals(editedFile, result) + assertContentEquals(expectedImage.readBytes(), result.readBytes()) + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileIdTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileIdTest.kt index ba1a7974bb94..0fb8f1dd1c3f 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileIdTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileIdTest.kt @@ -16,7 +16,7 @@ class ProfileIdTest { } @Test - fun `generate creates unique IDs using the purely random portion of a UUID`() { + fun `generate uses the purely random portion of a UUID`() { val iterations = 1000 val generatedIds = List(iterations) { ProfileId.generate().value } @@ -25,9 +25,6 @@ class ProfileIdTest { assertEquals("ID length should be 10 (p_ + 8 hex chars)", 10, id.length) } - val uniqueIds = generatedIds.toSet() - assertEquals("All $iterations generated IDs should be unique", iterations, uniqueIds.size) - val prefixLength = 2 val hexLength = 8 diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileManagerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileManagerTest.kt index 0bfb264d80c9..d0d64ecec5d8 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileManagerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileManagerTest.kt @@ -35,6 +35,7 @@ import org.junit.jupiter.api.Assertions.assertThrows import org.junit.runner.RunWith import org.robolectric.annotation.Config import java.io.File +import java.util.UUID import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -340,6 +341,24 @@ class ProfileManagerTest { assertTrue(allProfiles.containsKey(ProfileId.DEFAULT)) } + @Test + fun `a new profile ID that matches an existing profile is generated again`() { + val manager = ProfileManager.create(context) + mockkStatic(UUID::class) + every { UUID.randomUUID() } returnsMany + listOf( + UUID.fromString("aaaaaaaa-0000-4000-8000-000000000000"), + UUID.fromString("aaaaaaaa-1111-4111-8111-111111111111"), + UUID.fromString("bbbbbbbb-0000-4000-8000-000000000000"), + ) + + val first = manager.createNewProfile(ProfileName.fromTrustedSource("Work")) + val second = manager.createNewProfile(ProfileName.fromTrustedSource("Personal")) + + assertEquals(ProfileId("p_aaaaaaaa"), first) + assertEquals(ProfileId("p_bbbbbbbb"), second) + } + @Test fun `renameProfile updates displayName in registry`() { val manager = ProfileManager.create(context) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/noteeditor/AudioRecordingViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/noteeditor/AudioRecordingViewModelTest.kt index dc53d9d81a56..1957ca468aa0 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/noteeditor/AudioRecordingViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/noteeditor/AudioRecordingViewModelTest.kt @@ -3,6 +3,7 @@ package com.ichi2.anki.noteeditor +import android.content.Context import android.net.Uri import androidx.lifecycle.ViewModelStore import androidx.media3.common.MediaItem @@ -14,6 +15,7 @@ import com.ichi2.anki.recorder.MediaRecorderStub import com.ichi2.anki.recorder.PlayerState import com.ichi2.anki.recorder.PlayerStub import com.ichi2.anki.recorder.RecorderState +import com.ichi2.anki.recorder.VoiceRecorder import com.ichi2.testutils.JvmTest import io.mockk.every import io.mockk.verify @@ -333,6 +335,22 @@ class AudioRecordingViewModelTest : JvmTest() { assertEquals(RecorderState.Recorded(recording), viewModel.recorderState.value, "the recording can still be inserted") } + @Test + fun `recordings are stored outside the cache`() = + runTest { + val context = ApplicationProvider.getApplicationContext() + val viewModel = + AudioRecordingViewModel(AudioRecorder(context), playerStub.factory) + .also { viewModelStore.put(VIEW_MODEL_KEY, it) } + try { + val recording = viewModel.record() + + assertEquals(File(context.noBackupFilesDir, VoiceRecorder.RECORDINGS_DIRECTORY), recording.parentFile) + } finally { + viewModelStore.clear() + } + } + private fun runViewModelTest(testBody: suspend TestScope.(AudioRecordingViewModel) -> Unit) = runTest { val viewModel = diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/noteeditor/NoteEditorMultimediaControllerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/noteeditor/NoteEditorMultimediaControllerTest.kt index d867beaf7c11..21b09bc0149c 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/noteeditor/NoteEditorMultimediaControllerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/noteeditor/NoteEditorMultimediaControllerTest.kt @@ -3,11 +3,19 @@ package com.ichi2.anki.noteeditor +import android.app.Activity +import android.content.Intent +import android.net.Uri import android.os.Bundle +import android.provider.MediaStore +import androidx.core.net.toUri import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.NoteEditorActivity import com.ichi2.anki.NoteEditorFragment import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.common.destinations.NoteEditorDestination import com.ichi2.anki.compat.CompatHelper.Companion.getSerializableCompat +import com.ichi2.anki.multimedia.MultimediaActivity import com.ichi2.anki.multimedia.MultimediaArgsStorage import com.ichi2.anki.multimedia.MultimediaBottomSheet import com.ichi2.anki.multimedia.MultimediaResult @@ -21,6 +29,7 @@ import kotlinx.coroutines.test.advanceUntilIdle import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.equalTo import org.hamcrest.Matchers.notNullValue +import org.hamcrest.Matchers.nullValue import org.junit.Test import org.junit.runner.RunWith import org.robolectric.Shadows.shadowOf @@ -30,6 +39,84 @@ import java.io.IOException @RunWith(AndroidJUnit4::class) class NoteEditorMultimediaControllerTest : RobolectricTest() { + @Test + fun `cancelling gallery returns directly to the unchanged note editor`() { + val noteEditor = openNoteEditor() + val field = noteEditor.getFieldForTest(1) + field.setText("back") + field.requestFocus() + field.setSelection(1, 3) + selectGallery(noteEditor, 1) + val activity = shadowOf(noteEditor.requireActivity()) + val picker = activity.nextStartedActivityForResult.intent + + assertThat(picker.action, equalTo(Intent.ACTION_PICK)) + assertThat(picker.data, equalTo(MediaStore.Images.Media.EXTERNAL_CONTENT_URI)) + assertThat("no intermediate editor", picker.component, nullValue()) + activity.receiveResult(picker, Activity.RESULT_CANCELED, null) + advanceRobolectricLooper() + + assertThat(activity.nextStartedActivityForResult, nullValue()) + assertThat(noteEditor.requireActivity().isFinishing, equalTo(false)) + assertThat(field.text.toString(), equalTo("back")) + assertThat(field.selectionStart, equalTo(1)) + assertThat(field.selectionEnd, equalTo(3)) + + selectGallery(noteEditor, 1) + assertThat(activity.nextStartedActivityForResult.intent.action, equalTo(Intent.ACTION_PICK)) + } + + @Test + fun `gallery selection opens the image editor for the selected field with read access`() { + val noteEditor = openNoteEditor() + noteEditor.setField(0, "front") + noteEditor.setField(1, "back") + selectGallery(noteEditor, 1) + val activity = shadowOf(noteEditor.requireActivity()) + val picker = activity.nextStartedActivityForResult.intent + val imageUri = "content://media/external/images/media/1".toUri() + + activity.receiveResult(picker, Activity.RESULT_OK, Intent().setData(imageUri)) + advanceRobolectricLooper() + + assertGalleryEditor(activity.nextStartedActivityForResult.intent, imageUri, 1) + assertThat(noteEditor.editFieldAt(0)!!.text.toString(), equalTo("front")) + assertThat(noteEditor.editFieldAt(1)!!.text.toString(), equalTo("back")) + } + + @Test + fun `gallery result retains its target field after note editor recreation`() { + ensureCollectionLoadIsSynchronous() + val controller = + startActivityControllerNormallyOpenCollectionWithIntent( + NoteEditorActivity::class.java, + NoteEditorDestination.PassArguments(NoteEditorFragment.addNoteArgs()).toIntent(targetContext), + ) + selectGallery(controller.get().getNoteEditorFragment(), 1) + val picker = shadowOf(controller.get()).nextStartedActivityForResult + + controller.recreate() + advanceRobolectricLooper() + val imageUri = "content://media/external/images/media/2".toUri() + controller.get().activityResultRegistry.dispatchResult(picker.requestCode, Activity.RESULT_OK, Intent().setData(imageUri)) + advanceRobolectricLooper() + + assertGalleryEditor(shadowOf(controller.get()).nextStartedActivityForResult.intent, imageUri, 1) + } + + @Test + fun `gallery file uri result cannot open the image editor`() { + val noteEditor = openNoteEditor() + selectGallery(noteEditor, 0) + val activity = shadowOf(noteEditor.requireActivity()) + val picker = activity.nextStartedActivityForResult.intent + + activity.receiveResult(picker, Activity.RESULT_OK, Intent().setData(Uri.fromFile(File("/private/image.png")))) + advanceRobolectricLooper() + + assertThat(activity.nextStartedActivityForResult, nullValue()) + } + @Test fun `controller is reachable from the hosted fragment`() { val noteEditor = openNoteEditor() @@ -129,6 +216,30 @@ class NoteEditorMultimediaControllerTest : RobolectricTest() { assertThat(editText.selectionEnd, equalTo(5)) } + private fun selectGallery( + noteEditor: NoteEditorFragment, + fieldIndex: Int, + ) { + noteEditor.multimediaController.handleActions(fieldIndex) + advanceRobolectricLooper() + noteEditor.multimediaViewModel.setMultimediaAction(MultimediaBottomSheet.MultimediaAction.SELECT_IMAGE_FILE) + advanceRobolectricLooper() + } + + private fun assertGalleryEditor( + intent: Intent, + imageUri: Uri, + fieldIndex: Int, + ) { + assertThat(intent.component!!.className, equalTo(MultimediaActivity::class.java.name)) + assertThat(intent.data, equalTo(imageUri)) + assertThat(intent.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION, equalTo(Intent.FLAG_GRANT_READ_URI_PERMISSION)) + val argsFile = intent.extras!!.getSerializableCompat(MultimediaActivity.EXTRA_FRAGMENT_ARGS)!! + val args = MultimediaArgsStorage.create(targetContext).read(argsFile) + assertThat(args.index, equalTo(fieldIndex)) + assertThat(args.imageUri, equalTo(imageUri.toString())) + } + private fun openNoteEditor(): NoteEditorFragment { ensureCollectionLoadIsSynchronous() return openNoteEditorWithArgs(NoteEditorFragment.addNoteArgs()) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/notetype/ManageNoteTypesViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/notetype/ManageNoteTypesViewModelTest.kt index 4a13001ba6b5..da74ed5d32b5 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/notetype/ManageNoteTypesViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/notetype/ManageNoteTypesViewModelTest.kt @@ -3,13 +3,21 @@ package com.ichi2.anki.notetype import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.cash.turbine.test import com.ichi2.anki.RobolectricTest import com.ichi2.anki.exception.CombinedException import com.ichi2.anki.libanki.Collection +import com.ichi2.anki.libanki.getNotetypeNameIdUseCount import com.ichi2.anki.libanki.removeNotetype +import com.ichi2.anki.notetype.ManageNoteTypesState.UserMessage +import com.ichi2.anki.progress.ViewModelProgress import io.mockk.every import io.mockk.mockkStatic import io.mockk.unmockkStatic +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.setMain import org.hamcrest.CoreMatchers.equalTo import org.hamcrest.CoreMatchers.hasItems import org.hamcrest.CoreMatchers.not @@ -23,6 +31,7 @@ import org.junit.runner.RunWith import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -191,6 +200,68 @@ class ManageNoteTypesViewModelTest : RobolectricTest() { unmockkStatic(Collection::removeNotetype) } + @Test + fun `refreshNoteTypes reports progress`() = + runProgressTest { + assertProgressAround { refreshNoteTypes() } + } + + @Test + fun `rename reports progress`() = + runProgressTest { + assertProgressAround { rename(noteTypeForName(TEST_NAME_1).id, TEST_NAME_2) } + } + + @Test + fun `delete reports progress`() = + runProgressTest { + assertProgressAround { delete(noteTypeForName(TEST_NAME_1).id) } + } + + @Test + fun `deleteSelectedNoteTypes reports progress`() = + runProgressTest { + onItemLongClick(noteTypeForName(TEST_NAME_1)) + + assertProgressAround { deleteSelectedNoteTypes() } + } + + @Test + fun `deleting the last note type reports no progress`() = + runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + col.getNotetypeNameIdUseCount().drop(1).forEach { col.removeNotetype(it.id) } + val viewModel = ManageNoteTypesViewModel() + advanceUntilIdle() + + viewModel.progressManager.progress.test { + assertIs(awaitItem()) + viewModel.delete(viewModel.noteTypes.single().id) + advanceUntilIdle() + expectNoEvents() + } + assertEquals(UserMessage.DeletingLastModel, viewModel.state.value.message) + } + + private fun runProgressTest(testBody: suspend ManageNoteTypesViewModel.() -> Unit) = + runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + addStandardNoteType(TEST_NAME_1, arrayOf("front", "back"), "", "") + val viewModel = ManageNoteTypesViewModel() + advanceUntilIdle() + viewModel.testBody() + } + + private suspend fun ManageNoteTypesViewModel.assertProgressAround(op: () -> Unit) { + progressManager.progress.test { + assertIs(awaitItem()) + op() + assertIs>(awaitItem()) + assertIs(awaitItem()) + expectNoEvents() + } + } + /** Returns the current list of [NoteTypeItemState] */ private val ManageNoteTypesViewModel.noteTypes: List get() = state.value.noteTypes diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/notetype/ManageNotetypesTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/notetype/ManageNotetypesTest.kt index 7ab41a336f70..c8a426a42b04 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/notetype/ManageNotetypesTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/notetype/ManageNotetypesTest.kt @@ -3,13 +3,21 @@ package com.ichi2.anki.notetype +import android.view.WindowManager import android.widget.EditText import androidx.appcompat.app.AlertDialog import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.CollectionManager import com.ichi2.anki.R import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.dialogs.LoadingDialogFragment +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.equalTo +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.robolectric.shadows.ShadowDialog @@ -48,6 +56,70 @@ class ManageNotetypesTest : RobolectricTest() { assertThat("Rename button disabled for empty name", positiveButton.isEnabled, equalTo(false)) } + @Test + fun `deleting a note type blocks touches until it finishes`() = + runTest { + ensureCollectionLoadIsSynchronous() + addStandardNoteType(TEST_NOTE_TYPE_NAME, arrayOf("front", "back"), "", "") + val activity = startRegularActivity() + val noteType = + activity.viewModel.state.value.noteTypes + .first { it.name == TEST_NOTE_TYPE_NAME } + + withQueuedCollectionAccess { + activity.viewModel.delete(noteType.id) + assertTrue("touches blocked while deleting", activity.touchesBlocked()) + } + advanceUntilIdle() + + assertFalse("touches restored after deleting", activity.touchesBlocked()) + assertTrue( + "note type deleted", + activity.viewModel.state.value.noteTypes + .none { it.id == noteType.id }, + ) + } + + @Test + fun `back during the progress delay does not finish an activity deleting note types`() = + runTest { + ensureCollectionLoadIsSynchronous() + addStandardNoteType(TEST_NOTE_TYPE_NAME, arrayOf("front", "back"), "", "") + val activity = startRegularActivity() + val noteType = + activity.viewModel.state.value.noteTypes + .first { it.name == TEST_NOTE_TYPE_NAME } + activity.viewModel.onItemLongClick(noteType) + + try { + withQueuedCollectionAccess { + activity.viewModel.deleteSelectedNoteTypes() + assertTrue("deletion is still in progress", activity.touchesBlocked()) + assertFalse( + "the progress dialog has not appeared yet", + activity.supportFragmentManager.fragments.any { it is LoadingDialogFragment }, + ) + + activity.onBackPressedDispatcher.onBackPressed() + + assertFalse("Back must not finish the activity during deletion", activity.isFinishing) + } + } finally { + advanceUntilIdle() + } + } + + private suspend fun TestScope.withQueuedCollectionAccess(block: suspend () -> T): T { + val previousQueue = CollectionManager.setTestDispatcher(StandardTestDispatcher(testScheduler), useReentrantLock = false) + try { + return block() + } finally { + CollectionManager.setTestDispatcher(previousQueue) + } + } + + private fun ManageNotetypes.touchesBlocked() = window.attributes.flags and WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE != 0 + companion object { private const val TEST_NOTE_TYPE_NAME = "BasicTestNoteType1" } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/preferences/profiles/SwitchProfilesFragmentTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/preferences/profiles/SwitchProfilesFragmentTest.kt index a091cd36d9eb..2b1fc478194c 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/preferences/profiles/SwitchProfilesFragmentTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/preferences/profiles/SwitchProfilesFragmentTest.kt @@ -13,8 +13,6 @@ import com.ichi2.anki.preferences.HeaderFragment import com.ichi2.anki.preferences.PreferencesActivity import com.ichi2.anki.preferences.PreferencesFragment import com.ichi2.anki.preferences.requirePreference -import io.mockk.every -import io.mockk.mockkObject import org.junit.Test import org.junit.runner.RunWith import org.robolectric.annotation.Config @@ -44,18 +42,20 @@ class SwitchProfilesFragmentTest : RobolectricTest() { private fun openSwitchProfilesWhenProfilesFailedToLoad(): PreferencesActivity { val app = AnkiDroidApp.instance - lateinit var activity: PreferencesActivity - mockkObject(app) { - every { app.profileManager } returns null - activity = startRegularActivity(PreferencesActivity.getIntent(targetContext)) + val profileManager = app.profileManager + app.profileManager = null + try { + val activity = startRegularActivity(PreferencesActivity.getIntent(targetContext)) activity.settings.fragments .filterIsInstance() .single() .requirePreference(R.string.pref_switch_profile_screen_key) .performClick() advanceRobolectricLooper() + return activity + } finally { + app.profileManager = profileManager } - return activity } private val PreferencesActivity.settings diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/previewer/CardViewerFragmentTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/previewer/CardViewerFragmentTest.kt index 583a4a0e1a41..7db4941185d0 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/previewer/CardViewerFragmentTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/previewer/CardViewerFragmentTest.kt @@ -7,6 +7,7 @@ import android.view.ViewGroup import android.webkit.PermissionRequest import android.webkit.WebChromeClient import android.webkit.WebView +import androidx.core.net.toUri import androidx.lifecycle.Lifecycle import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -15,7 +16,11 @@ import com.ichi2.anki.R import com.ichi2.anki.RobolectricTest import com.ichi2.anki.browser.IdsFile import com.ichi2.anki.settings.Prefs +import com.ichi2.testutils.assertNoActivityStarted +import com.ichi2.testutils.assertOpenedUrl import com.ichi2.testutils.createTransientDirectory +import com.ichi2.testutils.mockWebResourceRequest +import com.ichi2.testutils.registerWebBrowser import com.ichi2.utils.negativeButton import com.ichi2.utils.positiveButton import org.hamcrest.MatcherAssert.assertThat @@ -34,6 +39,37 @@ import org.robolectric.shadows.ShadowDialog @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) // WebChromeClient @RunWith(AndroidJUnit4::class) class CardViewerFragmentTest : RobolectricTest() { + @Test + fun `web links open with external app permission disabled`() { + editPreferences { putBoolean(targetContext.getString(R.string.pref_allow_card_external_launch_key), false) } + targetContext.registerWebBrowser() + withCardViewerWebView { webView -> + for (scheme in listOf("http", "https")) { + val url = "$scheme://github.com/ankidroid/Anki-Android/releases" + shadowOf(webView).webViewClient.shouldOverrideUrlLoading(webView, mockWebResourceRequest(url)) + targetContext.assertOpenedUrl(url.toUri()) + } + } + } + + @Test + fun `custom schemes and parsed intents still require external app permission`() { + editPreferences { putBoolean(targetContext.getString(R.string.pref_allow_card_external_launch_key), false) } + targetContext.registerWebBrowser() + withCardViewerWebView { webView -> + val urls = + listOf( + "market://details?id=com.example", + "intent://example.org/#Intent;scheme=https;end", + "android-app://com.example/https/example.org", + ) + for (url in urls) { + shadowOf(webView).webViewClient.shouldOverrideUrlLoading(webView, mockWebResourceRequest(url)) + targetContext.assertNoActivityStarted(url) + } + } + } + @Test fun `dismissing the opt-in dialog denies the audio capture request`() { grantRecordAudioPermission() @@ -115,7 +151,12 @@ class CardViewerFragmentTest : RobolectricTest() { } /** Runs [block] on the [WebChromeClient] attached to an open previewer's WebView */ - private fun withCardViewerChromeClient(block: (WebChromeClient) -> Unit) { + private fun withCardViewerChromeClient(block: (WebChromeClient) -> Unit) = + withCardViewerWebView { webView -> + block(requireNotNull(webView.webChromeClient) { "no WebChromeClient attached" }) + } + + private fun withCardViewerWebView(block: (WebView) -> Unit) { val note = addBasicAndReversedNote() val intent = PreviewerFragment.getIntent( @@ -133,7 +174,7 @@ class CardViewerFragmentTest : RobolectricTest() { .map { webViewLayout.getChildAt(it) } .filterIsInstance() .single() - block(requireNotNull(webView.webChromeClient) { "no WebChromeClient attached" }) + block(webView) } } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/previewer/PreviewerViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/previewer/PreviewerViewModelTest.kt index 30ae3ef122ae..44c4a53ce563 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/previewer/PreviewerViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/previewer/PreviewerViewModelTest.kt @@ -11,6 +11,7 @@ import com.ichi2.anki.utils.ext.flag import com.ichi2.testutils.JvmTest import io.mockk.coEvery import io.mockk.spyk +import kotlinx.coroutines.Job import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -58,6 +59,7 @@ class PreviewerViewModelTest : JvmTest() { val note = addBasicNote() note.cards().map { it.id } } + updateDeckConfig(col.decks.selected()) { autoplay = false } viewModel = spyk(PreviewerViewModel(SavedStateHandle(), cardIds, initialIndex = 0)) // the default implementation requires the Collection media directory, // which needs Robolectric with CollectionStorageMode.IN_MEMORY_WITH_MEDIA or ON_DISK @@ -254,4 +256,52 @@ class PreviewerViewModelTest : JvmTest() { onSliderChange(sliderPosition = 2) assertEquals("Index should update for valid input", 1, viewModel.currentIndex.value) } + + @Test + fun `showing the answer stops the question audio`() = + runTest { + viewModel.onPageFinished(false) + val audio = audioPlaying() + + onNextButtonClick() + + assertTrue("question audio should stop", audio.isCancelled) + } + + @Test + fun `going back to the question stops the answer audio`() = + runTest { + viewModel.onPageFinished(false) + onNextButtonClick() + val audio = audioPlaying() + + onPreviousButtonClick() + + assertTrue("answer audio should stop", audio.isCancelled) + } + + @Test + fun `showing only the back side stops the question audio`() = + runTest { + viewModel.onPageFinished(false) + val audio = audioPlaying() + + toggleBackSideOnly() + + assertTrue("question audio should stop", audio.isCancelled) + } + + @Test + fun `audio keeps playing after a configuration change`() = + runTest { + viewModel.onPageFinished(false) + val audio = audioPlaying() + + viewModel.onPageFinished(isAfterRecreation = true) + advanceUntilIdle() + + assertFalse("audio should keep playing", audio.isCancelled) + } + + private fun audioPlaying() = Job().also { viewModel.cardMediaPlayer.playAvTagsJob = it } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/previewer/TypeAnswerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/previewer/TypeAnswerTest.kt index 38ffabf6ce7c..66c35dc5135c 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/previewer/TypeAnswerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/previewer/TypeAnswerTest.kt @@ -65,6 +65,62 @@ class TypeAnswerTest : JvmTest() { assertThat(typeAnswer.noSuggest, equalTo(true)) } + @Test + fun `combining is true when nc modifier is absent`() = + runTest { + val card = addBasicWithTypingNote("front", "back").firstCard() + val typeAnswer = requireNotNull(TypeAnswer.getInstance(card, "[[type:Back]]")) + assertThat(typeAnswer.combining, equalTo(true)) + } + + @Test + fun `combining is false when nc modifier is present`() = + runTest { + val card = addBasicWithTypingNote("front", "back").firstCard() + val typeAnswer = requireNotNull(TypeAnswer.getInstance(card, "[[type:nc:Back]]")) + assertThat(typeAnswer.combining, equalTo(false)) + } + + @Test + fun `nosuggest composes with cloze modifier`() = + runTest { + val card = addClozeNote("{{c1::hello}} world").firstCard() + val typeAnswer = requireNotNull(TypeAnswer.getInstance(card, "[[type:nosuggest:cloze:Text]]")) + assertThat(typeAnswer.noSuggest, equalTo(true)) + assertThat(typeAnswer.expectedAnswer, equalTo("hello")) + } + + @Test + fun `nosuggest composes with cloze and nc modifiers`() = + runTest { + val card = addClozeNote("{{c1::hello}} world").firstCard() + val typeAnswer = requireNotNull(TypeAnswer.getInstance(card, "[[type:nosuggest:cloze:nc:Text]]")) + assertThat(typeAnswer.noSuggest, equalTo(true)) + assertThat(typeAnswer.combining, equalTo(false)) + assertThat(typeAnswer.expectedAnswer, equalTo("hello")) + } + + @Test + fun `cloze type-in-answer extracts expected answer for each card ordinal`() = + runTest { + val note = addClozeNote("{{c1::alpha}} and {{c2::beta}}") + val (card1, card2) = note.cards() + + val typeAnswerCard1 = requireNotNull(TypeAnswer.getInstance(card1, "[[type:cloze:Text]]")) + assertThat(typeAnswerCard1.expectedAnswer, equalTo("alpha")) + + val typeAnswerCard2 = requireNotNull(TypeAnswer.getInstance(card2, "[[type:cloze:Text]]")) + assertThat(typeAnswerCard2.expectedAnswer, equalTo("beta")) + } + + @Test + fun `cloze type-in-answer extracts expected answer with hint`() = + runTest { + val card = addClozeNote("{{c1::alpha::hint for alpha}}").firstCard() + val typeAnswer = requireNotNull(TypeAnswer.getInstance(card, "[[type:cloze:Text]]")) + assertThat(typeAnswer.expectedAnswer, equalTo("alpha")) + } + companion object { suspend fun TypeAnswer.Companion.createInstance(card: Card) = requireNotNull(TypeAnswer.getInstance(card, VALID_CARD_TEXT)) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/progress/ProgressObserverTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/progress/ProgressObserverTest.kt index 7878223d6175..a9027ff91813 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/progress/ProgressObserverTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/progress/ProgressObserverTest.kt @@ -232,6 +232,23 @@ class ProgressObserverTest : RobolectricTest() { op.cancel() } + @Test + fun `back is blocked as soon as an op starts and restored when it ends`() { + val activity = startActivity().get() + activity.observeProgress(viewModel, delayMillis = SHOW_DELAY) { it } + + val gate = CompletableDeferred() + val op = launchOp(gate) + activity.onBackPressedDispatcher.onBackPressed() + assertFalse(activity.isFinishing, "back must be blocked before the dialog shows") + + gate.complete(Unit) + idleMainLooper(SHOW_DELAY) + activity.onBackPressedDispatcher.onBackPressed() + assertTrue(activity.isFinishing, "back must work again once the op ends") + op.cancel() + } + @Test fun `an observer whose op ends does not restore touches while another op runs`() { val activity = startActivity().get() diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/recorder/RemoveExpiredRecordingsTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/recorder/RemoveExpiredRecordingsTest.kt new file mode 100644 index 000000000000..f431a2c42583 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/recorder/RemoveExpiredRecordingsTest.kt @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 Ashish Yadav + +package com.ichi2.anki.recorder + +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.seconds + +class RemoveExpiredRecordingsTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val now = 1_800_000_000_000L + + @Test + fun `cleanup removes only recordings older than seven days`() { + val expired = createFile("ankidroid_audiorec1.3gp", 7.days + 1.seconds) + val atCutoff = createFile("ankidroid_audiorec2.3gp", 7.days) + val recent = createFile("ankidroid_audiorec3.3gp", 1.days) + + VoiceRecorder.removeExpiredRecordings(temporaryFolder.root, now) + + assertFalse(expired.exists(), "expired recording") + assertTrue(atCutoff.exists(), "recording at the cutoff") + assertTrue(recent.exists(), "recent recording") + } + + @Test + fun `cleanup keeps other files and folders`() { + val unrelated = createFile("notes.txt", 8.days) + val folder = temporaryFolder.newFolder("ankidroid_audiorec_folder.3gp") + assertTrue(folder.setLastModified(now - 8.days.inWholeMilliseconds)) + + VoiceRecorder.removeExpiredRecordings(temporaryFolder.root, now) + + assertTrue(unrelated.exists(), "unrelated file") + assertTrue(folder.exists(), "folder") + } + + @Test + fun `cleanup ignores a missing recordings folder`() { + VoiceRecorder.removeExpiredRecordings(File(temporaryFolder.root, "missing"), now) + } + + private fun createFile( + name: String, + age: Duration, + ): File = + temporaryFolder.newFile(name).apply { + assertTrue(setLastModified(now - age.inWholeMilliseconds)) + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/reviewreminders/ReminderTroubleshootingLayoutTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/reviewreminders/ReminderTroubleshootingLayoutTest.kt index f099af6f7d16..ce810f3e11f2 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/reviewreminders/ReminderTroubleshootingLayoutTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/reviewreminders/ReminderTroubleshootingLayoutTest.kt @@ -38,11 +38,15 @@ class ReminderTroubleshootingLayoutTest : RobolectricTest() { withTroubleshootingFragment { val checksList = binding.checksList + @Suppress("UNCHECKED_CAST") - (checksList.adapter as ListAdapter).submitList(checks) - advanceRobolectricLooper() + val adapter = checksList.adapter as ListAdapter + adapter.submitList(checks) + // ListAdapter computes the diff on a background thread before posting the layout. + advanceRobolectricLooperUntil { adapter.currentList == checks && !checksList.isLayoutRequested } - val itemCount = checksList.adapter!!.itemCount + assertThat("the submitted checks are displayed", adapter.currentList, equalTo(checks)) + val itemCount = checks.size assertThat( "sanity: enough checks to overflow the landscape viewport", itemCount, diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/security/AppPermissionsTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/security/AppPermissionsTest.kt index 9a37eeca09b9..d3c8fb5c9908 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/security/AppPermissionsTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/security/AppPermissionsTest.kt @@ -2,7 +2,9 @@ package com.ichi2.anki.security +import android.content.Intent import androidx.core.content.edit +import androidx.core.net.toUri import androidx.test.ext.junit.runners.AndroidJUnit4 import com.ichi2.anki.R import com.ichi2.anki.RobolectricTest @@ -18,6 +20,8 @@ import org.junit.jupiter.api.assertDoesNotThrow import org.junit.runner.RunWith import org.robolectric.annotation.Config import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull @RunWith(AndroidJUnit4::class) @Config(application = EmptyApplication::class) @@ -77,6 +81,46 @@ class AppPermissionsTest : RobolectricTest() { assertThat(snackbars.size, equalTo(2)) } + @Test + fun `web URLs are allowed without external app permission or a warning`() { + allowCardExternalLaunch = false + for (scheme in listOf("http", "https")) { + val url = "$scheme://example.org/path?query=value#fragment".toUri() + val intent = assertNotNull(permissions.createUrlIntent(url)) + assertThat(intent.action, equalTo(Intent.ACTION_VIEW)) + assertThat(intent.data, equalTo(url)) + assertThat(intent.categories, equalTo(setOf(Intent.CATEGORY_BROWSABLE))) + assertNull(intent.selector) + } + assertThat(snackbars.size, equalTo(0)) + } + + @Test + fun `non-web URLs are denied without external app permission`() { + allowCardExternalLaunch = false + val urls = + listOf( + "market://details?id=com.example", + "intent://example.org/#Intent;scheme=https;end", + "android-app://com.example/https/example.org", + ) + for (url in urls) { + assertNull(permissions.createUrlIntent(url.toUri()), url) + } + assertThat(snackbars.size, equalTo(2)) + } + + @Test + fun `non-web URLs are allowed with external app permission`() { + allowCardExternalLaunch = true + val url = "market://details?id=com.example".toUri() + val intent = assertNotNull(permissions.createUrlIntent(url)) + assertThat(intent.action, equalTo(Intent.ACTION_VIEW)) + assertThat(intent.data, equalTo(url)) + assertNull(intent.categories) + assertThat(snackbars.size, equalTo(0)) + } + @Test fun launchExternalApps_defaultsToDenied() { assertThat(permissions.checkCanLaunchExternalApps(), equalTo(false)) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/security/CardLinkNavigationTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/security/CardLinkNavigationTest.kt new file mode 100644 index 000000000000..89a1cb345f6d --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/security/CardLinkNavigationTest.kt @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.security + +import android.app.Application +import android.content.ComponentName +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.webkit.WebView +import androidx.core.net.toUri +import androidx.test.core.app.ActivityScenario +import com.ichi2.anki.AbstractFlashcardViewerTest.NonAbstractFlashcardViewer +import com.ichi2.anki.R +import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.browser.IdsFile +import com.ichi2.anki.loadInitialCard +import com.ichi2.anki.previewer.CardViewerActivity +import com.ichi2.anki.previewer.PreviewerFragment +import com.ichi2.anki.ui.windows.reviewer.ReviewerFragment +import com.ichi2.anki.workarounds.SafeWebViewLayout +import com.ichi2.testutils.assertNoActivityStarted +import com.ichi2.testutils.assertOpenedUrl +import com.ichi2.testutils.createTransientDirectory +import com.ichi2.testutils.mockWebResourceRequest +import com.ichi2.testutils.registerWebBrowser +import com.ichi2.testutils.webView +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner +import org.robolectric.Robolectric +import org.robolectric.Shadows.shadowOf +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@RunWith(ParameterizedRobolectricTestRunner::class) +class CardLinkNavigationTest( + private val viewerType: ViewerType, +) : RobolectricTest() { + override fun getCollectionStorageMode() = CollectionStorageMode.IN_MEMORY_WITH_MEDIA + + @Before + fun disableExternalApps() { + editPreferences { putBoolean(targetContext.getString(R.string.pref_allow_card_external_launch_key), false) } + } + + @Test + fun `web links resolve to browsers and browsable native apps without external app permission`() { + targetContext.registerWebBrowser() + val nativeHandler = registerNativeHandler(browsable = true) + registerNativeHandler(browsable = false) + withWebView { webView -> + val requests = listOf("http" to true, "https" to true, "http" to false, "https" to false) + for ((scheme, mainFrame) in requests) { + val url = "$scheme://native.example.org/action?value=1#fragment" + val request = mockWebResourceRequest(url, mainFrame = mainFrame) + assertTrue(shadowOf(webView).webViewClient.shouldOverrideUrlLoading(webView, request)) + val intent = assertNotNull(shadowOf(targetContext.applicationContext as Application).nextStartedActivity) + assertEquals(Intent.ACTION_VIEW, intent.action) + assertEquals(url.toUri(), intent.data) + assertNull(intent.selector) + val handlers = + targetContext.packageManager.queryIntentActivities( + intent, + PackageManager.MATCH_DEFAULT_ONLY, + ) + assertEquals( + setOf(ComponentName("com.ichi2.testutils.browser", "BrowserActivity"), nativeHandler), + handlers.map { ComponentName(it.activityInfo.packageName, it.activityInfo.name) }.toSet(), + ) + targetContext.assertNoActivityStarted() + } + } + } + + @Test + fun `web links can open a native app without a browser or external app permission`() { + registerNativeHandler(browsable = true) + withWebView { webView -> + shadowOf(targetContext.applicationContext as Application).checkActivities(true) + val url = "https://native.example.org/action?value=1#fragment" + assertTrue(shadowOf(webView).webViewClient.shouldOverrideUrlLoading(webView, mockWebResourceRequest(url))) + targetContext.assertOpenedUrl(url.toUri()) + } + } + + @Test + fun `missing browsable handler does not load an external page in the card`() { + registerNativeHandler(browsable = false) + withWebView { webView -> + shadowOf(targetContext.applicationContext as Application).checkActivities(true) + val request = mockWebResourceRequest("https://native.example.org/action") + assertTrue(shadowOf(webView).webViewClient.shouldOverrideUrlLoading(webView, request)) + targetContext.assertNoActivityStarted() + } + } + + @Test + fun `parsed intents with web data still require permission`() { + targetContext.registerWebBrowser() + withWebView { webView -> + for (url in listOf( + "intent://example.org/#Intent;scheme=https;end", + "android-app://com.ichi2.testutils.browser/https/example.org", + )) { + val request = mockWebResourceRequest(url) + assertTrue(shadowOf(webView).webViewClient.shouldOverrideUrlLoading(webView, request)) + targetContext.assertNoActivityStarted(url) + } + } + } + + @Test + fun `opted-in intents still reject dangerous data and strip URI grants`() { + editPreferences { putBoolean(targetContext.getString(R.string.pref_allow_card_external_launch_key), true) } + targetContext.registerWebBrowser() + withWebView { webView -> + val client = shadowOf(webView).webViewClient + for (scheme in listOf("file", "content", "javascript", "data")) { + val request = mockWebResourceRequest("intent://example.org/#Intent;scheme=$scheme;end") + assertTrue(client.shouldOverrideUrlLoading(webView, request)) + targetContext.assertNoActivityStarted() + } + val request = mockWebResourceRequest("intent://example.org/#Intent;scheme=https;launchFlags=0xc3;end") + assertTrue(client.shouldOverrideUrlLoading(webView, request)) + val intent = assertNotNull(shadowOf(targetContext.applicationContext as Application).nextStartedActivity) + assertEquals("https://example.org/".toUri(), intent.data) + val grantFlags = + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION or + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or Intent.FLAG_GRANT_PREFIX_URI_PERMISSION + assertEquals(0, intent.flags and grantFlags) + targetContext.assertNoActivityStarted() + } + } + + private fun registerNativeHandler(browsable: Boolean): ComponentName { + val component = ComponentName("com.example.native", "Handler$browsable") + shadowOf(targetContext.packageManager).apply { + addActivityIfNotPresent(component).exported = true + addIntentFilterForActivity( + component, + IntentFilter(Intent.ACTION_VIEW).apply { + addCategory(Intent.CATEGORY_DEFAULT) + if (browsable) addCategory(Intent.CATEGORY_BROWSABLE) + addDataScheme("http") + addDataScheme("https") + if (browsable) addDataAuthority("native.example.org", null) + }, + ) + } + return component + } + + private fun withWebView(block: (WebView) -> Unit) { + val note = addBasicAndReversedNote() + if (viewerType != ViewerType.LEGACY) { + val intent = + if (viewerType == ViewerType.PREVIEWER) { + PreviewerFragment.getIntent( + targetContext, + idsFile = IdsFile(createTransientDirectory(), note.cardIds(col), IdsFile.Purpose.PREVIEW), + currentIndex = 0, + ) + } else { + ReviewerFragment.getIntent(targetContext) + } + ActivityScenario.launch(intent).use { scenario -> + scenario.onActivity { activity -> + block(activity.findViewById(R.id.web_view_layout).webView) + } + } + } else { + val controller = Robolectric.buildActivity(NonAbstractFlashcardViewer::class.java).setup() + saveControllerForCleanup(controller) + val viewer = controller.get() + viewer.onCollectionLoaded(col) + viewer.loadInitialCard() + advanceRobolectricLooper() + advanceRobolectricLooper() + block(assertNotNull(viewer.webView)) + } + } + + companion object { + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters(name = "{0}") + fun viewers() = ViewerType.entries.map { arrayOf(it) } + } + + enum class ViewerType { LEGACY, PREVIEWER, STUDY } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragmentTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragmentTest.kt index 05d6893edf62..d57e6e32a5d8 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragmentTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragmentTest.kt @@ -17,6 +17,7 @@ import anki.scheduler.CardAnswer.Rating import com.ichi2.anki.R import com.ichi2.anki.RobolectricTest import com.ichi2.anki.cardviewer.Gesture +import com.ichi2.anki.cardviewer.TypeAnswerWebView import com.ichi2.anki.common.preferences.sharedPrefs import com.ichi2.anki.dialogs.tags.TagsDialog import com.ichi2.anki.preferences.reviewer.ViewerAction @@ -71,6 +72,20 @@ class ReviewerFragmentTest : RobolectricTest() { } } + @Test + fun `recreated WebView retains keyboard support`() = + runTest { + addBasicWithTypingNote("front", "back") + withReviewer { + val layout = binding.webViewLayout + val original = assertIs(layout.getChildAt(0)) + layout.onRenderProcessGone(original) + val replacement = assertIs(layout.getChildAt(0)) + assertNotSame(original, replacement) + assertNotNull(shadowOf(replacement).getJavascriptInterface("AnkiDroidKeyboard")) + } + } + @Test fun `software rendering is applied to the study screen when enabled`() = runTest { diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModelTest.kt index 690cc09c60ba..f3539c330271 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModelTest.kt @@ -2,20 +2,29 @@ package com.ichi2.anki.ui.windows.reviewer +import androidx.core.content.edit import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModelStore import androidx.test.ext.junit.runners.AndroidJUnit4 import anki.scheduler.CardAnswer.Rating import app.cash.turbine.test import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.common.preferences.sharedPrefs import com.ichi2.anki.common.utils.ext.cardStateCustomizer +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle +import org.json.JSONTokener +import org.jsoup.Jsoup +import org.jsoup.nodes.Element import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) @@ -35,6 +44,44 @@ class ReviewerViewModelTest : RobolectricTest() { viewModelStore.clear() } + @Test + fun `HTML answer marks nosuggest fields and requests a Done key`() = + runTest { + val field = htmlAnswerField("nosuggest:type") + + assertEquals("true", field.attr("data-ankidroid-nosuggest")) + assertEquals("done", field.attr("enterkeyhint")) + } + + @Test + fun `ordinary HTML answers keep their keyboard defaults`() = + runTest { + val field = htmlAnswerField("type") + + assertFalse(field.hasAttr("data-ankidroid-nosuggest")) + assertFalse(field.hasAttr("enterkeyhint")) + } + + private suspend fun TestScope.htmlAnswerField(filter: String): Element { + targetContext.sharedPrefs().edit { putBoolean("useInputTag", true) } + val card = addBasicWithTypingNote("front", "back").firstCard() + val noteType = card.noteType(col) + noteType.templates[0].qfmt = "{{Front}}{{$filter:Back}}" + col.notetypes.save(noteType) + val viewModel = ReviewerViewModel(SavedStateHandle()).also { viewModelStore.put("reviewer", it) } + lateinit var html: String + viewModel.eval + .filter { it.startsWith("_showQuestion(") } + .test { + viewModel.onPageFinished(false) + advanceUntilIdle() + // The first argument is a JSON string containing the question HTML. + html = assertIs(JSONTokener(awaitItem().removePrefix("_showQuestion(")).nextValue()) + cancelAndIgnoreRemainingEvents() + } + return assertNotNull(Jsoup.parseBodyFragment(html).selectFirst("input#typeans")) + } + @Test fun `answering waits for the custom scheduler of the first card`() = runTest { diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/audiorecord/CheckPronunciationViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/audiorecord/CheckPronunciationViewModelTest.kt index 63a1f89913eb..c92f58b0993e 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/audiorecord/CheckPronunciationViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/audiorecord/CheckPronunciationViewModelTest.kt @@ -2,6 +2,7 @@ package com.ichi2.anki.ui.windows.reviewer.audiorecord +import android.content.Context import android.net.Uri import androidx.lifecycle.ViewModelStore import androidx.media3.common.MediaItem @@ -11,6 +12,7 @@ import app.cash.turbine.test import com.ichi2.anki.recorder.AudioRecorder import com.ichi2.anki.recorder.MediaRecorderStub import com.ichi2.anki.recorder.PlayerStub +import com.ichi2.anki.recorder.VoiceRecorder import com.ichi2.testutils.JvmTest import io.mockk.verify import kotlinx.coroutines.flow.first @@ -287,6 +289,22 @@ class CheckPronunciationViewModelTest : JvmTest() { assertFalse(mediaRecorderStub.outputFiles.single().exists(), "the empty file is deleted") } + @Test + fun `recordings are stored outside the cache`() = + runTest { + val context = ApplicationProvider.getApplicationContext() + val viewModel = + CheckPronunciationViewModel(AudioRecorder(context), playerStub.factory) + .also { viewModelStore.put(VIEW_MODEL_KEY, it) } + try { + val recording = viewModel.record() + + assertEquals(File(context.noBackupFilesDir, VoiceRecorder.RECORDINGS_DIRECTORY), recording.parentFile) + } finally { + viewModelStore.clear() + } + } + private fun runViewModelTest(testBody: suspend TestScope.(CheckPronunciationViewModel) -> Unit) = runTest { val viewModel = diff --git a/AnkiDroid/src/test/java/com/ichi2/testutils/ActivityList.kt b/AnkiDroid/src/test/java/com/ichi2/testutils/ActivityList.kt index 494b1db491e1..24c8aeea34ee 100644 --- a/AnkiDroid/src/test/java/com/ichi2/testutils/ActivityList.kt +++ b/AnkiDroid/src/test/java/com/ichi2/testutils/ActivityList.kt @@ -10,8 +10,8 @@ import android.content.Intent import androidx.annotation.CheckResult import com.ichi2.anki.CardBrowser import com.ichi2.anki.CardTemplateBrowserAppearanceEditor -import com.ichi2.anki.CardTemplateBrowserAppearanceEditor.Companion.INTENT_ANSWER_FORMAT -import com.ichi2.anki.CardTemplateBrowserAppearanceEditor.Companion.INTENT_QUESTION_FORMAT +import com.ichi2.anki.CardTemplateBrowserAppearanceEditor.Companion.EXTRA_ANSWER_FORMAT +import com.ichi2.anki.CardTemplateBrowserAppearanceEditor.Companion.EXTRA_QUESTION_FORMAT import com.ichi2.anki.CardTemplateEditor import com.ichi2.anki.DeckPicker import com.ichi2.anki.DrawingFragment @@ -130,13 +130,13 @@ object ActivityList { private fun intentForCardTemplateBrowserAppearanceEditor(): Intent { // bundle != null return Intent().apply { - putExtra(INTENT_QUESTION_FORMAT, "{{Front}}") - putExtra(INTENT_ANSWER_FORMAT, "{{FrontSide}}\n{{Back}}") + putExtra(EXTRA_QUESTION_FORMAT, "{{Front}}") + putExtra(EXTRA_ANSWER_FORMAT, "{{FrontSide}}\n{{Back}}") } } private fun intentForCardTemplateEditor(noteTypeId: Long = 1L): Intent = - Intent().putExtra(CardTemplateEditor.EDITOR_NOTE_TYPE_ID, noteTypeId) + Intent().putExtra(CardTemplateEditor.EXTRA_NOTE_TYPE_ID, noteTypeId) private fun intentForWidgetConfig(): Intent = Intent().apply { putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, 1) } diff --git a/AnkiDroid/src/test/java/com/ichi2/testutils/ActivitySavedStateHelpersTest.kt b/AnkiDroid/src/test/java/com/ichi2/testutils/ActivitySavedStateHelpersTest.kt index de41b48b8d3a..d15138c73b29 100644 --- a/AnkiDroid/src/test/java/com/ichi2/testutils/ActivitySavedStateHelpersTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/testutils/ActivitySavedStateHelpersTest.kt @@ -29,22 +29,25 @@ class ActivitySavedStateHelpersTest : AndroidTest { val payload = byteArrayOf(1, 2, 3) assertNull(intent.getBundleExtra(SingleFragmentActivity.EXTRA_FRAGMENT_ARGS)) - intent.withLaunchPayload(SingleFragmentActivity::class.java, PAYLOAD_KEY, payload) + intent.withLaunchPayload(SingleFragmentActivity::class.java, EXTRA_PAYLOAD, payload) - assertContentEquals(payload, intent.getByteArrayExtra(PAYLOAD_KEY)) - assertContentEquals(payload, intent.getBundleExtra(SingleFragmentActivity.EXTRA_FRAGMENT_ARGS)?.getByteArray(PAYLOAD_KEY)) + assertContentEquals(payload, intent.getByteArrayExtra(EXTRA_PAYLOAD)) + assertContentEquals(payload, intent.getBundleExtra(SingleFragmentActivity.EXTRA_FRAGMENT_ARGS)?.getByteArray(EXTRA_PAYLOAD)) } @Test fun `saved state inspection detects copies made by the unfiltered default factory`() { - val intent = Intent().putExtra(PAYLOAD_KEY, ByteArray(300_000)) + val intent = Intent().putExtra(EXTRA_PAYLOAD, ByteArray(300_000)) // A plain AndroidX activity reproduces argument copying without AnkiActivity's override. Robolectric.buildActivity(FragmentActivity::class.java, intent).setup().use { controller -> controller.get().createSavedStateHandleWithDefaultFactory() val savedState = controller.saveState().parcelledCopy(javaClass.classLoader) assertTrue(savedState.savedStateHandlePaths().isNotEmpty(), "Expected a serialized SavedStateHandle provider") - assertTrue(savedState.savedStateHandlePathsToKey(PAYLOAD_KEY).isNotEmpty(), "Expected the copied intent payload to be detected") + assertTrue( + savedState.savedStateHandlePathsToKey(EXTRA_PAYLOAD).isNotEmpty(), + "Expected the copied intent payload to be detected", + ) } } @@ -54,7 +57,7 @@ class ActivitySavedStateHelpersTest : AndroidTest { val savedState = Robolectric.buildActivity(FragmentActivity::class.java).setup().use { controller -> val activity = controller.get() - val fragment = Fragment().apply { arguments = Bundle().apply { putByteArray(PAYLOAD_KEY, payload) } } + val fragment = Fragment().apply { arguments = Bundle().apply { putByteArray(EXTRA_PAYLOAD, payload) } } activity.supportFragmentManager .beginTransaction() .add(fragment, "payload") @@ -64,14 +67,14 @@ class ActivitySavedStateHelpersTest : AndroidTest { } assertTrue(savedState.savedStateHandlePaths().isNotEmpty(), "Expected a serialized SavedStateHandle provider") - assertTrue(savedState.savedStateHandlePathsToKey(PAYLOAD_KEY).isEmpty(), "Fragment arguments are not ViewModel state") + assertTrue(savedState.savedStateHandlePathsToKey(EXTRA_PAYLOAD).isEmpty(), "Fragment arguments are not ViewModel state") Robolectric.buildActivity(FragmentActivity::class.java).setup(savedState).use { controller -> val restoredFragment = controller.get().supportFragmentManager.findFragmentByTag("payload") - assertContentEquals(payload, restoredFragment?.arguments?.getByteArray(PAYLOAD_KEY)) + assertContentEquals(payload, restoredFragment?.arguments?.getByteArray(EXTRA_PAYLOAD)) } } companion object { - private const val PAYLOAD_KEY = "saved_state_test_payload" + private const val EXTRA_PAYLOAD = "saved_state_test_payload" } } diff --git a/AnkiDroid/src/test/java/com/ichi2/testutils/FileProviderHostPathsTest.kt b/AnkiDroid/src/test/java/com/ichi2/testutils/FileProviderHostPathsTest.kt new file mode 100644 index 000000000000..5b59ba741901 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/testutils/FileProviderHostPathsTest.kt @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.testutils + +import android.content.Context +import androidx.core.content.FileProvider +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.EmptyApplicationCategory +import com.ichi2.utils.openInputStreamSafe +import org.junit.Before +import org.junit.Test +import org.junit.experimental.categories.Category +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.shadow.api.Shadow +import org.robolectric.util.ReflectionHelpers +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs + +@RunWith(AndroidJUnit4::class) +@Config(application = EmptyApplication::class, shadows = [ShadowFileProviderPathStrategy::class]) +@Category(EmptyApplicationCategory::class) +class FileProviderHostPathsTest { + private val context: Context get() = ApplicationProvider.getApplicationContext() + private val authority: String get() = "${context.packageName}.apkgfileprovider" + + @Before + fun setUp() { + // Robolectric gives each test a new cache directory, but AndroidX retains the old roots. + ReflectionHelpers.getStaticField>(FileProvider::class.java, "sCache").clear() + } + + @Test + fun `nested cache files can be shared and opened`() { + val directory = File(context.cacheDir, "nested images").apply { mkdirs() } + val file = File(directory, "image #1.png").apply { writeText("original") } + val uri = FileProvider.getUriForFile(context, authority, file) + + val strategies = ReflectionHelpers.getStaticField>(FileProvider::class.java, "sCache") + assertIs(Shadow.extract(strategies.getValue(authority))) + val contents = + context.contentResolver + .openInputStreamSafe(uri)!! + .bufferedReader() + .use { it.readText() } + assertEquals("original", contents) + context.contentResolver.openOutputStream(uri, "wt")!!.use { it.write("edited".toByteArray()) } + assertEquals("edited", file.readText()) + } + + @Test + fun `the root itself and a directory with the same prefix cannot be shared`() { + val root = context.cacheDir + val sibling = File(root.parentFile, "${root.name}-private/image.png") + for (file in listOf(root, sibling)) { + assertFailsWith { FileProvider.getUriForFile(context, authority, file) } + } + } + + @Test + fun `a URI cannot traverse outside its configured root`() { + val file = File(context.cacheDir, "image.png").apply { writeText("original") } + val uri = FileProvider.getUriForFile(context, authority, file) + val escaped = + uri + .buildUpon() + .encodedPath("/${uri.pathSegments.first()}/%2E%2E/${context.cacheDir.name}-private/image.png") + .build() + + assertFailsWith { context.contentResolver.openInputStreamSafe(escaped) } + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/testutils/ShadowFileProviderPathStrategy.kt b/AnkiDroid/src/test/java/com/ichi2/testutils/ShadowFileProviderPathStrategy.kt new file mode 100644 index 000000000000..0dbe0ee25161 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/testutils/ShadowFileProviderPathStrategy.kt @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.testutils + +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import java.io.File + +/** Adapts AndroidX's Unix-only containment check to Robolectric's host filesystem. */ +@Implements(className = "androidx.core.content.FileProvider\$SimplePathStrategy", isInAndroidSdk = false) +class ShadowFileProviderPathStrategy { + @Implementation + protected fun belongsToRoot( + filePath: String, + rootPath: String, + ): Boolean { + val file = File(filePath).toPath() + val root = File(rootPath).toPath() + return file != root && file.startsWith(root) + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/utils/ImportUtilsTest.kt b/AnkiDroid/src/test/java/com/ichi2/utils/ImportUtilsTest.kt index 5e3026532961..89558906f83c 100644 --- a/AnkiDroid/src/test/java/com/ichi2/utils/ImportUtilsTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/utils/ImportUtilsTest.kt @@ -15,6 +15,8 @@ import com.ichi2.anki.CommonString import com.ichi2.anki.RobolectricTest import com.ichi2.anki.common.crashreporting.CrashReportService import com.ichi2.anki.common.crashreporting.CrashReporter +import com.ichi2.anki.common.time.MockTime +import com.ichi2.anki.common.time.TimeManager import com.ichi2.utils.ImportUtils.FileImporter import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.containsString @@ -255,6 +257,19 @@ class ImportUtilsTest : RobolectricTest() { assertEquals(expectedFilepath, actualFilepath) } + @Test + fun getFileCachedCopyUsesFallbackFileNameWhenDecodingFails() { + TimeManager.resetWith(MockTime(1_600_000_000_000)) + val prefix = "a".repeat(89) + val invalidShortenedFileName = "${prefix}ÀÀÀÀÀÀÀÀÀÀ.apkg" + val expectedFallbackFileName = "file_1600000000000.apkg" + val expectedFilepath = File(targetContext.cacheDir, expectedFallbackFileName).absolutePath + + val actualFilepath = TestFileImporter(invalidShortenedFileName).getFileCachedCopy(targetContext, "dummy".toUri()) + + assertEquals(expectedFilepath, actualFilepath) + } + @Test fun collectionApkgIsValid() { assertTrue(ImportUtils.isValidPackageName("collection.apkg")) diff --git a/anki-common/build.gradle.kts b/anki-common/build.gradle.kts index 4a3c52c377c7..d028a5d222b2 100644 --- a/anki-common/build.gradle.kts +++ b/anki-common/build.gradle.kts @@ -5,8 +5,8 @@ import com.ichi2.anki.gradle.addAnkiBackendDependencies plugins { id("ankidroid.android.library") + id("ankidroid.android.compose") id("org.jetbrains.kotlin.plugin.parcelize") - alias(libs.plugins.kotlin.compose) } configure { @@ -14,7 +14,6 @@ configure { // namespace must be unique for resources generation. namespace = "com.ichi2.anki.ankicommon" buildFeatures.buildConfig = false - buildFeatures.compose = true testFixtures.enable = true } @@ -23,11 +22,6 @@ dependencies { implementation(project(":common:android")) implementation(project(":libanki")) implementation(project(":compat")) - implementation(platform(libs.androidx.compose.bom)) - implementation(libs.androidx.compose.ui) - implementation(libs.androidx.compose.ui.tooling.preview) - implementation(libs.androidx.compose.material3) - debugImplementation(libs.androidx.compose.ui.tooling) addAnkiBackendDependencies(project) @@ -57,10 +51,4 @@ dependencies { testFixturesImplementation(libs.androidx.test.core) testFixturesImplementation(libs.jakewharton.timber) testFixturesImplementation(libs.kotlin.test) - // The Kotlin Compose Compiler plugin attaches to every Kotlin compilation in the - // module including testFixtures, which has no @Composable code and refuses to - // run unless the Compose Runtime is on the classpath. compileOnly satisfies the - // plugin's version check without shipping the runtime in the testFixtures output. - testFixturesCompileOnly(platform(libs.androidx.compose.bom)) - testFixturesCompileOnly(libs.androidx.compose.runtime) } diff --git a/anki-common/src/main/kotlin/com/ichi2/anki/sync/FullSync.kt b/anki-common/src/main/kotlin/com/ichi2/anki/sync/FullSync.kt new file mode 100644 index 000000000000..9baa2e7138a1 --- /dev/null +++ b/anki-common/src/main/kotlin/com/ichi2/anki/sync/FullSync.kt @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.sync + +import com.ichi2.anki.libanki.Collection + +/** + * Whether a successful full collection sync should start a media sync (executed in the backend). + * + * Upstream [documents](https://github.com/ankitects/anki/blob/2727cf39b24869e9d379049c9aa141caaf66c49c/proto/anki/sync.proto#L85-L90) + * that omitting `server_usn` skips media sync. When supplied, the backend + * [starts media sync after a successful full sync](https://github.com/ankitects/anki/blob/2727cf39b24869e9d379049c9aa141caaf66c49c/rslib/src/backend/sync.rs#L452-L454). + */ +sealed interface MediaSync { + /** + * Leave media startup to the caller. + * + * Useful for separately enforcing network constraints. + */ + data object Skip : MediaSync + + /** Start media immediately, using the server media USN returned by the initial collection sync. */ + data class Start( + val serverUsn: Int, + ) : MediaSync +} + +/** Transfer the full collection to/from the server */ +fun Collection.fullUploadOrDownload( + auth: SyncAuth, + upload: Boolean, + mediaSync: MediaSync, +) = fullUploadOrDownload( + auth = auth.toProto(), + upload = upload, + // The backend uses `usn != null` to start a media sync. + // Make this distinction explicit, so a media sync is an explicit action. + serverUsn = + when (mediaSync) { + MediaSync.Skip -> null + is MediaSync.Start -> mediaSync.serverUsn + }, +) diff --git a/anki-common/src/main/kotlin/com/ichi2/anki/sync/SyncAuth.kt b/anki-common/src/main/kotlin/com/ichi2/anki/sync/SyncAuth.kt index 494f79b6db01..1e93ca4385b5 100644 --- a/anki-common/src/main/kotlin/com/ichi2/anki/sync/SyncAuth.kt +++ b/anki-common/src/main/kotlin/com/ichi2/anki/sync/SyncAuth.kt @@ -35,9 +35,3 @@ fun Collection.syncCollection( auth: SyncAuth, syncMedia: Boolean, ): SyncCollectionResponse = syncCollection(auth.toProto(), syncMedia) - -fun Collection.fullUploadOrDownload( - auth: SyncAuth, - serverUsn: Int?, - upload: Boolean, -) = fullUploadOrDownload(auth.toProto(), serverUsn, upload) diff --git a/anki-common/src/test/kotlin/com/ichi2/anki/sync/FullSyncTest.kt b/anki-common/src/test/kotlin/com/ichi2/anki/sync/FullSyncTest.kt new file mode 100644 index 000000000000..f3b915451268 --- /dev/null +++ b/anki-common/src/test/kotlin/com/ichi2/anki/sync/FullSyncTest.kt @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.sync + +import anki.sync.FullUploadOrDownloadRequest +import anki.sync.syncAuth +import com.ichi2.anki.libanki.testutils.InMemoryAnkiTest +import net.ankiweb.rsdroid.Backend +import net.ankiweb.rsdroid.BackendFactory +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class FullSyncTest : InMemoryAnkiTest() { + private lateinit var request: FullUploadOrDownloadRequest + private val auth = + SyncAuth( + syncAuth { + hkey = "test" + endpoint = "https://sync.example.com/" + ioTimeoutSecs = 123 + }, + ) + + override fun setUp() { + BackendFactory.setOverride { + object : Backend() { + override fun fullUploadOrDownload(input: FullUploadOrDownloadRequest) { + request = input + } + } + } + super.setUp() + } + + override fun tearDown() { + try { + super.tearDown() + } finally { + BackendFactory.setOverride(null) + } + } + + @Test + fun `skipping media omits the backend startup trigger in both directions`() { + for (upload in listOf(false, true)) { + col.fullUploadOrDownload(auth, upload = upload, mediaSync = MediaSync.Skip) + + assertFalse(request.hasServerUsn()) + assertEquals(upload, request.upload) + assertEquals(auth.toProto(), request.auth) + } + } + + @Test + fun `starting media preserves the server USN including zero in both directions`() { + for (upload in listOf(false, true)) { + for (serverUsn in listOf(0, 42)) { + col.fullUploadOrDownload(auth, upload = upload, mediaSync = MediaSync.Start(serverUsn)) + + assertTrue(request.hasServerUsn()) + assertEquals(serverUsn, request.serverUsn) + assertEquals(upload, request.upload) + assertEquals(auth.toProto(), request.auth) + } + } + } +} diff --git a/build.gradle.kts b/build.gradle.kts index ff850664a8d4..ee62e08b3275 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -30,8 +30,8 @@ plugins { alias(libs.plugins.androidx.baselineprofile) apply false // Serialization is a separate artifact, not pinned transitively by AGP. alias(libs.plugins.kotlin.serialization) apply false - // Compose Compiler plugin (required since Kotlin 2.0); applied per-module that opts in. - alias(libs.plugins.kotlin.compose) apply false + // Pinned by buildSrc; applied by the Compose convention plugin. + id("org.jetbrains.kotlin.plugin.compose") apply false alias(libs.plugins.ktlint.gradle.plugin) apply false alias(libs.plugins.keeper) apply false } diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 02e06c222126..a25c793d3d40 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -19,6 +19,8 @@ dependencies { implementation("com.android.tools.build:gradle:${libs.versions.androidGradlePlugin.get()}") // Force the catalog version of KGP, otherwise it's overridden by AGP. implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:${libs.versions.kotlin.get()}") + implementation("org.jetbrains.kotlin:compose-compiler-gradle-plugin:${libs.versions.kotlin.get()}") + implementation("io.github.takahirom.roborazzi:roborazzi-gradle-plugin:${libs.versions.roborazzi.get()}") testImplementation(gradleTestKit()) testImplementation(libs.junit.jupiter) testRuntimeOnly(libs.junit.platform.launcher) @@ -28,6 +30,8 @@ tasks.test { useJUnitPlatform() // Rerun the path guard if the app build file is moved. inputs.files("../AnkiDroid/build.gradle.kts") + // The screenshot fixture uses the same Android SDK as the main build. + inputs.file("../gradle/libs.versions.toml") } configure { diff --git a/buildSrc/src/main/kotlin/ankidroid.android.compose.gradle.kts b/buildSrc/src/main/kotlin/ankidroid.android.compose.gradle.kts new file mode 100644 index 000000000000..ca87c6cba625 --- /dev/null +++ b/buildSrc/src/main/kotlin/ankidroid.android.compose.gradle.kts @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +// Apply after the Android app or library plugin: +// plugins { +// id("ankidroid.android.library") // or "ankidroid.android.app" +// id("ankidroid.android.compose") +// } + +import com.android.build.api.dsl.CommonExtension +import com.android.build.api.dsl.TestedExtension +import com.android.build.api.variant.AndroidComponentsExtension +import com.ichi2.anki.gradle.libsLibrary + +plugins { + id("org.jetbrains.kotlin.plugin.compose") +} + +extensions.configure { + buildFeatures.compose = true +} + +dependencies { + "implementation"(platform(libsLibrary("androidx-compose-bom"))) + "implementation"(libsLibrary("androidx-compose-ui")) + "implementation"(libsLibrary("androidx-compose-ui-tooling-preview")) + "implementation"(libsLibrary("androidx-compose-material3")) + "debugImplementation"(libsLibrary("androidx-compose-ui-tooling")) + "testImplementation"(libsLibrary("androidx-compose-ui-test-junit4")) + // Needed for createComposeRule(), but not for createAndroidComposeRule(). + "debugImplementation"(libsLibrary("androidx-compose-ui-test-manifest")) +} + +// The Compose compiler also runs on test fixtures without @Composable code. +// Wait until the module has configured whether it enables test fixtures, and keep +// the runtime compile-only so it is not shipped in the test fixtures output. +extensions.getByType(AndroidComponentsExtension::class.java).finalizeDsl { androidDsl -> + if (androidDsl is TestedExtension && androidDsl.testFixtures.enable) { + dependencies { + "testFixturesCompileOnly"(platform(libsLibrary("androidx-compose-bom"))) + "testFixturesCompileOnly"(libsLibrary("androidx-compose-runtime")) + } + } +} diff --git a/buildSrc/src/main/kotlin/ankidroid.plugins.screenshots.gradle.kts b/buildSrc/src/main/kotlin/ankidroid.plugins.screenshots.gradle.kts new file mode 100644 index 000000000000..ca5a90687d3d --- /dev/null +++ b/buildSrc/src/main/kotlin/ankidroid.plugins.screenshots.gradle.kts @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +// Apply after the Android app/library plugin. Gradle runs these aliases in every +// participating module: ./gradlew compareScreenshots -Pscreenshot +plugins { + id("io.github.takahirom.roborazzi") +} + +val screenshotRun = providers.gradleProperty("screenshot").isPresent +val variant = if (plugins.hasPlugin("com.android.application")) "PlayDebug" else "Debug" + +tasks.register("compileScreenshotTests") { + group = "verification" + description = "Compiles $variant screenshot tests without running them." + dependsOn("compile${variant}UnitTestSources") +} + +listOf("record", "compare", "verify").forEach { mode -> + tasks.register("${mode}Screenshots") { + group = "verification" + description = "Runs Roborazzi $mode for $variant (requires -Pscreenshot)." + if (screenshotRun) { + dependsOn("${mode}Roborazzi$variant") + } else { + doLast { throw GradleException("Screenshot tasks require -Pscreenshot") } + } + } +} + +val cleanScreenshotDiffs = + tasks.register("cleanScreenshotDiffs") { + // Keep baselines, including when running a filtered subset of tests. + // Match generated filenames: a directory named `diffs` may contain baselines. + // Clear cached copies too, so Roborazzi cannot restore obsolete comparisons. + listOf("outputs/roborazzi", "intermediates/roborazzi").forEach { directory -> + delete( + fileTree(layout.buildDirectory.dir(directory)) { + include("**/*_compare.png", "**/*_actual.png") + }, + ) + } + } + +val collectScreenshotDiffs = + tasks.register("collectScreenshotDiffs") { + // Roborazzi declares this directory as a test output, including cached runs. + from(layout.buildDirectory.dir("intermediates/roborazzi")) { + include("**/*_compare.png") + } + into(layout.buildDirectory.dir("reports/screenshot-diffs")) + includeEmptyDirs = false + mustRunAfter("finalizeTestRoborazzi$variant") + } + +tasks.withType().configureEach { + useJUnitPlatform { + if (screenshotRun) { + includeTags("com.ichi2.anki.ScreenshotTestCategory") + } else { + excludeTags("com.ichi2.anki.ScreenshotTestCategory") + } + } + if (screenshotRun && name == "test${variant}UnitTest") { + dependsOn(cleanScreenshotDiffs) + // Keep comparisons available even when verification fails. + finalizedBy(collectScreenshotDiffs) + } +} diff --git a/buildSrc/src/test/kotlin/com/ichi2/anki/gradle/ScreenshotPluginTest.kt b/buildSrc/src/test/kotlin/com/ichi2/anki/gradle/ScreenshotPluginTest.kt new file mode 100644 index 000000000000..7808946730a9 --- /dev/null +++ b/buildSrc/src/test/kotlin/com/ichi2/anki/gradle/ScreenshotPluginTest.kt @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.gradle + +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +/** Test of `ankidroid.plugins.screenshots.gradle.kts` */ +class ScreenshotPluginTest { + @TempDir + lateinit var projectDir: File + + @Test + fun `matching screenshots clear previous diffs including cached results`() { + writeFixture() + // Preserve baselines not exercised by this run, even inside a directory named 'diffs'. + // Populate that directory up front so creating it cannot change the comparison's cache key. + val baselines = mapOf("Example/screen.png" to "baseline", "Example/diffs/other.png" to "unselected baseline") + val diffs = mapOf("Example/diffs/screen_compare.png" to "comparison", "Example/diffs/screen_actual.png" to "actual") + baselines.forEach { (path, contents) -> + writeFile("build/outputs/roborazzi/$path", contents) + } + + val runner = + GradleRunner + .create() + .withProjectDir(projectDir) + .withTestKitDir(File("build/test-kit").absoluteFile) + .withPluginClasspath() + .withArguments("compareScreenshots", "-Pscreenshot", "--build-cache", "--configuration-cache", "--stacktrace") + + // Run changed β†’ matching once normally, then repeat with both results restored from cache. + listOf(TaskOutcome.SUCCESS, TaskOutcome.FROM_CACHE).forEach { outcome -> + listOf(true, false).forEach { changed -> + writeFile("changed.txt", changed.toString()) + val result = runner.build() + assertEquals(outcome, result.task(":testDebugUnitTest")?.outcome, result.output) + val expectedDiffs = if (changed) diffs else emptyMap() + listOf("outputs/roborazzi", "intermediates/roborazzi").forEach { directory -> + assertEquals(baselines + expectedDiffs, fileContents(directory), "$directory: $outcome, changed=$changed") + } + if (outcome == TaskOutcome.FROM_CACHE) { + assertTrue(result.output.contains("Reusing configuration cache.")) + assertEquals(expectedDiffs.filterKeys { it.endsWith("_compare.png") }, fileContents("reports/screenshot-diffs")) + } + } + } + } + + private fun fileContents(directory: String): Map { + val root = File(projectDir, "build/$directory") + return root + .walkTopDown() + .filter { it.isFile } + .associate { it.relativeTo(root).invariantSeparatorsPath to it.readText() } + } + + private fun writeFixture() { + File("../gradle/libs.versions.toml").copyTo(File(projectDir, "libs.versions.toml")) + File("../local.properties").takeIf { it.isFile }?.copyTo(File(projectDir, "local.properties")) + writeFile( + "settings.gradle", + """ + rootProject.name = 'screenshot-cleanup-regression' + dependencyResolutionManagement { + repositories { google(); mavenCentral() } + versionCatalogs { libs { from(files('libs.versions.toml')) } } + } + buildCache { local { directory = file('build-cache') } } + """.trimIndent(), + ) + writeFile("gradle.properties", "android.builtInKotlin=false\nandroid.newDsl=false\norg.gradle.workers.max=2\n") + + writeFile( + "build.gradle", + """ + plugins { + id 'com.android.library' + id 'ankidroid.plugins.screenshots' + } + android { + namespace = 'regression.screenshots' + compileSdk = libs.versions.compileSdk.get().toInteger() + compileSdkMinor = libs.versions.compileSdkMinor.get().toInteger() + defaultConfig { minSdk = 24 } + } + dependencies { + testImplementation libs.junit.jupiter + testRuntimeOnly libs.junit.platform.launcher + } + tasks.withType(Test).configureEach { + inputs.file('changed.txt') + } + """.trimIndent(), + ) + // Pixel comparison is outside this test's scope. Emit a renderer's diff files and let + // the real Roborazzi and screenshot plugins handle copying, cleanup and cache restoration. + writeFile( + "src/test/java/regression/ScreenshotTest.java", + """ + package regression; + + import java.nio.file.Files; + import java.nio.file.Path; + import org.junit.jupiter.api.Tag; + import org.junit.jupiter.api.Test; + + @Tag("com.ichi2.anki.ScreenshotTestCategory") + public class ScreenshotTest { + @Test + void screenshot() throws Exception { + if (Files.readString(Path.of("changed.txt")).equals("true")) { + Path diffs = Path.of("build/outputs/roborazzi/Example/diffs"); + Files.createDirectories(diffs); + Files.writeString(diffs.resolve("screen_compare.png"), "comparison"); + Files.writeString(diffs.resolve("screen_actual.png"), "actual"); + } + } + } + """.trimIndent(), + ) + } + + private fun writeFile( + path: String, + contents: String, + ) { + File(projectDir, path).apply { + parentFile.mkdirs() + writeText(contents) + } + } +} diff --git a/AnkiDroid/src/main/res/drawable/ic_baseline_chevron_right_24.xml b/common/android/src/main/res/drawable/ic_baseline_chevron_right_24.xml similarity index 100% rename from AnkiDroid/src/main/res/drawable/ic_baseline_chevron_right_24.xml rename to common/android/src/main/res/drawable/ic_baseline_chevron_right_24.xml diff --git a/common/android/src/main/res/values/10-preferences.xml b/common/android/src/main/res/values/10-preferences.xml index a27c10dc368e..0ba52d57ae0e 100644 --- a/common/android/src/main/res/values/10-preferences.xml +++ b/common/android/src/main/res/values/10-preferences.xml @@ -44,7 +44,7 @@ Allow cards to launch other apps - Cards can open other apps on your device, triggering potentially unwanted actions.\nLeave off unless you trust all cards and templates in your collection. + Cards can open other apps on your device, triggering potentially unwanted actions.\nWeb links may still open other apps when this setting is disabled.\nLeave off unless you trust all cards and templates in your collection. Security: Card blocked from launching another app.\nIf you trust all cards and templates in your collection, this can be unblocked in the advanced settings diff --git a/common/android/src/testFixtures/java/com/ichi2/anki/ScreenshotTestCategory.kt b/common/android/src/testFixtures/java/com/ichi2/anki/ScreenshotTestCategory.kt new file mode 100644 index 000000000000..64523fde0595 --- /dev/null +++ b/common/android/src/testFixtures/java/com/ichi2/anki/ScreenshotTestCategory.kt @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki + +/** Screenshot tests selected by the root screenshot commands or `-Pscreenshot`. */ +interface ScreenshotTestCategory diff --git a/lint-rules/src/main/java/com/ichi2/anki/lint/IssueRegistry.kt b/lint-rules/src/main/java/com/ichi2/anki/lint/IssueRegistry.kt index fcaed73b2a05..21e31fa247f3 100644 --- a/lint-rules/src/main/java/com/ichi2/anki/lint/IssueRegistry.kt +++ b/lint-rules/src/main/java/com/ichi2/anki/lint/IssueRegistry.kt @@ -16,7 +16,10 @@ import com.ichi2.anki.lint.rules.DirectSystemCurrentTimeMillisUsage import com.ichi2.anki.lint.rules.DirectSystemTimeInstantiation import com.ichi2.anki.lint.rules.DirectToastMakeTextUsage import com.ichi2.anki.lint.rules.DuplicateCrowdInStrings +import com.ichi2.anki.lint.rules.FragmentRequestKeyNamingDetector +import com.ichi2.anki.lint.rules.FragmentResultKeyNamingDetector import com.ichi2.anki.lint.rules.HardcodedPreferenceKey +import com.ichi2.anki.lint.rules.IntentExtraNamingDetector import com.ichi2.anki.lint.rules.InvalidStringFormatDetector import com.ichi2.anki.lint.rules.JUnitNullAssertionDetector import com.ichi2.anki.lint.rules.LayoutPrefixDetector @@ -45,7 +48,10 @@ class IssueRegistry : IssueRegistry() { DirectSystemTimeInstantiation.ISSUE, DirectToastMakeTextUsage.ISSUE, DuplicateCrowdInStrings.ISSUE, + FragmentRequestKeyNamingDetector.ISSUE, + FragmentResultKeyNamingDetector.ISSUE, HardcodedPreferenceKey.ISSUE, + IntentExtraNamingDetector.ISSUE, JUnitNullAssertionDetector.ISSUE, LicenseHeaderExists.ISSUE, LocaleRootDetector.ISSUE, diff --git a/lint-rules/src/main/java/com/ichi2/anki/lint/rules/FragmentRequestKeyNamingDetector.kt b/lint-rules/src/main/java/com/ichi2/anki/lint/rules/FragmentRequestKeyNamingDetector.kt new file mode 100644 index 000000000000..a17722dc4971 --- /dev/null +++ b/lint-rules/src/main/java/com/ichi2/anki/lint/rules/FragmentRequestKeyNamingDetector.kt @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.lint.rules + +import com.android.tools.lint.detector.api.ConstantEvaluator +import com.android.tools.lint.detector.api.Detector +import com.android.tools.lint.detector.api.Implementation +import com.android.tools.lint.detector.api.Issue +import com.android.tools.lint.detector.api.JavaContext +import com.android.tools.lint.detector.api.Scope +import com.android.tools.lint.detector.api.SourceCodeScanner +import com.ichi2.anki.lint.utils.Constants +import com.ichi2.anki.lint.utils.ext.resolveToField +import com.intellij.psi.PsiMethod +import com.intellij.psi.PsiModifier +import org.jetbrains.uast.UCallExpression +import org.jetbrains.uast.skipParenthesizedExprDown + +/** Checks constant request keys, leaving runtime-generated keys and forwarded parameters alone. */ +class FragmentRequestKeyNamingDetector : + Detector(), + SourceCodeScanner { + override fun getApplicableMethodNames() = + listOf("setFragmentResult", "setFragmentResultListener", "clearFragmentResult", "clearFragmentResultListener") + + override fun visitMethodCall( + context: JavaContext, + node: UCallExpression, + method: PsiMethod, + ) { + val owner = method.containingClass?.qualifiedName + if (owner != "androidx.fragment.app.FragmentManager" && + owner != "androidx.fragment.app.FragmentKt" && + owner != "com.ichi2.anki.utils.ext.FragmentResultKt" + ) { + return + } + // Extension methods have a receiver parameter; named arguments may also be reordered. + val index = method.parameterList.parameters.indexOfFirst { it.name == "requestKey" } + if (index < 0) return + val argument = node.getArgumentForParameter(index)?.skipParenthesizedExprDown() ?: return + val field = argument.resolveToField() ?: return + if (!field.hasModifierProperty(PsiModifier.STATIC) || !field.hasModifierProperty(PsiModifier.FINAL)) return + if (field.name.startsWith("REQUEST_")) return + if (ConstantEvaluator.evaluate(context, argument) !is String) return + context.report( + ISSUE, + argument, + context.getLocation(argument), + "Fragment Result API request key constants must start with REQUEST_: ${field.name}", + ) + } + + companion object { + val ISSUE = + Issue.create( + id = "FragmentRequestKeyNaming", + briefDescription = "Fragment request key constants must start with REQUEST_", + explanation = + """ + Fragment Result API request key constants should be named: REQUEST_. + The name should be an action to represent the request. e.g. REQUEST_SELECT_TAGS. + """.trimIndent(), + category = Constants.ANKI_CODE_STYLE_CATEGORY, + priority = Constants.ANKI_CODE_STYLE_PRIORITY, + severity = Constants.ANKI_CODE_STYLE_SEVERITY, + implementation = Implementation(FragmentRequestKeyNamingDetector::class.java, Scope.JAVA_FILE_SCOPE), + ) + } +} diff --git a/lint-rules/src/main/java/com/ichi2/anki/lint/rules/FragmentResultKeyNamingDetector.kt b/lint-rules/src/main/java/com/ichi2/anki/lint/rules/FragmentResultKeyNamingDetector.kt new file mode 100644 index 000000000000..44971a726c18 --- /dev/null +++ b/lint-rules/src/main/java/com/ichi2/anki/lint/rules/FragmentResultKeyNamingDetector.kt @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.lint.rules + +import com.android.tools.lint.checks.DataFlowAnalyzer +import com.android.tools.lint.detector.api.ConstantEvaluator +import com.android.tools.lint.detector.api.Detector +import com.android.tools.lint.detector.api.Implementation +import com.android.tools.lint.detector.api.Issue +import com.android.tools.lint.detector.api.JavaContext +import com.android.tools.lint.detector.api.Scope +import com.android.tools.lint.detector.api.SourceCodeScanner +import com.android.tools.lint.detector.api.isReturningContext +import com.ichi2.anki.lint.utils.Constants +import com.ichi2.anki.lint.utils.ext.resolveToField +import com.intellij.psi.PsiMethod +import com.intellij.psi.PsiModifier +import org.jetbrains.uast.UCallExpression +import org.jetbrains.uast.UElement +import org.jetbrains.uast.UExpression +import org.jetbrains.uast.ULambdaExpression +import org.jetbrains.uast.UMethod +import org.jetbrains.uast.getParentOfType +import org.jetbrains.uast.skipParenthesizedExprDown + +/** + * Requires `RESULT_` names for constant payload keys in Fragment Result API bundles. + * + * @see FragmentRequestKeyNamingDetector for `REQUEST_` + */ +class FragmentResultKeyNamingDetector : + Detector(), + SourceCodeScanner { + /** Starts tracking explicit `Bundle(...)` constructions as potential result payloads. */ + override fun getApplicableConstructorTypes() = listOf("android.os.Bundle") + + /** + * Checks a constructed bundle only if it flows into `setFragmentResult` in this method. + * + * ```kotlin + * val result = Bundle().apply { putLong(SELECTED_DECK, 1L) } + * parentFragmentManager.setFragmentResult(REQUEST_SELECT_DECK, result) + * ``` + */ + override fun visitConstructor( + context: JavaContext, + node: UCallExpression, + constructor: PsiMethod, + ) { + val scope = node.getParentOfType() ?: return + checkBundle(context, scope, node, isResult = false) + } + + /** Selects listener registrations whose callback receives a result bundle. */ + override fun getApplicableMethodNames() = listOf("setFragmentResultListener") + + /** + * Tracks the second parameter of an inline result listener. + * + * ```kotlin + * setFragmentResultListener(REQUEST_SELECT_DECK) { _, result -> + * val deckId = result.getLong(SELECTED_DECK) + * } + * ``` + */ + override fun visitMethodCall( + context: JavaContext, + node: UCallExpression, + method: PsiMethod, + ) { + if (!method.isFragmentResultApi()) return + val listenerIndex = + method.parameterList.parameters.indexOfLast { + it.type.canonicalText.contains("FragmentResultListener") || it.name == "listener" + } + if (listenerIndex < 0) return + val listener = node.getArgumentForParameter(listenerIndex)?.skipParenthesizedExprDown() as? ULambdaExpression ?: return + val bundle = listener.valueParameters.getOrNull(1) ?: return + checkBundle(context, listener.body, bundle, isResult = true) + } + + /** + * Collects key accesses on [bundle] and its aliases, reporting them only for result payloads. + * Tracking stays within [scope]; it does not follow helper bodies or unrelated argument/saved-state bundles. + */ + private fun checkBundle( + context: JavaContext, + scope: UElement, + bundle: UElement, + isResult: Boolean, + ) { + val keys = mutableListOf() + var sentAsResult = isResult + scope.accept( + object : DataFlowAnalyzer(listOf(bundle)) { + /** Follows scope functions such as `apply`, but treats `getBundle()` as a separate bundle. */ + override fun returnsSelf(call: UCallExpression) = isReturningContext(call) + + /** Collects string key arguments in bundle calls such as `result.putString(KEY, value)`. */ + override fun receiver(call: UCallExpression) { + val method = call.resolve() ?: return + if (!context.evaluator.isMemberInClass(method, "android.os.Bundle") && + !context.evaluator.isMemberInClass(method, "android.os.BaseBundle") + ) { + return + } + if (method.parameterList.parameters + .firstOrNull() + ?.type + ?.canonicalText != "java.lang.String" + ) { + return + } + call.getArgumentForParameter(0)?.let(keys::add) + } + + /** Tracks helper keys such as `BundleCompat.getParcelable(result, KEY, type)` and result sends. */ + override fun argument( + call: UCallExpression, + reference: UElement, + ) { + val method = call.resolve() ?: return + call.bundleHelperKey(method, reference)?.let(keys::add) + if (method.name != "setFragmentResult" || !method.isFragmentResultApi()) return + val index = method.parameterList.parameters.indexOfFirst { it.type.canonicalText == "android.os.Bundle" } + if (index >= 0 && call.getArgumentForParameter(index) == reference) sentAsResult = true + } + }, + ) + if (sentAsResult) keys.forEach { checkKey(context, it) } + } + + /** + * Extracts `KEY` from helper calls such as `BundleCompat.getParcelable(result, KEY, type)`. + * Only matches when the helper's bundle argument is the tracked [bundleReference]. + */ + private fun UCallExpression.bundleHelperKey( + method: PsiMethod, + bundleReference: UElement, + ): UExpression? { + if (method.containingClass?.qualifiedName !in BUNDLE_HELPERS) return null + + val parameters = method.parameterList.parameters + val bundleIndex = parameters.indexOfFirst { it.type.canonicalText == "android.os.Bundle" } + if (bundleIndex < 0 || getArgumentForParameter(bundleIndex) != bundleReference) return null + + val keyIndex = parameters.indexOfFirst { it.type.canonicalText == "java.lang.String" } + if (keyIndex < 0) return null + return getArgumentForParameter(keyIndex) + } + + /** Reports non-`RESULT_` fields used as Bundle result keys. */ + private fun checkKey( + context: JavaContext, + expression: UExpression, + ) { + val argument = expression.skipParenthesizedExprDown() + val field = argument.resolveToField() ?: return + if (!field.hasModifierProperty(PsiModifier.STATIC) || !field.hasModifierProperty(PsiModifier.FINAL)) return + if (field.name.startsWith("RESULT_")) return + if (ConstantEvaluator.evaluate(context, argument) !is String) return + context.report( + ISSUE, + argument, + context.getLocation(argument), + "Fragment Result API bundle key constants must start with RESULT_: ${field.name}", + ) + } + + /** Restricts matching to AndroidX Fragment APIs and AnkiDroid's FragmentActivity extensions. */ + private fun PsiMethod.isFragmentResultApi() = + containingClass?.qualifiedName in + setOf( + "androidx.fragment.app.FragmentManager", + "androidx.fragment.app.FragmentKt", + "com.ichi2.anki.utils.ext.FragmentResultKt", + ) + + companion object { + /** Helpers whose bundle and string arguments represent payload access, including Kotlin extensions. */ + private val BUNDLE_HELPERS = + setOf( + "androidx.core.os.BundleCompat", + "com.ichi2.anki.utils.ext.BundleUtilsKt", + "com.ichi2.anki.compat.CompatHelper.Companion", + "com.ichi2.anki.compat.CompatHelperKt", + ) + + /** Lint diagnostic for constant payload keys that do not follow the `RESULT_` naming convention. */ + val ISSUE = + Issue.create( + id = "FragmentResultKeyNaming", + briefDescription = "Fragment result bundle keys must start with RESULT_", + explanation = + """ + Fragment Result API bundle result keys should be named RESULT_, + for example RESULT_SELECTED_DECK. + """.trimIndent(), + category = Constants.ANKI_CODE_STYLE_CATEGORY, + priority = Constants.ANKI_CODE_STYLE_PRIORITY, + severity = Constants.ANKI_CODE_STYLE_SEVERITY, + implementation = Implementation(FragmentResultKeyNamingDetector::class.java, Scope.JAVA_FILE_SCOPE), + ) + } +} diff --git a/lint-rules/src/main/java/com/ichi2/anki/lint/rules/IntentExtraNamingDetector.kt b/lint-rules/src/main/java/com/ichi2/anki/lint/rules/IntentExtraNamingDetector.kt new file mode 100644 index 000000000000..c8a2957ea793 --- /dev/null +++ b/lint-rules/src/main/java/com/ichi2/anki/lint/rules/IntentExtraNamingDetector.kt @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.lint.rules + +import com.android.tools.lint.detector.api.ConstantEvaluator +import com.android.tools.lint.detector.api.Detector +import com.android.tools.lint.detector.api.Implementation +import com.android.tools.lint.detector.api.Issue +import com.android.tools.lint.detector.api.JavaContext +import com.android.tools.lint.detector.api.Scope +import com.android.tools.lint.detector.api.SourceCodeScanner +import com.ichi2.anki.lint.utils.Constants +import com.ichi2.anki.lint.utils.ext.resolveToField +import com.intellij.psi.PsiMethod +import com.intellij.psi.PsiModifier +import org.jetbrains.uast.UCallExpression +import org.jetbrains.uast.skipParenthesizedExprDown + +class IntentExtraNamingDetector : + Detector(), + SourceCodeScanner { + override fun getApplicableMethodNames() = + listOf( + "putExtra", + "hasExtra", + "removeExtra", + "getBooleanExtra", + "getByteExtra", + "getShortExtra", + "getCharExtra", + "getIntExtra", + "getLongExtra", + "getFloatExtra", + "getDoubleExtra", + "getStringExtra", + "getCharSequenceExtra", + "getBundleExtra", + "getParcelableExtra", + "getParcelableArrayExtra", + "getParcelableArrayListExtra", + "getSerializableExtra", + "getIntegerArrayListExtra", + "getStringArrayListExtra", + "getCharSequenceArrayListExtra", + "getBooleanArrayExtra", + "getByteArrayExtra", + "getShortArrayExtra", + "getCharArrayExtra", + "getIntArrayExtra", + "getLongArrayExtra", + "getFloatArrayExtra", + "getDoubleArrayExtra", + "getStringArrayExtra", + "getCharSequenceArrayExtra", + "getParcelableExtraCompat", + "getSerializableExtraCompat", + ) + + override fun visitMethodCall( + context: JavaContext, + node: UCallExpression, + method: PsiMethod, + ) { + val keyIndex = + when (method.containingClass?.qualifiedName) { + "android.content.Intent" -> 0 + "androidx.core.content.IntentCompat", + "com.ichi2.anki.common.utils.ext.IntentKt", + "com.ichi2.anki.compat.CompatHelper.Companion", + -> 1 + else -> return + } + val argument = node.getArgumentForParameter(keyIndex)?.skipParenthesizedExprDown() ?: return + val field = argument.resolveToField() ?: return + if (!field.hasModifierProperty(PsiModifier.STATIC) || !field.hasModifierProperty(PsiModifier.FINAL)) return + if (field.name.startsWith("EXTRA_")) return + if (ConstantEvaluator.evaluate(context, argument) !is String) return + context.report( + ISSUE, + argument, + context.getLocation(argument), + "Intent extra key constants must start with EXTRA_: ${field.name}", + ) + } + + companion object { + val ISSUE = + Issue.create( + id = "IntentExtraNaming", + briefDescription = "Intent extra key constants must start with EXTRA_", + explanation = + """ + Intent extra key constants should be named: EXTRA_. + The name should describe the value being passed. e.g. EXTRA_DECK_ID. + """.trimIndent(), + category = Constants.ANKI_CODE_STYLE_CATEGORY, + priority = Constants.ANKI_CODE_STYLE_PRIORITY, + severity = Constants.ANKI_CODE_STYLE_SEVERITY, + implementation = Implementation(IntentExtraNamingDetector::class.java, Scope.JAVA_FILE_SCOPE), + ) + } +} diff --git a/lint-rules/src/main/java/com/ichi2/anki/lint/utils/ext/UExpression.kt b/lint-rules/src/main/java/com/ichi2/anki/lint/utils/ext/UExpression.kt new file mode 100644 index 000000000000..a411c7422074 --- /dev/null +++ b/lint-rules/src/main/java/com/ichi2/anki/lint/utils/ext/UExpression.kt @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.lint.utils.ext + +import com.intellij.psi.PsiField +import org.jetbrains.kotlin.asJava.unwrapped +import org.jetbrains.kotlin.psi.KtProperty +import org.jetbrains.uast.UExpression +import org.jetbrains.uast.UField +import org.jetbrains.uast.UQualifiedReferenceExpression +import org.jetbrains.uast.UReferenceExpression +import org.jetbrains.uast.toUElementOfType + +/** + * Resolves a Kotlin property to its backing field, including access through its getter. + * + * [UReferenceExpression.resolve] can return a getter method for an ordinary Kotlin `val`. + * + * This unwraps it, so it works the same as a `const val`. + */ +internal fun UExpression.resolveToField(): PsiField? { + val reference = (this as? UQualifiedReferenceExpression)?.selector ?: this + val resolved = (reference as? UReferenceExpression)?.resolve() ?: return null + val property = resolved.unwrapped as? KtProperty ?: return null + return property.toUElementOfType()?.javaPsi as? PsiField +} diff --git a/lint-rules/src/test/java/com/ichi2/anki/lint/rules/FragmentRequestKeyNamingDetectorTest.kt b/lint-rules/src/test/java/com/ichi2/anki/lint/rules/FragmentRequestKeyNamingDetectorTest.kt new file mode 100644 index 000000000000..2aaf43b2f9e1 --- /dev/null +++ b/lint-rules/src/test/java/com/ichi2/anki/lint/rules/FragmentRequestKeyNamingDetectorTest.kt @@ -0,0 +1,210 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.lint.rules + +import com.android.tools.lint.checks.infrastructure.LintDetectorTest.kotlin +import com.android.tools.lint.checks.infrastructure.TestLintTask.lint +import org.junit.Test + +class FragmentRequestKeyNamingDetectorTest { + @Test + fun `reports constants in manager and extension calls`() { + lint() + .allowMissingSdk() + .files( + manager, + fragment, + activity, + fragmentExtensions, + activityExtensions, + kotlin( + """ + package test + import androidx.fragment.app.* + import com.ichi2.anki.utils.ext.setFragmentResultListener + + const val RESULT_KEY = "result" + class Screen : Fragment() { + companion object { + const val FRAGMENT_KEY = "fragment" + } + fun send(manager: FragmentManager, activity: FragmentActivity) { + manager.setFragmentResult(RESULT_KEY, "payload") + manager.setFragmentResult(Screen.FRAGMENT_KEY, "payload") + manager.setFragmentResultListener(RESULT_KEY) {} + manager.clearFragmentResult(RESULT_KEY) + manager.clearFragmentResultListener(RESULT_KEY) + setFragmentResult(result = "payload", requestKey = RESULT_KEY) + setFragmentResultListener(RESULT_KEY) {} + activity.setFragmentResultListener(RESULT_KEY) {} + } + } + """, + ).indented(), + ).issues(FragmentRequestKeyNamingDetector.ISSUE) + .run() + .expectErrorCount(8) + .expectContains("Fragment Result API request key constants must start with REQUEST_: RESULT_KEY") + } + + @Test + fun `reports request key properties accessed through Kotlin getters`() { + lint() + .allowMissingSdk() + .files( + manager, + fragment, + fragmentExtensions, + kotlin( + """ + package test + import androidx.fragment.app.* + + val RESULT_KEY = "result" + object Keys { + val FRAGMENT_KEY = "fragment" + } + class Screen : Fragment() { + companion object { + val REQUE_ = "dfjdsjkfdsjfds" + } + fun listen(manager: FragmentManager) { + setFragmentResultListener(REQUE_) {} + manager.setFragmentResult(Screen.REQUE_, "payload") + manager.setFragmentResult(RESULT_KEY, "payload") + manager.setFragmentResult(Keys.FRAGMENT_KEY, "payload") + } + } + """, + ).indented(), + ).issues(FragmentRequestKeyNamingDetector.ISSUE) + .run() + .expectErrorCount(4) + .expectContains("Fragment Result API request key constants must start with REQUEST_: REQUE_") + .expectContains("Fragment Result API request key constants must start with REQUEST_: RESULT_KEY") + .expectContains("Fragment Result API request key constants must start with REQUEST_: FRAGMENT_KEY") + } + + @Test + fun `allows compliant and nonconstant properties accessed through Kotlin getters`() { + lint() + .allowMissingSdk() + .files( + manager, + fragment, + fragmentExtensions, + kotlin( + """ + package test + import androidx.fragment.app.* + + val REQUEST_TOP_LEVEL = "topLevel" + object Keys { + val REQUEST_OBJECT = "object" + } + class Screen : Fragment() { + companion object { + val REQUEST_LISTEN = "listen" + val dynamicKey = "request_" + System.nanoTime() + var mutableKey = "mutable" + val computedKey get() = "request_" + System.nanoTime() + val delegatedKey by lazy { "request_" + System.nanoTime() } + } + val instanceKey = "instance" + fun listen(manager: FragmentManager) { + setFragmentResultListener(REQUEST_LISTEN) {} + manager.setFragmentResult(REQUEST_TOP_LEVEL, "payload") + manager.setFragmentResult(Keys.REQUEST_OBJECT, "payload") + manager.setFragmentResult(dynamicKey, "payload") + manager.setFragmentResult(mutableKey, "payload") + manager.setFragmentResult(computedKey, "payload") + manager.setFragmentResult(delegatedKey, "payload") + manager.setFragmentResult(instanceKey, "payload") + } + } + """, + ).indented(), + ).issues(FragmentRequestKeyNamingDetector.ISSUE) + .run() + .expectClean() + } + + @Test + fun `allows request constants payload keys and dynamic keys`() { + lint() + .allowMissingSdk() + .files( + manager, + kotlin( + """ + package test + import androidx.fragment.app.FragmentManager + + object Keys { + const val REQUEST_TEST = "test" + const val RESULT_VALUE = "value" + } + fun send(manager: FragmentManager, requestKey: String) { + manager.setFragmentResult(Keys.REQUEST_TEST, Keys.RESULT_VALUE) + manager.setFragmentResult(requestKey, "payload") + val dynamicKey = "request_" + System.nanoTime() + manager.setFragmentResult(dynamicKey, "payload") + } + class Unrelated { + fun setFragmentResult(requestKey: String, result: String) {} + fun send() = setFragmentResult(Keys.RESULT_VALUE, "payload") + } + """, + ).indented(), + ).issues(FragmentRequestKeyNamingDetector.ISSUE) + .run() + .expectClean() + } + + companion object { + private val manager = + kotlin( + """ + package androidx.fragment.app + class FragmentManager { + fun setFragmentResult(requestKey: String, result: String) {} + fun setFragmentResultListener(requestKey: String, listener: () -> Unit) {} + fun clearFragmentResult(requestKey: String) {} + fun clearFragmentResultListener(requestKey: String) {} + } + """, + ).indented() + private val fragment = + kotlin( + """ + package androidx.fragment.app + open class Fragment + """, + ).indented() + private val activity = + kotlin( + """ + package androidx.fragment.app + class FragmentActivity + """, + ).indented() + private val fragmentExtensions = + kotlin( + """ + @file:JvmName("FragmentKt") + package androidx.fragment.app + fun Fragment.setFragmentResult(requestKey: String, result: String) {} + fun Fragment.setFragmentResultListener(requestKey: String, listener: () -> Unit) {} + """, + ).indented() + private val activityExtensions = + kotlin( + """ + @file:JvmName("FragmentResultKt") + package com.ichi2.anki.utils.ext + import androidx.fragment.app.FragmentActivity + fun FragmentActivity.setFragmentResultListener(requestKey: String, listener: () -> Unit) {} + """, + ).indented() + } +} diff --git a/lint-rules/src/test/java/com/ichi2/anki/lint/rules/FragmentResultKeyNamingDetectorTest.kt b/lint-rules/src/test/java/com/ichi2/anki/lint/rules/FragmentResultKeyNamingDetectorTest.kt new file mode 100644 index 000000000000..9ef56fe41730 --- /dev/null +++ b/lint-rules/src/test/java/com/ichi2/anki/lint/rules/FragmentResultKeyNamingDetectorTest.kt @@ -0,0 +1,214 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.lint.rules + +import com.android.tools.lint.checks.infrastructure.LintDetectorTest.kotlin +import com.android.tools.lint.checks.infrastructure.TestLintTask.lint +import org.junit.Test + +class FragmentResultKeyNamingDetectorTest { + private fun check(source: String) = + lint() + .allowMissingSdk() + .files(bundle, manager, fragment, extensions, activityExtensions, kotlin(source).indented()) + .issues(FragmentResultKeyNamingDetector.ISSUE) + .run() + + @Test + fun `reports payload keys in inline and local bundles`() { + check( + """ + package test + import android.os.Bundle + import androidx.fragment.app.* + const val BAD_KEY = "value" + fun send(manager: FragmentManager) { + manager.setFragmentResult("request", Bundle().apply { putString(BAD_KEY, "payload") }) + val result = Bundle() + result.putString(BAD_KEY, "payload") + val alias = result + manager.setFragmentResult("request", alias) + } + """, + ).expectErrorCount(2) + .expectContains("Fragment Result API bundle key constants must start with RESULT_: BAD_KEY") + } + + @Test + fun `reports ordinary public and private Kotlin val properties as well as const val`() { + check( + """ + package test + import android.os.Bundle + import androidx.fragment.app.* + val TOP_LEVEL = "top" + object Keys { val OBJECT_KEY = "object" } + class Screen : Fragment() { + companion object { + val PUBLIC_KEY = "public" + private val PRIVATE_KEY = "private" + const val CONST_KEY = "constant" + } + fun send() { + setFragmentResult(result = Bundle().apply { + putString(TOP_LEVEL, "value") + putString(Keys.OBJECT_KEY, "value") + putString(Screen.PUBLIC_KEY, "value") + putString(PRIVATE_KEY, "value") + putString(CONST_KEY, "value") + }, requestKey = "request") + } + } + """, + ).expectErrorCount(5) + } + + @Test + fun `checks manager fragment and activity listener payloads`() { + check( + """ + package test + import androidx.fragment.app.* + import com.ichi2.anki.utils.ext.setFragmentResultListener + const val BAD_KEY = "value" + class Screen : Fragment() { + fun listen(manager: FragmentManager, activity: FragmentActivity) { + manager.setFragmentResultListener("request", this) { _, result -> result.getString(BAD_KEY) } + setFragmentResultListener("request") { _, result -> + val alias = result + alias.getString(BAD_KEY) + } + activity.setFragmentResultListener("request") { _, result -> result.getString(BAD_KEY) } + } + } + """, + ).expectErrorCount(3) + } + + @Test + fun `ignores arguments saved state nested bundles values and unrelated APIs`() { + check( + """ + package test + import android.os.Bundle + import androidx.fragment.app.* + const val ARG_VALUE = "argument" + const val STATE_VALUE = "state" + const val REQUEST_SEND = "send" + const val RESULT_VALUE = "result" + class Other { fun setFragmentResult(key: String, bundle: Bundle) {} } + fun send(manager: FragmentManager, savedState: Bundle) { + savedState.putString(STATE_VALUE, "value") + val args = Bundle().apply { putString(ARG_VALUE, "value") } + Other().setFragmentResult(REQUEST_SEND, args) + manager.setFragmentResult(REQUEST_SEND, Bundle().apply { + putString(RESULT_VALUE, ARG_VALUE) + args.putString(ARG_VALUE, "value") + putBundle(RESULT_VALUE, args) + }) + manager.setFragmentResultListener(REQUEST_SEND, Any()) { _, result -> + result.getString(RESULT_VALUE) + result.getBundle(RESULT_VALUE)?.getString(ARG_VALUE) + args.getString(ARG_VALUE) + } + } + """, + ).expectClean() + } + + @Test + fun `allows runtime keys and compliant properties`() { + check( + """ + package test + import android.os.Bundle + import androidx.fragment.app.* + val RESULT_VALUE = "value" + val dynamicKey = "key_" + System.nanoTime() + var mutableKey = "mutable" + val computedKey get() = "computed" + val delegatedKey by lazy { "delegated" } + fun send(manager: FragmentManager, forwardedKey: String) { + manager.setFragmentResult("request", Bundle().apply { + putString(RESULT_VALUE, "value") + putString(dynamicKey, "value") + putString(mutableKey, "value") + putString(computedKey, "value") + putString(delegatedKey, "value") + putString(forwardedKey, "value") + }) + } + """, + ).expectClean() + } + + @Test + fun `honors suppression`() { + check( + """ + package test + import android.os.Bundle + import androidx.fragment.app.FragmentManager + const val BAD_KEY = "key" + @Suppress("FragmentResultKeyNaming") + fun send(manager: FragmentManager) { + manager.setFragmentResult("request", Bundle().apply { putString(BAD_KEY, "value") }) + } + """, + ).expectClean() + } + + companion object { + private val bundle = + kotlin( + """ + package android.os + class Bundle { + fun putString(key: String, value: String) {} + fun putBundle(key: String, value: Bundle) {} + fun getString(key: String): String? = null + fun getBundle(key: String): Bundle? = null + } + """, + ).indented() + private val manager = + kotlin( + """ + package androidx.fragment.app + import android.os.Bundle + class FragmentManager { + fun setFragmentResult(requestKey: String, result: Bundle) {} + fun setFragmentResultListener(requestKey: String, owner: Any, listener: (String, Bundle) -> Unit) {} + } + """, + ).indented() + private val fragment = + kotlin( + """ + package androidx.fragment.app + open class Fragment + class FragmentActivity + """, + ).indented() + private val extensions = + kotlin( + """ + @file:JvmName("FragmentKt") + package androidx.fragment.app + import android.os.Bundle + fun Fragment.setFragmentResult(requestKey: String, result: Bundle) {} + fun Fragment.setFragmentResultListener(requestKey: String, listener: (String, Bundle) -> Unit) {} + """, + ).indented() + private val activityExtensions = + kotlin( + """ + @file:JvmName("FragmentResultKt") + package com.ichi2.anki.utils.ext + import android.os.Bundle + import androidx.fragment.app.FragmentActivity + fun FragmentActivity.setFragmentResultListener(requestKey: String, listener: (String, Bundle) -> Unit) {} + """, + ).indented() + } +} diff --git a/lint-rules/src/test/java/com/ichi2/anki/lint/rules/IntentExtraNamingDetectorTest.kt b/lint-rules/src/test/java/com/ichi2/anki/lint/rules/IntentExtraNamingDetectorTest.kt new file mode 100644 index 000000000000..2434ea80a3e4 --- /dev/null +++ b/lint-rules/src/test/java/com/ichi2/anki/lint/rules/IntentExtraNamingDetectorTest.kt @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.lint.rules + +import com.android.tools.lint.checks.infrastructure.LintDetectorTest.kotlin +import com.android.tools.lint.checks.infrastructure.TestLintTask.lint +import org.junit.Test + +class IntentExtraNamingDetectorTest { + @Test + fun `reports constants in intent and helper calls`() { + lint() + .allowMissingSdk() + .files( + intent, + intentCompat, + intentExtensions, + compatHelper, + kotlin( + """ + package test + import android.content.Intent + import androidx.core.content.IntentCompat + import com.ichi2.anki.common.utils.ext.getLongExtra + import com.ichi2.anki.common.utils.ext.getParcelableExtraCompat + import com.ichi2.anki.compat.CompatHelper.Companion.getSerializableExtraCompat + + const val NOTE_ID = "noteId" + class Screen { + companion object { + const val DECK_KEY = "deck" + } + fun read(intent: Intent) { + intent.putExtra(NOTE_ID, "1") + intent.putExtra(Screen.DECK_KEY, 1L) + intent.getStringExtra(NOTE_ID) + intent.getLongExtra(NOTE_ID, 0L) + intent.hasExtra(NOTE_ID) + intent.removeExtra(NOTE_ID) + IntentCompat.getParcelableExtra(intent, NOTE_ID, String::class.java) + intent.getLongExtra(NOTE_ID) + intent.getParcelableExtraCompat(NOTE_ID) + intent.getSerializableExtraCompat(NOTE_ID) + } + } + """, + ).indented(), + ).issues(IntentExtraNamingDetector.ISSUE) + .run() + .expectErrorCount(10) + .expectContains("Intent extra key constants must start with EXTRA_: NOTE_ID") + .expectContains("Intent extra key constants must start with EXTRA_: DECK_KEY") + } + + @Test + fun `reports extra key properties accessed through Kotlin getters`() { + lint() + .allowMissingSdk() + .files( + intent, + kotlin( + """ + package test + import android.content.Intent + + val NOTE_ID = "noteId" + object Keys { + val DECK_KEY = "deck" + } + fun read(intent: Intent) { + intent.getStringExtra(NOTE_ID) + intent.putExtra(Keys.DECK_KEY, "1") + } + """, + ).indented(), + ).issues(IntentExtraNamingDetector.ISSUE) + .run() + .expectErrorCount(2) + .expectContains("Intent extra key constants must start with EXTRA_: NOTE_ID") + .expectContains("Intent extra key constants must start with EXTRA_: DECK_KEY") + } + + @Test + fun `allows compliant constants and keys that are not constants`() { + lint() + .allowMissingSdk() + .files( + intent, + intentCompat, + kotlin( + """ + package test + import android.content.Intent + import androidx.core.content.IntentCompat + + const val EXTRA_NOTE_ID = "noteId" + object Keys { + val EXTRA_DECK_ID = "deck" + } + class Screen { + companion object { + val dynamicKey = "extra_" + System.nanoTime() + var mutableKey = "mutable" + val computedKey get() = "extra_" + System.nanoTime() + val delegatedKey by lazy { "extra_" + System.nanoTime() } + } + val instanceKey = "instance" + fun read(intent: Intent, key: String) { + intent.getStringExtra(EXTRA_NOTE_ID) + intent.putExtra(Keys.EXTRA_DECK_ID, "1") + intent.getStringExtra(dynamicKey) + intent.getStringExtra(mutableKey) + intent.getStringExtra(computedKey) + intent.getStringExtra(delegatedKey) + intent.getStringExtra(instanceKey) + intent.getStringExtra(key) + intent.getStringExtra("literal") + IntentCompat.getParcelableExtra(intent, key, String::class.java) + } + } + """, + ).indented(), + ).issues(IntentExtraNamingDetector.ISSUE) + .run() + .expectClean() + } + + @Test + fun `ignores values and unrelated methods with the same name`() { + lint() + .allowMissingSdk() + .files( + intent, + kotlin( + """ + package test + import android.content.Intent + + const val EXTRA_NOTE = "note" + const val NOTE_ID = "noteId" + fun write(intent: Intent) { + intent.putExtra(EXTRA_NOTE, NOTE_ID) + } + class Unrelated { + fun putExtra(name: String, value: String) {} + fun send() = putExtra(NOTE_ID, "1") + } + """, + ).indented(), + ).issues(IntentExtraNamingDetector.ISSUE) + .run() + .expectClean() + } + + companion object { + private val intent = + kotlin( + """ + package android.content + class Intent { + fun putExtra(name: String, value: String): Intent = this + fun putExtra(name: String, value: Long): Intent = this + fun getStringExtra(name: String): String? = null + fun getLongExtra(name: String, defaultValue: Long): Long = defaultValue + fun hasExtra(name: String): Boolean = false + fun removeExtra(name: String) {} + } + """, + ).indented() + private val intentCompat = + kotlin( + """ + package androidx.core.content + import android.content.Intent + object IntentCompat { + @JvmStatic + fun getParcelableExtra(intent: Intent, name: String, clazz: Class): T? = null + } + """, + ).indented() + private val intentExtensions = + kotlin( + """ + @file:JvmName("IntentKt") + package com.ichi2.anki.common.utils.ext + import android.content.Intent + fun Intent.getLongExtra(key: String): Long? = null + inline fun Intent.getParcelableExtraCompat(key: String): T? = null + """, + ).indented() + private val compatHelper = + kotlin( + """ + package com.ichi2.anki.compat + import android.content.Intent + class CompatHelper { + companion object { + inline fun Intent.getSerializableExtraCompat(name: String): T? = null + } + } + """, + ).indented() + } +} diff --git a/note-editor/build.gradle.kts b/note-editor/build.gradle.kts new file mode 100644 index 000000000000..7b200457f99a --- /dev/null +++ b/note-editor/build.gradle.kts @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +import com.android.build.api.dsl.LibraryExtension + +plugins { + id("ankidroid.android.library") + id("ankidroid.android.compose") + id("ankidroid.plugins.screenshots") +} + +configure { + namespace = "com.ichi2.anki.noteeditor" +} + +dependencies { + implementation(project(":common:android")) + implementation(libs.androidx.core) + + testImplementation(testFixtures(project(":common:android"))) + testImplementation(libs.robolectric) + testImplementation(libs.roborazzi) + testImplementation(libs.junit.vintage.engine) + testRuntimeOnly(libs.junit.platform.launcher) +} diff --git a/note-editor/src/main/java/com/ichi2/anki/noteeditor/data/ToolbarPreferences.kt b/note-editor/src/main/java/com/ichi2/anki/noteeditor/data/ToolbarPreferences.kt new file mode 100644 index 000000000000..54360f431a29 --- /dev/null +++ b/note-editor/src/main/java/com/ichi2/anki/noteeditor/data/ToolbarPreferences.kt @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.noteeditor.data + +import android.content.SharedPreferences +import androidx.core.content.edit +import com.ichi2.anki.noteeditor.presentation.ToolbarLayoutMode + +private const val PREF_NOTE_EDITOR_SCROLL_TOOLBAR = "noteEditorScrollToolbar" +private const val PREF_NOTE_EDITOR_SHOW_TOOLBAR = "noteEditorShowToolbar" + +/** + * Persists the toolbar layout using the existing show/scroll preferences. + * + * TODO: migrate to a single preference value. + */ +class ToolbarPreferences( + private val preferences: SharedPreferences, +) { + var isVisible: Boolean + get() = preferences.getBoolean(PREF_NOTE_EDITOR_SHOW_TOOLBAR, true) + set(value) { + preferences.edit { putBoolean(PREF_NOTE_EDITOR_SHOW_TOOLBAR, value) } + } + + /** The layout to use when visible, retained while the toolbar is hidden. */ + var isScrollable: Boolean + get() = preferences.getBoolean(PREF_NOTE_EDITOR_SCROLL_TOOLBAR, true) + set(value) { + preferences.edit { putBoolean(PREF_NOTE_EDITOR_SCROLL_TOOLBAR, value) } + } + + var layout: ToolbarLayoutMode + get() = + when { + !isVisible -> ToolbarLayoutMode.HIDDEN + isScrollable -> ToolbarLayoutMode.SCROLL + else -> ToolbarLayoutMode.STACK + } + set(value) { + preferences.edit { + putBoolean(PREF_NOTE_EDITOR_SHOW_TOOLBAR, value != ToolbarLayoutMode.HIDDEN) + if (value != ToolbarLayoutMode.HIDDEN) { + putBoolean(PREF_NOTE_EDITOR_SCROLL_TOOLBAR, value == ToolbarLayoutMode.SCROLL) + } + } + } +} diff --git a/note-editor/src/main/java/com/ichi2/anki/noteeditor/presentation/ToolbarLayoutMode.kt b/note-editor/src/main/java/com/ichi2/anki/noteeditor/presentation/ToolbarLayoutMode.kt new file mode 100644 index 000000000000..fe1a54b84b42 --- /dev/null +++ b/note-editor/src/main/java/com/ichi2/anki/noteeditor/presentation/ToolbarLayoutMode.kt @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.noteeditor.presentation + +/** Layout of the formatting toolbar. */ +enum class ToolbarLayoutMode { + SCROLL, + STACK, + HIDDEN, +} diff --git a/AnkiDroid/src/main/res/drawable/ic_add_equation_black_24dp.xml b/note-editor/src/main/res/drawable/ic_add_equation_black_24dp.xml similarity index 100% rename from AnkiDroid/src/main/res/drawable/ic_add_equation_black_24dp.xml rename to note-editor/src/main/res/drawable/ic_add_equation_black_24dp.xml diff --git a/AnkiDroid/src/main/res/drawable/ic_add_toolbar_icon.xml b/note-editor/src/main/res/drawable/ic_add_toolbar_icon.xml similarity index 100% rename from AnkiDroid/src/main/res/drawable/ic_add_toolbar_icon.xml rename to note-editor/src/main/res/drawable/ic_add_toolbar_icon.xml diff --git a/AnkiDroid/src/main/res/drawable/ic_format_bold_black_24dp.xml b/note-editor/src/main/res/drawable/ic_format_bold_black_24dp.xml similarity index 100% rename from AnkiDroid/src/main/res/drawable/ic_format_bold_black_24dp.xml rename to note-editor/src/main/res/drawable/ic_format_bold_black_24dp.xml diff --git a/AnkiDroid/src/main/res/drawable/ic_format_font_size_24dp.xml b/note-editor/src/main/res/drawable/ic_format_font_size_24dp.xml similarity index 100% rename from AnkiDroid/src/main/res/drawable/ic_format_font_size_24dp.xml rename to note-editor/src/main/res/drawable/ic_format_font_size_24dp.xml diff --git a/AnkiDroid/src/main/res/drawable/ic_format_italic_black_24dp.xml b/note-editor/src/main/res/drawable/ic_format_italic_black_24dp.xml similarity index 100% rename from AnkiDroid/src/main/res/drawable/ic_format_italic_black_24dp.xml rename to note-editor/src/main/res/drawable/ic_format_italic_black_24dp.xml diff --git a/AnkiDroid/src/main/res/drawable/ic_format_title_black_24dp.xml b/note-editor/src/main/res/drawable/ic_format_title_black_24dp.xml similarity index 100% rename from AnkiDroid/src/main/res/drawable/ic_format_title_black_24dp.xml rename to note-editor/src/main/res/drawable/ic_format_title_black_24dp.xml diff --git a/AnkiDroid/src/main/res/drawable/ic_format_underlined_black_24dp.xml b/note-editor/src/main/res/drawable/ic_format_underlined_black_24dp.xml similarity index 100% rename from AnkiDroid/src/main/res/drawable/ic_format_underlined_black_24dp.xml rename to note-editor/src/main/res/drawable/ic_format_underlined_black_24dp.xml diff --git a/AnkiDroid/src/main/res/drawable/ic_horizontal_rule_black_24dp.xml b/note-editor/src/main/res/drawable/ic_horizontal_rule_black_24dp.xml similarity index 100% rename from AnkiDroid/src/main/res/drawable/ic_horizontal_rule_black_24dp.xml rename to note-editor/src/main/res/drawable/ic_horizontal_rule_black_24dp.xml diff --git a/settings.gradle.kts b/settings.gradle.kts index 9aebd49b0d1b..a180c5218baf 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -37,6 +37,7 @@ include( ":compat", ":libanki", ":lint-rules", + ":note-editor", ":vbpd", ":widgets", ) \ No newline at end of file diff --git a/tools/release.sh b/tools/release.sh index ba68288a79a6..ccd91e3dd7af 100755 --- a/tools/release.sh +++ b/tools/release.sh @@ -112,14 +112,22 @@ then exit 1 fi -# If any changes go in during the release process, pushing fails, so push immediately. -# Worst case this burns a version number despite a failure later, and we have a version/tag -# that never launched. That's better than having to manually patch up build.gradle.kts and push a tag -# for a release that did launch, but the push failed +# Push the version bump before Play. A merge during the upload rejects the push, +# and the script would otherwise carry on after Play had already accepted the build, +# leaving main without the version that shipped. +# If this push fails, nothing has been published yet and the release stops. +# Worst case a later failure burns a version on main. That is better than +# hand-patching a release that did launch but whose push failed. +# The tag is pushed only after Play accepts, so a Play failure does not create a GitHub release. echo "Committing changelog and version bump via git" git add $GRADLEFILE $CHANGELOG git commit -m "Bumped version to $VERSION" git tag v"$VERSION" +if ! git push +then + echo "Failed to push the version bump. Nothing was published yet." + exit 1 +fi . tools/check-keystore.sh @@ -134,9 +142,12 @@ then exit 1 fi -# If the Play Store accepted the builds, the version bump should be pushed / made concrete -git push -git push --tags +# If the Play Store accepted the builds, the tag should be pushed / made concrete +if ! git push --tags +then + echo "Failed to push the v$VERSION tag. Play already has this build." + exit 1 +fi # Build the full set of release APKs for all flavors, with universals UCFLAVORS='Full Amazon Play' @@ -199,8 +210,10 @@ export GITHUB_REPO="Anki-Android" if [ "$PUBLIC" = "public" ]; then RELEASE_TYPE="--latest" + RELEASE_KIND="Public release" else RELEASE_TYPE="--prerelease" + RELEASE_KIND="Pre-release" fi # Get the directory of the current script @@ -208,7 +221,11 @@ SCRIPT_DIR=$(dirname "$(readlink -f "$0")") # Read the content of the markdown file using the absolute path RELEASE_NOTES="$SCRIPT_DIR/release-description.md" echo "Creating new Github release" -gh release create v"$VERSION" --title "AnkiDroid $VERSION" -F "$RELEASE_NOTES" $RELEASE_TYPE --draft +if ! gh release create v"$VERSION" --verify-tag --title "AnkiDroid $VERSION" -F "$RELEASE_NOTES" $RELEASE_TYPE --draft +then + echo "Failed to create the GitHub release for v$VERSION" + exit 1 +fi echo "Sleeping 30s to make sure the release exists, see issue 11746" sleep 30 @@ -276,3 +293,9 @@ then exit 1 fi echo "Release published successfully" + +cat <> "${GITHUB_STEP_SUMMARY:-/dev/null}" +### [AnkiDroid $VERSION](https://github.com/ankidroid/Anki-Android/releases/tag/v$VERSION) + +$RELEASE_KIND +EOF