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/with_emulator_cleanup.sh b/.github/with_emulator_cleanup.sh new file mode 100644 index 000000000000..1152597454cb --- /dev/null +++ b/.github/with_emulator_cleanup.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# SPDX-License-Identifier: GPL-3.0-or-later + +set -euo pipefail + +# Only for dedicated Linux CI runners with one emulator and -no-snapshot-save. +# Forced shutdown must not interrupt saving a snapshot for subsequent jobs. +stop_emulator() { + # #22613 `emu kill` can report success while QEMU keeps running and + # holds the emulator action's output pipes open. Bound both ADB and QEMU. + timeout -s KILL 5s "$ANDROID_HOME/platform-tools/adb" -s "emulator-$EMULATOR_PORT" emu kill || true + + local emulator_pattern="^$ANDROID_HOME/emulator/qemu/" + local deadline=$((SECONDS + 20)) + while pgrep -f "$emulator_pattern" > /dev/null; do + if ((SECONDS >= deadline)); then + echo "Emulator did not stop within 20 seconds; killing QEMU." >&2 + pkill -KILL -f "$emulator_pattern" || true + break + fi + sleep 1 + done +} + +# Preserve the exit status +trap stop_emulator EXIT +"$@" 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/merge_requirements.yml b/.github/workflows/merge_requirements.yml index 81302dff8745..afec2c2b02fd 100644 --- a/.github/workflows/merge_requirements.yml +++ b/.github/workflows/merge_requirements.yml @@ -9,9 +9,7 @@ on: permissions: {} -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true +# Don't cancel runs: this is a fast check which shows as a failing check when canceled. jobs: squashMerge: 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..afbc5a28a8b7 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 bash .github/with_emulator_cleanup.sh ./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/.github/workflows/tests_unit.yml b/.github/workflows/tests_unit.yml index 13b2b4071a82..cad6fe5341b6 100644 --- a/.github/workflows/tests_unit.yml +++ b/.github/workflows/tests_unit.yml @@ -126,6 +126,17 @@ jobs: #env: # CODACY_TOKEN: ${{ secrets.CODACY_TOKEN }} steps: + # Work around Ubuntu 26.04's RAM-backed /tmp until the image fix rolls out. + # https://github.com/actions/runner-images/pull/14814 + - name: Keep /tmp on disk + if: runner.os == 'Linux' + run: | + if [ "$(findmnt -n -o FSTYPE --target /tmp)" = "tmpfs" ]; then + sudo systemctl mask tmp.mount + sudo umount -l /tmp + fi + df -hT /tmp + - name: Configure Windows Pagefile uses: al-cheb/configure-pagefile-action@9b6da52fb72a3c6147c1aad2df22d8d905681adc # v1.5 if: contains(matrix.os, 'windows') diff --git a/AnkiDroid/build.gradle.kts b/AnkiDroid/build.gradle.kts index ebcf51b3d8e0..d1467a0ea0b7 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,7 @@ android { test.systemProperty("screenshot.theme", theme) test.systemProperty("screenshot.device", device) - } else { - excludeTags("com.ichi2.anki.ScreenshotTestCategory") + test.systemProperty("screenshot.accessibilityScan", project.hasProperty("accessibilityScan")) } // Ensures that no EmptyApplication test relies on AnkiDroidApp // by only running EmptyApplication tests @@ -751,6 +747,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 +755,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 +844,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) @@ -877,6 +863,7 @@ dependencies { } testImplementation(libs.androidx.work.testing) testImplementation(libs.roborazzi) + testImplementation(libs.roborazzi.accessibility.check) testImplementation(libs.roborazzi.rule) // for testing flows testImplementation(libs.cashapp.turbine) @@ -908,10 +895,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/androidTest/java/com/ichi2/anki/dialogs/NoteTypeFieldEditorContextMenuTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/dialogs/NoteTypeFieldEditorContextMenuTest.kt index 2c2cb19c783b..0f2093d69a44 100644 --- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/dialogs/NoteTypeFieldEditorContextMenuTest.kt +++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/dialogs/NoteTypeFieldEditorContextMenuTest.kt @@ -26,7 +26,7 @@ class NoteTypeFieldEditorContextMenuTest : InstrumentedTest() { @Ignore("flaky") fun showsAllOptions() { launchFragment( - fragmentArgs = Bundle().apply { putString(NoteTypeFieldEditorContextMenu.KEY_LABEL, testDialogTitle) }, + fragmentArgs = Bundle().apply { putString(NoteTypeFieldEditorContextMenu.ARG_LABEL, testDialogTitle) }, themeResId = R.style.Theme_Light, ) { NoteTypeFieldEditorContextMenu() } onView(withText(testDialogTitle)) diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/exception/SanitizedReportCollectorIntegrationTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/exception/SanitizedReportCollectorIntegrationTest.kt new file mode 100644 index 000000000000..3da3adad95e8 --- /dev/null +++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/exception/SanitizedReportCollectorIntegrationTest.kt @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.exception + +import android.content.Context +import androidx.test.core.app.ApplicationProvider.getApplicationContext +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.AnkiDroidApp +import com.ichi2.anki.logging.ProductionCrashReportingTree +import com.ichi2.anki.servicelayer.ThrowableFilterService +import org.acra.ReportField +import org.acra.builder.ReportBuilder +import org.acra.config.CoreConfigurationBuilder +import org.acra.data.CrashReportData +import org.acra.data.CrashReportDataFactory +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import timber.log.Timber +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +/** Exercises the packaged ACRA collectors against Android's logcat and the uncaught-error filter. */ +@RunWith(AndroidJUnit4::class) +class SanitizedReportCollectorIntegrationTest { + private val tree = ProductionCrashReportingTree() + + @Before + fun setUp() { + Timber.plant(tree) + } + + @After + fun tearDown() { + Timber.uproot(tree) + } + + @Test + fun sanitizedReportsExcludePrivateMessagesAlreadyWrittenToLogcat() { + val privateMessage = "private-${UUID.randomUUID()}@example.com" + val exception = StorageAccessException(privateMessage) + Timber.w(exception) + + // Prove that the real LogCatCollector can read the private message before filtering. + val unfiltered = collectReport(exception, parallel = false) + assertTrue(assertNotNull(unfiltered.getString(ReportField.LOGCAT)).contains(privateMessage)) + + for (parallel in listOf(false, true)) { + val report = collectUnhandledReport(exception, parallel) + + assertEquals( + "Logcat filtered because it may contain personally identifiable information (PII).", + report.getString(ReportField.LOGCAT), + ) + assertFalse(report.toJSON().contains(privateMessage)) + assertTrue(assertNotNull(report.getString(ReportField.STACK_TRACE)).contains(exception.javaClass.name)) + assertFalse(report.getString(ReportField.STACK_TRACE_HASH).isNullOrEmpty()) + } + } + + @Test + fun ordinaryReportsRetainTheirLogcatDiagnostics() { + val message = "ordinary diagnostics ${UUID.randomUUID()}" + val exception = IllegalStateException(message) + Timber.w(exception) + + for (parallel in listOf(false, true)) { + val report = collectUnhandledReport(exception, parallel) + + assertTrue(assertNotNull(report.getString(ReportField.LOGCAT)).contains(message)) + assertTrue(assertNotNull(report.getString(ReportField.STACK_TRACE)).contains(message)) + } + } + + @Test + fun unrelatedThreadFailuresReachOriginalHandler() { + val backgroundFailure = IllegalStateException("unrelated background failure") + val worker = + Thread { throw backgroundFailure }.apply { + uncaughtExceptionHandler = ThrowableFilterService.uncaughtExceptionHandler + } + val originalHandler = ThrowableFilterService.originalUncaughtExceptionHandler + var delegatedThread: Thread? = null + var delegatedFailure: Throwable? = null + val delegate = + Thread.UncaughtExceptionHandler { thread, throwable -> + if (thread === worker) { + delegatedThread = thread + delegatedFailure = throwable + } else { + originalHandler?.uncaughtException(thread, throwable) + } + } + try { + ThrowableFilterService.originalUncaughtExceptionHandler = delegate + val expectedFailure = IllegalStateException("expected report failure") + val report = + collectUnhandledReport(expectedFailure, parallel = false) { + worker.start() + worker.join(5000) + assertFalse(worker.isAlive, "The unrelated exception handler must finish") + assertSame(worker, delegatedThread) + assertSame(backgroundFailure, delegatedFailure) + } + + assertTrue(assertNotNull(report.getString(ReportField.STACK_TRACE)).contains("expected report failure")) + assertSame(delegate, ThrowableFilterService.originalUncaughtExceptionHandler) + } finally { + ThrowableFilterService.originalUncaughtExceptionHandler = originalHandler + } + } + + private fun collectUnhandledReport( + exception: Throwable, + parallel: Boolean, + beforeReport: () -> Unit = {}, + ): CrashReportData { + val reportingThread = Thread.currentThread() + val originalHandler = ThrowableFilterService.originalUncaughtExceptionHandler + var report: CrashReportData? = null + try { + // Capture this thread's report before ACRA's sending and process-termination stages. + ThrowableFilterService.originalUncaughtExceptionHandler = + Thread.UncaughtExceptionHandler { thread, reportable -> + if (thread === reportingThread) { + report = collectReport(reportable, parallel) + } else { + originalHandler?.uncaughtException(thread, reportable) + } + } + beforeReport() + ThrowableFilterService.uncaughtExceptionHandler.uncaughtException(reportingThread, exception) + } finally { + ThrowableFilterService.originalUncaughtExceptionHandler = originalHandler + } + return assertNotNull(report, "The uncaught exception must reach report collection") + } + + private fun collectReport( + exception: Throwable, + parallel: Boolean, + ): CrashReportData { + val config = + CoreConfigurationBuilder() + .withReportContent(ReportField.STACK_TRACE, ReportField.STACK_TRACE_HASH, ReportField.LOGCAT) + .withLogcatArguments("-t", "500", "-v", "time", "${AnkiDroidApp.TAG}:D", "*:S") + .withParallel(parallel) + .build() + return CrashReportDataFactory(getApplicationContext(), config).createCrashData(ReportBuilder().exception(exception)) + } +} diff --git a/AnkiDroid/src/main/AndroidManifest.xml b/AnkiDroid/src/main/AndroidManifest.xml index f5c3dc5cc208..cacc66bbd45c 100644 --- a/AnkiDroid/src/main/AndroidManifest.xml +++ b/AnkiDroid/src/main/AndroidManifest.xml @@ -513,49 +513,12 @@ android:name="autoStoreLocales" android:value="true" /> - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - 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..b6d67c01c7d6 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 @@ -131,7 +130,6 @@ import com.ichi2.anki.multimedia.getAvTag import com.ichi2.anki.observability.ChangeManager import com.ichi2.anki.observability.undoableOp import com.ichi2.anki.pages.AnkiServer -import com.ichi2.anki.pages.CongratsPage import com.ichi2.anki.pages.PostRequestHandler import com.ichi2.anki.pages.PostRequestUri import com.ichi2.anki.reviewer.AutomaticAnswer @@ -150,6 +148,7 @@ import com.ichi2.anki.settings.Prefs import com.ichi2.anki.snackbar.BaseSnackbarBuilderProvider import com.ichi2.anki.snackbar.SnackbarBuilder import com.ichi2.anki.snackbar.showSnackbar +import com.ichi2.anki.study.StudyCompletionUi import com.ichi2.anki.ui.windows.reviewer.StudyScreenRepository import com.ichi2.anki.utils.OnlyOnce.Method.ANSWER_CARD import com.ichi2.anki.utils.OnlyOnce.preventSimultaneousExecutions @@ -526,7 +525,7 @@ abstract class AbstractFlashcardViewer : closeReviewer(RESULT_NO_MORE_CARDS) // When launched with a shortcut, we want to display a message when finishing if (intent.getBooleanExtra(EXTRA_STARTED_WITH_SHORTCUT, false)) { - CongratsPage.display(this) + StudyCompletionUi.displayFromShortcut(this) } return } @@ -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..7e1211aa768e 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), ) } @@ -187,7 +187,7 @@ open class AnkiActivity( } if (savedInstanceState != null) { - val restoredValue = savedInstanceState.getString(KEY_EXPORT_FILE_NAME) ?: return + val restoredValue = savedInstanceState.getString(STATE_EXPORT_FILE_PATH) ?: return fileExportPath = restoredValue } } @@ -718,7 +718,7 @@ open class AnkiActivity( override fun onSaveInstanceState(outState: Bundle) { if (::fileExportPath.isInitialized) { - outState.putString(KEY_EXPORT_FILE_NAME, fileExportPath) + outState.putString(STATE_EXPORT_FILE_PATH, fileExportPath) } super.onSaveInstanceState(outState) } @@ -864,7 +864,7 @@ open class AnkiActivity( const val EXTRA_FINISH_ANIMATION = "finishAnimation" private const val SIMPLE_NOTIFICATION_ID = 0 - private const val KEY_EXPORT_FILE_NAME = "key_export_file_name" + private const val STATE_EXPORT_FILE_PATH = "key_export_file_name" } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt index e0936fb42fee..40fcf73244e4 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 @@ -74,8 +75,10 @@ import com.ichi2.utils.ExceptionUtil import com.ichi2.utils.LanguageUtil import com.ichi2.utils.measureTime import com.ichi2.utils.setWebContentsDebuggingEnabled +import com.ichi2.widget.AnkiDroidWidgetSmall import com.ichi2.widget.DayRolloverAlarm import com.ichi2.widget.WidgetNotificationScheduler +import com.ichi2.widget.WidgetStatus import com.ichi2.widget.cardanalysis.CardAnalysisWidget import com.ichi2.widget.deckpicker.DeckPickerWidget import com.ichi2.widget.restoreRecurringAlarms @@ -129,8 +132,8 @@ open class AnkiDroidApp : } } + @set:VisibleForTesting var profileManager: ProfileManager? = null - private set private val profileContext: Context? get() = profileManager?.activeProfileContext @@ -188,6 +191,7 @@ open class AnkiDroidApp : initializeNavigator() initializeWidgetRepository() WidgetNotificationScheduler.register { scheduleNotification() } + WidgetStatus.register(Prefs) { context -> AnkiDroidWidgetSmall.UpdateService().doUpdate(context) } Animations.setPreferencesProvider { context -> PrefsRepository(context) } CollectionLockedException.messageProvider = { getString(CommonString.database_locked_summary_new, getString(CommonString.col_path)) @@ -279,6 +283,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..929ae1319c10 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateBrowserAppearanceEditor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateBrowserAppearanceEditor.kt @@ -69,13 +69,17 @@ class CardTemplateBrowserAppearanceEditor : AnkiActivity(R.layout.activity_card_ view.updatePadding(left = constraints.left, right = constraints.right, bottom = constraints.bottom) insets } - val bundle = savedInstanceState ?: intent.extras - if (bundle == null) { - showThemedToast(this, getString(CommonString.something_wrong), true) - finish() - return + when { + savedInstanceState != null -> + initializeUi(savedInstanceState.getString(STATE_QUESTION_FORMAT), savedInstanceState.getString(STATE_ANSWER_FORMAT)) + intent.extras != null -> + initializeUi(intent.getStringExtra(EXTRA_QUESTION_FORMAT), intent.getStringExtra(EXTRA_ANSWER_FORMAT)) + else -> { + showThemedToast(this, getString(CommonString.something_wrong), true) + finish() + return + } } - initializeUiFromBundle(bundle) // default result, only changed to RESULT_OK if actually saving changes setResult(RESULT_CANCELED) onBackPressedDispatcher.addCallback(discardChangesCallback) @@ -137,14 +141,17 @@ 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(STATE_QUESTION_FORMAT, questionFormat) + outState.putString(STATE_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)) + private fun initializeUi( + questionFormat: String?, + answerFormat: String?, + ) { + binding.questionFormat.setText(questionFormat) + binding.answerFormat.setText(answerFormat) discardChangesCallback.isEnabled = hasChanges() @@ -152,9 +159,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 +181,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 +215,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 +227,10 @@ 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" + private const val STATE_QUESTION_FORMAT = "bqfmt" + private const val STATE_ANSWER_FORMAT = "bafmt" /** Specified the card browser should use the default template formatter */ const val VALUE_USE_DEFAULT = "" @@ -239,8 +248,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/CardTemplateChanges.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateChanges.kt index 92d86f8d8d5f..9c723551c605 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateChanges.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateChanges.kt @@ -104,56 +104,12 @@ class CardTemplateChanges { if (!BuildConfig.DEBUG) { return } - val adjustedChanges = adjustedTemplateChanges for (i in templateChanges.indices) { val change = templateChanges[i] - val adjustedChange = adjustedChanges[i] Timber.d("dumpChanges() Change %s is ord/type %s/%s", i, change.ordinal, change.type) - Timber.d( - "dumpChanges() During save change %s will be ord/type %s/%s", - i, - adjustedChange.ordinal, - adjustedChange.type, - ) } } - /** - * Adjust the ordinals in our accrued change list so that any pending adds have the correct - * ordinal after taking into account any pending deletes - * - * @return ArrayList[2]> of [ordinal][ChangeType] entries - */ - val adjustedTemplateChanges: ArrayList - get() { - val changes = templateChanges - val adjustedChanges = ArrayList(changes.size) - - // In order to save the changes into the database, the ordinals in the changelist must correspond to the - // ordinals in the database (for deletes) or the correct index in the changes array (for adds) - // It is not possible to know what those will be until the user requests a save, so they are stored in the - // change list as-is until the save time comes, then the adjustment is made all at once - for (i in changes.indices) { - val change = changes[i] - val adjustedChange = - when (change.type) { - ChangeType.ADD -> { - val adjustedOrdinal = getAdjustedAddOrdinalAtChangeIndex(this, i) - Timber.d( - "getAdjustedTemplateChanges() change %s ordinal adjusted from %s to %s", - i, - change.ordinal, - adjustedOrdinal, - ) - TemplateChange(adjustedOrdinal, ChangeType.ADD) - } - ChangeType.DELETE -> change - } - adjustedChanges.add(adjustedChange) - } - return adjustedChanges - } - /** * Scan the sequence of template add/deletes, looking for the given ordinal. * When found, purge that ordinal and shift future changes down if they had ordinals higher than the one purged diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt index eebc07c52163..a4eea06f2157 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,24 +211,24 @@ 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) - tabToCursorPositions = savedInstanceState.getSerializableCompat>>(TAB_TO_CURSOR_POSITION_KEY)!! - tabToViewId = savedInstanceState.getSerializableCompat>(TAB_TO_VIEW_ID)!! + noteTypeId = savedInstanceState.getLong(STATE_NOTE_TYPE_ID) + noteId = savedInstanceState.getLong(STATE_NOTE_ID) + startingOrdId = savedInstanceState.getInt(STATE_STARTING_TEMPLATE_ORDINAL) + tabToCursorPositions = savedInstanceState.getSerializableCompat>>(STATE_TAB_TO_CURSOR_POSITION)!! + tabToViewId = savedInstanceState.getSerializableCompat>(STATE_TAB_TO_VIEW_ID)!! tempNoteType = CardTemplateNotetype.fromBundle(savedInstanceState) } @@ -335,11 +342,11 @@ 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) - putSerializable(TAB_TO_VIEW_ID, tabToViewId) - putSerializable(TAB_TO_CURSOR_POSITION_KEY, tabToCursorPositions) + putLong(STATE_NOTE_TYPE_ID, noteTypeId) + putLong(STATE_NOTE_ID, noteId) + putInt(STATE_STARTING_TEMPLATE_ORDINAL, startingOrdId) + putSerializable(STATE_TAB_TO_VIEW_ID, tabToViewId) + putSerializable(STATE_TAB_TO_CURSOR_POSITION, tabToCursorPositions) super.onSaveInstanceState(this) } } @@ -611,7 +618,7 @@ open class CardTemplateEditor : AnkiActivity(R.layout.activity_card_template_edi // Index of this card template fragment in ViewPager private val cardIndex - get() = requireArguments().getInt(CARD_INDEX) + get() = requireArguments().getInt(ARG_CARD_INDEX) /** Whether this is the page shown by the pager. */ private val isCurrentPage: Boolean @@ -732,7 +739,7 @@ open class CardTemplateEditor : AnkiActivity(R.layout.activity_card_template_edi } // set saved or default view binding.bottomNavigation.selectedItemId = - templateEditor.tabToViewId[cardIndex] ?: requireArguments().getInt(EDITOR_VIEW_ID_KEY) + templateEditor.tabToViewId[cardIndex] ?: requireArguments().getInt(ARG_EDITOR_VIEW_ID) // Set text change listeners val templateEditorWatcher: TextWatcher = @@ -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 } @@ -1382,7 +1386,7 @@ open class CardTemplateEditor : AnkiActivity(R.layout.activity_card_template_edi */ private fun getCurrentCardTemplateIndex(): Int { // COULD_BE_BETTER: Lots of duplicate code could call this. Hold off on the refactor until #5151 goes in. - return requireArguments().getInt(CARD_INDEX) + return requireArguments().getInt(ARG_CARD_INDEX) } private suspend fun deletionWouldOrphanNote( @@ -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,9 +1582,23 @@ 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 - val oldCardIndex = requireArguments().getInt(CARD_INDEX) + 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(ARG_CARD_INDEX) val templates = noteType.templates val oldTemplate = templates[oldCardIndex] val newTemplate = Notetypes.newTemplate(newCardName(templates)) @@ -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 } /** @@ -1668,9 +1680,9 @@ open class CardTemplateEditor : AnkiActivity(R.layout.activity_card_template_edi ): CardTemplateFragment { val f = CardTemplateFragment() val args = Bundle() - args.putInt(CARD_INDEX, cardIndex) - args.putLong(EDITOR_NOTE_ID, noteId) - args.putInt(EDITOR_VIEW_ID_KEY, viewId) + args.putInt(ARG_CARD_INDEX, cardIndex) + args.putLong(EXTRA_NOTE_ID, noteId) + args.putInt(ARG_EDITOR_VIEW_ID, viewId) f.arguments = args return f } @@ -1684,13 +1696,16 @@ open class CardTemplateEditor : AnkiActivity(R.layout.activity_card_template_edi } companion object { - 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" - private const val CARD_INDEX = "card_ord" + private const val STATE_TAB_TO_CURSOR_POSITION = "tabToCursorPosition" + private const val ARG_EDITOR_VIEW_ID = "editorViewId" + private const val STATE_TAB_TO_VIEW_ID = "tabToViewId" + 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 STATE_NOTE_TYPE_ID = "noteTypeId" + private const val STATE_NOTE_ID = "noteId" + private const val STATE_STARTING_TEMPLATE_ORDINAL = "ordId" + private const val ARG_CARD_INDEX = "card_ord" // Keys for saving pane weights in SharedPreferences private const val PREF_TEMPLATE_EDITOR_PANE_WEIGHT = "cardTemplateEditorPaneWeight" @@ -1713,9 +1728,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 +1741,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/CardTemplateNotetype.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateNotetype.kt index 650b701a4dda..c850541f5cd5 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateNotetype.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateNotetype.kt @@ -8,9 +8,9 @@ import android.content.Intent import android.os.Bundle import android.os.Parcel import android.os.Parcelable -import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.common.android.appContext import com.ichi2.anki.compat.CompatHelper.Companion.getSerializableCompat +import com.ichi2.anki.libanki.CardOrdinal import com.ichi2.anki.libanki.CardTemplate import com.ichi2.anki.libanki.NoteTypeId import com.ichi2.anki.libanki.NotetypeJson @@ -37,6 +37,8 @@ class CardTemplateNotetype( private val changes = CardTemplateChanges() + private var originalTemplateOrds: ArrayList = notetype.templates.mapTo(ArrayList()) { it.ord } + var templateChanges: ArrayList get() = changes.templateChanges private set(value) { @@ -45,13 +47,15 @@ class CardTemplateNotetype( fun toBundle(): Bundle = Bundle().apply { - putString(INTENT_MODEL_FILENAME, saveTempNoteType(appContext, notetype)) - putSerializable("mTemplateChanges", templateChanges) + putString(STATE_EDITED_NOTE_TYPE_FILENAME, saveTempNoteType(appContext, notetype)) + putSerializable(STATE_TEMPLATE_CHANGES, templateChanges) + putSerializable(STATE_ORIGINAL_TEMPLATE_ORDS, originalTemplateOrds) } private fun loadTemplateChanges(bundle: Bundle) { try { - templateChanges = bundle.getSerializableCompat("mTemplateChanges")!! + templateChanges = bundle.getSerializableCompat(STATE_TEMPLATE_CHANGES)!! + originalTemplateOrds = bundle.getSerializableCompat(STATE_ORIGINAL_TEMPLATE_ORDS)!! } catch (e: ClassCastException) { Timber.e(e, "Unexpected cast failure") } @@ -84,50 +88,25 @@ class CardTemplateNotetype( fun addNewTemplate(newTemplate: CardTemplate) { Timber.d("addNewTemplate()") addTemplateChange(ChangeType.ADD, newTemplate.ord) + originalTemplateOrds.add(null) } fun removeTemplate(ord: Int) { Timber.d("removeTemplate() on ordinal %s", ord) addTemplateChange(ChangeType.DELETE, ord) + originalTemplateOrds.removeAt(ord) } suspend fun saveToDatabase() { Timber.d("saveToDatabase() called") changes.dumpChanges() clearTempNoteTypeFiles() - saveNoteType(notetype, adjustedTemplateChanges) - } - - /** - * Handles everything for a note type change at once - template add / deletes as well as content updates - */ - suspend fun saveNoteType( - notetype: NotetypeJson, - templateChanges: ArrayList, - ) { - Timber.d("saveNoteType") - val oldNoteType = withCol { notetypes.get(notetype.id) } - - val newTemplates = notetype.templates - for (change in templateChanges) { - val oldTemplates = oldNoteType!!.templates - when (change.type) { - ChangeType.ADD -> { - Timber.d("saveNoteType() adding template %s", change.ordinal) - withCol { notetypes.addTemplate(oldNoteType, newTemplates[change.ordinal]) } - } - ChangeType.DELETE -> { - Timber.d("saveNoteType() deleting template currently at ordinal %s", change.ordinal) - withCol { notetypes.removeTemplate(oldNoteType, oldTemplates[change.ordinal]) } - } - } + val updated = notetype.deepClone() + for ((i, template) in updated.templates.withIndex()) { + template.setOrd(originalTemplateOrds[i]) } - - // required for Rust: the modified time can't go backwards, and we updated the note type by adding fields - // This could be done better - notetype.mod = oldNoteType!!.mod undoableOp { - notetypes.updateDict(notetype) + notetypes.updateDict(updated) } } @@ -138,23 +117,22 @@ class CardTemplateNotetype( fun getDeleteDbOrds(ord: Int): IntArray = changes.getDeleteDbOrds(ord) - val adjustedTemplateChanges: ArrayList - get() = changes.adjustedTemplateChanges - companion object { - const val INTENT_MODEL_FILENAME = "editedNoteTypeFilename" + private const val STATE_EDITED_NOTE_TYPE_FILENAME = "editedNoteTypeFilename" + private const val STATE_TEMPLATE_CHANGES = "mTemplateChanges" + private const val STATE_ORIGINAL_TEMPLATE_ORDS = "originalTemplateOrds" /** * Load the TemporaryNoteType from the filename included in a Bundle * - * @param bundle a Bundle that should contain persisted JSON under INTENT_MODEL_FILENAME key + * @param bundle a Bundle that should contain persisted JSON under STATE_EDITED_NOTE_TYPE_FILENAME key * @return re-hydrated TemporaryNoteType or null if there was a problem, null means should reload from database */ fun fromBundle(bundle: Bundle): CardTemplateNotetype? { - val editedNoteTypeFileName = bundle.getString(INTENT_MODEL_FILENAME) + val editedNoteTypeFileName = bundle.getString(STATE_EDITED_NOTE_TYPE_FILENAME) // Bundle.getString is @Nullable, so we have to check. if (editedNoteTypeFileName == null) { - Timber.d("fromBundle() - note type file name under key %s", INTENT_MODEL_FILENAME) + Timber.d("fromBundle() - note type file name under key %s", STATE_EDITED_NOTE_TYPE_FILENAME) return null } Timber.d("onCreate() loading saved note type file %s", editedNoteTypeFileName) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt index be57b28f4648..998e21fe9817 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt @@ -17,8 +17,10 @@ import androidx.appcompat.app.AlertDialog import androidx.core.net.toUri import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModel import androidx.lifecycle.coroutineScope +import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewModelScope import anki.collection.Progress import com.ichi2.anki.CollectionManager.TR @@ -243,23 +245,25 @@ fun Fragment.launchCatchingTask( block: suspend CoroutineScope.() -> Unit, ): Job = lifecycle.coroutineScope.launch { - // It's a bug to call this if the activity is invalid or finishing. - val validActivity = - kotlin - .runCatching { - requireActivity(simpleName = true).also { activity -> - check(!activity.isFinishing) { - "Cannot launch task for ${this@launchCatchingTask.javaClass.simpleName}: activity is finishing" - } - } - }.getOrElse { - Timber.w(it, "Fragment.launchCatchingTask") - if (isRobolectric) throw it - return@launch - } + val validActivity = validActivityForTask() ?: return@launch validActivity.runCatching(errorMessage, skipCrashReport = skipCrashReport) { block() } } +private fun Fragment.validActivityForTask(): FragmentActivity? = + // It's a bug to call this if the activity is invalid or finishing. + kotlin + .runCatching { + requireActivity(simpleName = true).also { activity -> + check(!activity.isFinishing) { + "Cannot launch task for ${this@validActivityForTask.javaClass.simpleName}: activity is finishing" + } + } + }.getOrElse { + Timber.w(it, "Fragment.launchCatchingTask") + if (isRobolectric) throw it + null + } + /** * [Fragment.requireActivity], with a short stable name so crash reports can be grouped together. * @@ -275,6 +279,26 @@ fun Fragment.requireActivity(simpleName: Boolean): FragmentActivity = requireActivity() } +/** + * Like [Fragment.launchCatchingTask], but tied to the current view's lifecycle. + * Skips [block] if the view is destroyed before the job starts. + * + * @return the launched job, or `null` if the fragment has no view. + */ +fun Fragment.launchCatchingViewTask( + errorMessage: String? = null, + skipCrashReport: ((Exception) -> Boolean)? = null, + block: suspend CoroutineScope.() -> Unit, +): Job? { + val owner = viewLifecycleOwnerLiveData.value ?: return null + return owner.lifecycleScope.launch { + // A synchronous replacement can destroy the view before lifecycleScope registers cancellation. + if (owner.lifecycle.currentState == Lifecycle.State.DESTROYED) return@launch + val validActivity = validActivityForTask() ?: return@launch + validActivity.runCatching(errorMessage, skipCrashReport = skipCrashReport) { block() } + } +} + /** * Displays an error dialog with title 'Error' and provided [message]. * May report the error when the dialog is dismissed diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt index 7f8194ae4104..7bdc25f0b315 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 @@ -78,6 +79,7 @@ import com.google.android.material.snackbar.Snackbar import com.ichi2.anki.BottomNavController.NavigationItem import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CollectionManager.withCol +import com.ichi2.anki.DeckPickerFloatingActionMenu.CloseAnimation import com.ichi2.anki.DeckPickerFloatingActionMenu.FloatingActionBarToggleListener import com.ichi2.anki.InitialActivity.StartupFailure import com.ichi2.anki.InitialActivity.StartupFailure.DBError @@ -165,8 +167,6 @@ import com.ichi2.anki.libanki.sched.DeckNode import com.ichi2.anki.mediacheck.MediaCheckFragment import com.ichi2.anki.observability.ChangeManager import com.ichi2.anki.pages.AnkiPackageImporterFragment -import com.ichi2.anki.pages.CongratsPage -import com.ichi2.anki.pages.CongratsPage.Companion.onDeckCompleted import com.ichi2.anki.progress.observeProgress import com.ichi2.anki.receiver.SdCardReceiver import com.ichi2.anki.reviewreminders.ReviewReminderScope @@ -179,6 +179,8 @@ import com.ichi2.anki.shareddeck.SharedDecksActivity import com.ichi2.anki.snackbar.BaseSnackbarBuilderProvider import com.ichi2.anki.snackbar.SnackbarBuilder import com.ichi2.anki.snackbar.showSnackbar +import com.ichi2.anki.study.StudyCompletionUi +import com.ichi2.anki.study.currentDeckStudyState import com.ichi2.anki.sync.MeteredSyncPolicy import com.ichi2.anki.sync.launchCatchingRequiringOneWaySyncDiscardUndo import com.ichi2.anki.sync.syncStatus @@ -221,6 +223,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 +304,7 @@ open class DeckPicker : private lateinit var decksLayoutManager: LinearLayoutManager private lateinit var deckListAdapter: DeckAdapter + private var backgroundImageErrorShown = false private lateinit var pullToSyncWrapper: SwipeRefreshLayout @VisibleForTesting @@ -431,7 +435,7 @@ open class DeckPicker : private val closeFloatingActionBarBackPressCallback = object : OnBackPressedCallback(enabled = false) { override fun handleOnBackPressed() { - floatingActionMenu.closeFloatingActionMenu(applyRiseAndShrinkAnimation = true) + floatingActionMenu.closeFloatingActionMenu(style = CloseAnimation.SHORT) } } @@ -522,7 +526,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 +592,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 @@ -638,6 +651,16 @@ open class DeckPicker : setFragmentResultListener(Check.DATABASE.requestKey) { _, _ -> performIntegrityCheck() } + setFragmentResultListener(SchedulerUpgradeDialog.REQUEST_SCHEDULER_UPGRADE) { _, result -> + if (result.getBoolean(SchedulerUpgradeDialog.RESULT_UPGRADE)) { + launchCatchingRequiringOneWaySyncDiscardUndo { + this@DeckPicker.withProgress { withCol { sched.upgradeToV2() } } + showThemedToast(this@DeckPicker, TR.schedulingUpdateDone(), false) + } + } else { + onBackPressedDispatcher.onBackPressed() + } + } registerStudyOptionsStudyHandler { Timber.d("Opening study screen from DeckPicker's study options panel") @@ -811,18 +834,9 @@ open class DeckPicker : } fun onPromptUserToUpdateScheduler(op: Unit) { - SchedulerUpgradeDialog( - activity = this, - onUpgrade = { - launchCatchingRequiringOneWaySyncDiscardUndo { - this@DeckPicker.withProgress { withCol { sched.upgradeToV2() } } - showThemedToast(this@DeckPicker, TR.schedulingUpdateDone(), false) - } - }, - onCancel = { - onBackPressedDispatcher.onBackPressed() - }, - ).showDialog() + if (supportFragmentManager.findFragmentByTag(SchedulerUpgradeDialog.TAG) == null) { + SchedulerUpgradeDialog().showNow(supportFragmentManager, SchedulerUpgradeDialog.TAG) + } } fun onOptionsMenuUpdated(unused: OptionsMenuState) = invalidateOptionsMenu() @@ -1212,17 +1226,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 @@ -1230,7 +1250,7 @@ open class DeckPicker : Timber.d("onCreateOptionsMenu()") if (isDrawerOpen) { - floatingActionMenu.closeFloatingActionMenu(applyRiseAndShrinkAnimation = false) + floatingActionMenu.closeFloatingActionMenu(style = CloseAnimation.LONG) } // Fragments own their menus: each fragment registers a MenuProvider against this // activity (see StudyOptionsFragment), and the menu host dispatches creation, @@ -1507,9 +1527,7 @@ open class DeckPicker : return true } R.id.action_deck_delete -> { - launchCatchingTask { - viewModel.deleteSelectedDeck().join() - } + deleteSelectedDeck() return true } R.id.action_export_collection -> { @@ -1549,7 +1567,10 @@ open class DeckPicker : private fun processReviewResults(resultCode: Int) { if (resultCode == AbstractFlashcardViewer.RESULT_NO_MORE_CARDS) { - CongratsPage.onReviewsCompleted(this, getColUnsafe.sched.totalCount() == 0) + launchCatchingTask { + val state = withCol { currentDeckStudyState() } + StudyCompletionUi.onReviewsCompleted(this@DeckPicker, state) + } fragment?.refreshInterface() } } @@ -1572,7 +1593,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() @@ -1786,7 +1807,7 @@ open class DeckPicker : if (event.isShiftPressed) { // Shortcut: Shift + DEL - Delete deck without confirmation dialog Timber.i("Shift+DEL: Deck deck without confirmation") - viewModel.focusedDeck?.let { did -> deleteDeck(did) } + deleteSelectedDeck() } else { // Shortcut: DEL Timber.i("Delete Deck from keypress") @@ -1801,7 +1822,7 @@ open class DeckPicker : // that is, when it appears in the trailing study option fragment if (fragmented) { Timber.i("Rename Deck from keypress") - viewModel.focusedDeck?.let { did -> renameDeckDialog(did) } + viewModel.requestRenameSelectedDeck() return true } } @@ -2117,11 +2138,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) { @@ -2247,7 +2272,7 @@ open class DeckPicker : showEmptyDeckSnackbar() updateUi() } else { - onDeckCompleted() + StudyCompletionUi.openCongratsPage(this) } } @@ -2364,6 +2389,11 @@ open class DeckPicker : viewModel.deleteDeck(did).join() } + private fun deleteSelectedDeck() = + launchCatchingTask { + viewModel.deleteSelectedDeck().join() + } + @NeedsTest("14285: regression test to ensure UI is updated after this call") fun rebuildFiltered(did: DeckId) { launchCatchingTask { @@ -2488,7 +2518,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 +2545,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 +2553,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..3020e2013045 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt @@ -4,6 +4,7 @@ package com.ichi2.anki import android.animation.Animator +import android.animation.AnimatorListenerAdapter import android.content.Context import android.content.res.ColorStateList import android.view.KeyEvent @@ -11,6 +12,8 @@ import android.view.MotionEvent import android.view.View import android.widget.LinearLayout import androidx.annotation.VisibleForTesting +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner import com.google.android.material.color.MaterialColors import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.common.android.animationEnabled @@ -30,6 +33,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 @@ -48,12 +52,15 @@ class DeckPickerFloatingActionMenu( var isFABOpen = false private set + private var pendingMenuReset = false + @Suppress("unused") val isFragmented: Boolean get() = studyOptionsFrame != null @VisibleForTesting fun showFloatingActionMenu() { + if (pendingMenuReset) return toggleListener.onBeginToggle(isOpening = true) deckPicker.activeSnackBar?.dismiss() linearLayout.alpha = 0.5f @@ -70,9 +77,12 @@ class DeckPickerFloatingActionMenu( binding.fabBGLayout.visibility = View.VISIBLE binding.fabMain.backgroundTintList = ColorStateList.valueOf(fabPressedColor) binding.fabMain.setIconResource(addNoteIcon) - binding.fabMain.extend() + extendMainButton() with(binding) { + // Dismissal listeners must not run when these opening animations finish. + addDeckButton.animate().setListener(null) + addFilteredDeckButton.animate().setListener(null) addSharedButton.animate().translationY(0f).duration = 100 addDeckButton.animate().translationY(0f).duration = 70 addFilteredDeckButton.animate().translationY(0f).duration = 100 @@ -102,159 +112,83 @@ 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. - * - * Case 1: When the FAB is already opened and we close it by pressing the back button then we need to show - * the rise and shrink animation and get back to the FAB with `+` icon. - * - * Case 2: When the user opens the side navigation drawer (without touching the FAB). In that case we don't - * want to show any type of rise and shrink animation for the FAB so we put the value `false` for the parameter. + * Dismisses the menu, or resets it off-screen after opening the editor. + * [style] selects the closing animation for Back/outside taps or menu actions. + * [animate] is false for the off-screen reset. */ - fun closeFloatingActionMenu(applyRiseAndShrinkAnimation: Boolean) { + fun closeFloatingActionMenu( + style: CloseAnimation, + animate: Boolean = deckPicker.animationEnabled(), + ) { toggleListener.onBeginToggle(isOpening = false) - if (applyRiseAndShrinkAnimation) { - linearLayout.alpha = 1f - studyOptionsFrame?.let { it.alpha = 1f } - isFABOpen = false - binding.fabBGLayout.visibility = View.GONE - if (deckPicker.animationEnabled()) { - // Changes the background color of FAB to default - binding.fabMain.backgroundTintList = ColorStateList.valueOf(fabNormalColor) - // Close with animation - binding.fabMain.setIconResource(addWhiteIcon) - binding.fabMain.shrink() - - with(binding) { - addSharedButton.animate().alpha(0f).duration = 50 - addDeckButton.animate().alpha(0f).duration = 100 - addFilteredDeckButton.animate().alpha(0f).duration = 100 - addSharedButton.animate().translationY(400f).duration = 100 - addDeckButton - .animate() - .translationY(300f) - .setDuration(50) - .setListener( - object : Animator.AnimatorListener { - override fun onAnimationStart(animator: Animator) {} - - override fun onAnimationEnd(animator: Animator) { - if (!isFABOpen) { - addSharedButton.visibility = View.GONE - addDeckButton.visibility = View.GONE - addFilteredDeckButton.visibility = View.GONE - } - } - - override fun onAnimationCancel(animator: Animator) {} - - override fun onAnimationRepeat(animator: Animator) {} - }, - ) - addFilteredDeckButton - .animate() - .translationY(400f) - .setDuration(100) - .setListener( - object : Animator.AnimatorListener { - override fun onAnimationStart(animator: Animator) {} - - override fun onAnimationEnd(animator: Animator) { - if (!isFABOpen) { - addSharedButton.visibility = View.GONE - addDeckButton.visibility = View.GONE - addFilteredDeckButton.visibility = View.GONE - } - } - - override fun onAnimationCancel(animator: Animator) {} - - override fun onAnimationRepeat(animator: Animator) {} - }, - ) - } - } else { - // Close without animation - binding.addSharedButton.visibility = View.GONE - binding.addDeckButton.visibility = View.GONE - binding.addFilteredDeckButton.visibility = View.GONE - binding.fabMain.isExtended = false - - binding.fabMain.setIconResource(addWhiteIcon) + linearLayout.alpha = 1f + studyOptionsFrame?.let { it.alpha = 1f } + isFABOpen = false + with(binding) { + fabBGLayout.visibility = View.GONE + fabMain.backgroundTintList = ColorStateList.valueOf(fabNormalColor) + fabMain.setIconResource(addWhiteIcon) + + if (!animate) { + addSharedButton.animate().cancel() + addDeckButton.animate().cancel() + addFilteredDeckButton.animate().cancel() + hideMenuButtons() + fabMain.isExtended = false + return } - } else { - linearLayout.alpha = 1f - studyOptionsFrame?.let { it.alpha = 1f } - isFABOpen = false - binding.fabBGLayout.visibility = View.GONE - if (deckPicker.animationEnabled()) { - // Changes the background color of FAB to default - binding.fabMain.backgroundTintList = ColorStateList.valueOf(fabNormalColor) - // Close with animation - binding.fabMain.setIconResource(addWhiteIcon) - binding.fabMain.shrink() - - with(binding) { - addSharedButton.animate().alpha(0f).duration = 70 - addDeckButton.animate().alpha(0f).duration = 50 - addFilteredDeckButton.animate().alpha(0f).duration = 50 - addSharedButton.animate().translationY(600f).duration = 100 - addDeckButton - .animate() - .translationY(400f) - .setDuration(50) - .setListener( - object : Animator.AnimatorListener { - override fun onAnimationStart(animator: Animator) {} - - override fun onAnimationEnd(animator: Animator) { - if (!isFABOpen) { - addSharedButton.visibility = View.GONE - addDeckButton.visibility = View.GONE - addFilteredDeckButton.visibility = View.GONE - } - } - - override fun onAnimationCancel(animator: Animator) {} - - override fun onAnimationRepeat(animator: Animator) {} - }, - ) - addFilteredDeckButton - .animate() - .translationY(600f) - .setDuration(100) - .setListener( - object : Animator.AnimatorListener { - override fun onAnimationStart(animator: Animator) {} - - override fun onAnimationEnd(animator: Animator) { - if (!isFABOpen) { - addSharedButton.visibility = View.GONE - addDeckButton.visibility = View.GONE - addFilteredDeckButton.visibility = View.GONE - } - } - - override fun onAnimationCancel(animator: Animator) {} - - override fun onAnimationRepeat(animator: Animator) {} - }, - ) + + fabMain.shrink() + val hideButtons = + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + if (!isFABOpen) { + hideMenuButtons() + } + } } - } else { - // Close without animation - binding.addSharedButton.visibility = View.GONE - binding.addDeckButton.visibility = View.GONE - binding.addFilteredDeckButton.visibility = View.GONE - binding.fabMain.isExtended = false - - binding.fabMain.setIconResource(addWhiteIcon) - } + addSharedButton.animateClose(style.otherOffset, 100) + addDeckButton.animateClose(style.deckOffset, 50).setListener(hideButtons) + addFilteredDeckButton.animateClose(style.otherOffset, 100).setListener(hideButtons) } } + private fun hideMenuButtons() = + with(binding) { + addSharedButton.visibility = View.GONE + addDeckButton.visibility = View.GONE + addFilteredDeckButton.visibility = View.GONE + } + + private fun View.animateClose( + offset: Float, + duration: Long, + ) = animate() + .alpha(0f) + .translationY(offset) + .setDuration(duration) + fun showFloatingActionButton() { if (!binding.fabMain.isShown) { Timber.i("DeckPicker:: showFloatingActionButton()") @@ -265,7 +199,7 @@ class DeckPickerFloatingActionMenu( fun hideFloatingActionButton() { if (binding.fabMain.isShown) { // close the menu if it's open so the other menu items are hidden as well - closeFloatingActionMenu(false) + closeFloatingActionMenu(style = CloseAnimation.LONG) Timber.i("DeckPicker:: hideFloatingActionButton()") binding.fabMain.visibility = View.GONE } @@ -287,6 +221,14 @@ class DeckPickerFloatingActionMenu( } init { + deckPicker.lifecycle.addObserver( + object : DefaultLifecycleObserver { + override fun onStop(owner: LifecycleOwner) = resetMenuAfterAdding() + + // In multi-window mode the deck screen may remain visible behind the editor. + override fun onResume(owner: LifecycleOwner) = resetMenuAfterAdding() + }, + ) binding.fabMain.isExtended = false binding.fabMain.setOnTouchListener( object : DoubleTapListener(context) { @@ -322,7 +264,7 @@ class DeckPickerFloatingActionMenu( KeyEvent.KEYCODE_ESCAPE -> { if (isFABOpen) { Timber.d("FAB main button: ESC key pressed - closing menu") - closeFloatingActionMenu(applyRiseAndShrinkAnimation = true) + closeFloatingActionMenu(style = CloseAnimation.SHORT) return@setOnKeyListener true } } @@ -331,11 +273,21 @@ class DeckPickerFloatingActionMenu( false } - binding.fabBGLayout.setOnClickListener { closeFloatingActionMenu(applyRiseAndShrinkAnimation = true) } + // Close the FAB menu when clicking outside the menu container + binding.fabBGLayout.setOnClickListener { closeFloatingActionMenu(style = CloseAnimation.SHORT) } + + // 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(style = CloseAnimation.SHORT) + } + } + val addDeckListener = View.OnClickListener { if (isFABOpen) { - closeFloatingActionMenu(applyRiseAndShrinkAnimation = false) + closeFloatingActionMenu(style = CloseAnimation.LONG) deckPicker.showCreateDeckDialog() } } @@ -346,7 +298,7 @@ class DeckPickerFloatingActionMenu( val addDeckKeyListener = createActivationKeyListener("Add Deck button: ENTER key pressed") { if (isFABOpen) { - closeFloatingActionMenu(applyRiseAndShrinkAnimation = false) + closeFloatingActionMenu(style = CloseAnimation.LONG) deckPicker.showCreateDeckDialog() } } @@ -354,7 +306,7 @@ class DeckPickerFloatingActionMenu( val addFilteredDeckListener = View.OnClickListener { if (isFABOpen) { - closeFloatingActionMenu(applyRiseAndShrinkAnimation = false) + closeFloatingActionMenu(style = CloseAnimation.LONG) deckPicker.showCreateFilteredDeckDialog() } } @@ -364,7 +316,7 @@ class DeckPickerFloatingActionMenu( val addFilteredDeckKeyListener = createActivationKeyListener("Add Filtered Deck button: ENTER key pressed") { if (isFABOpen) { - closeFloatingActionMenu(applyRiseAndShrinkAnimation = false) + closeFloatingActionMenu(style = CloseAnimation.LONG) deckPicker.showCreateFilteredDeckDialog() } } @@ -372,7 +324,7 @@ class DeckPickerFloatingActionMenu( val addSharedListener = View.OnClickListener { if (isFABOpen) { - closeFloatingActionMenu(applyRiseAndShrinkAnimation = false) + closeFloatingActionMenu(style = CloseAnimation.LONG) Timber.d("configureFloatingActionsMenu::addSharedButton::onClickListener - Adding Shared Deck") deckPicker.openAnkiWebSharedDecks() } @@ -383,7 +335,7 @@ class DeckPickerFloatingActionMenu( val addSharedKeyListener = createActivationKeyListener("Add Shared Deck button: ENTER key pressed") { if (isFABOpen) { - closeFloatingActionMenu(applyRiseAndShrinkAnimation = false) + closeFloatingActionMenu(style = CloseAnimation.LONG) deckPicker.openAnkiWebSharedDecks() } } @@ -408,14 +360,23 @@ class DeckPickerFloatingActionMenu( } /** - * Closes the FAB menu and opens the [NoteEditorFragment] + * Opens the [NoteEditorFragment], preserving the menu during the outgoing transition. * @see DeckPicker.addNote */ private fun addNote() { - closeFloatingActionMenu(applyRiseAndShrinkAnimation = false) + if (pendingMenuReset) return + pendingMenuReset = true + // Save a closed menu state without changing its appearance during the transition. + isFABOpen = false deckPicker.addNote() } + private fun resetMenuAfterAdding() { + if (!pendingMenuReset) return + pendingMenuReset = false + closeFloatingActionMenu(style = CloseAnimation.LONG, animate = false) + } + /** * Sets the label of the 'Create deck' button from the backend translation. */ @@ -427,6 +388,18 @@ class DeckPickerFloatingActionMenu( } } + /** Animation styles for closing the FAB menu, with offsets in pixels. */ + enum class CloseAnimation( + val deckOffset: Float, + val otherOffset: Float, + ) { + /** Back or an outside tap moves the menu buttons a shorter distance. */ + SHORT(deckOffset = 300f, otherOffset = 400f), + + /** Menu actions, the navigation drawer, or hiding the FAB move the buttons farther. */ + LONG(deckOffset = 400f, otherOffset = 600f), + } + fun interface FloatingActionBarToggleListener { /** Triggered when the drawer is starting to open/close */ fun onBeginToggle(isOpening: Boolean) 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/IntentHandler.kt b/AnkiDroid/src/main/java/com/ichi2/anki/IntentHandler.kt index ca424c2f39af..8cd1ad9c4cc5 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/IntentHandler.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/IntentHandler.kt @@ -33,9 +33,11 @@ import com.ichi2.anki.exception.SystemStorageException import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.settings.Prefs import com.ichi2.anki.shareddeck.SharedDecksDownloadFragment +import com.ichi2.anki.sync.MeteredSyncPolicy import com.ichi2.anki.ui.windows.reviewer.ReviewerFragment import com.ichi2.anki.utils.MimeTypeUtils import com.ichi2.anki.worker.SyncWorker +import com.ichi2.anki.worker.UniqueWorkNames import com.ichi2.utils.FileUtil import com.ichi2.utils.ImportResult import com.ichi2.utils.ImportUtils.handleFileImport @@ -46,6 +48,9 @@ import com.ichi2.utils.NetworkUtils import com.ichi2.utils.TruncatedString import com.ichi2.utils.copyToClipboard import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.MainScope +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import timber.log.Timber import java.io.File @@ -60,6 +65,9 @@ import kotlin.math.min * @author Tim */ class IntentHandler : AbstractIntentHandler() { + // A delayed work query must not reopen DeckPicker after this activity is dismissed. + private val syncIntentScope = MainScope() + override fun onCreate(savedInstanceState: Bundle?) { // Note: This is our entry point from the launcher with intent: android.intent.action.MAIN if (showedActivityFailedScreen(savedInstanceState)) { @@ -76,7 +84,8 @@ class IntentHandler : AbstractIntentHandler() { val runIfStoragePermissions = { runnable: () -> Unit -> performActionIfStorageAccessible(reloadIntent, action) { runnable() } } val launchType = getLaunchType(intent) // TODO block the UI with some kind of ProgressDialog instead of cancelling the sync work - if (requiresCollectionAccess(launchType)) { + // A sync intent must not interrupt an existing sync; handleSyncIntent ignores duplicates. + if (launchType != LaunchType.SYNC && requiresCollectionAccess(launchType)) { if (WorkManager.isInitialized()) { SyncWorker.cancel(this) } else { @@ -118,6 +127,11 @@ class IntentHandler : AbstractIntentHandler() { } } + override fun onDestroy() { + syncIntentScope.cancel() + super.onDestroy() + } + private fun copyDebugInfoToClipboard(intent: Intent) { Timber.i("Copying debug info to clipboard") val clipboardData = @@ -210,11 +224,23 @@ class IntentHandler : AbstractIntentHandler() { action: String?, ) { Timber.i("Handling Sync Intent") - sendDoSyncMsg() - reloadIntent.action = action - reloadIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - startActivity(reloadIntent) - finish() + syncIntentScope.launch { + val existingSync = WorkManager.getInstance(this@IntentHandler).getWorkInfosForUniqueWorkFlow(UniqueWorkNames.SYNC).first() + // finish() may be called before onDestroy() cancels the query. + if (isFinishing) return@launch + val foregroundSync = CollectionManager.collectionLease?.operation?.isSync == true + if (foregroundSync || existingSync.any { !it.state.isFinished }) { + // Opening DeckPicker would also access the collection while the existing sync is using it. + Timber.w("Ignoring sync intent: collection sync is already pending or running") + finish() + return@launch + } + sendDoSyncMsg() + reloadIntent.action = action + reloadIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) + startActivity(reloadIntent) + finish() + } } private fun handleFileImport( @@ -457,17 +483,18 @@ class IntentHandler : AbstractIntentHandler() { val deckPicker = activity.requireDeckPickerOrShowError() ?: return val res = deckPicker.resources val hkey = Prefs.hkey - val millisecondsSinceLastSync = millisecondsSinceLastSync() - val limited = millisecondsSinceLastSync < INTENT_SYNC_MIN_INTERVAL + val millisecondsSinceLastSyncAttempt = millisecondsSinceLastSyncAttempt() + val limited = millisecondsSinceLastSyncAttempt < INTENT_SYNC_MIN_INTERVAL if (!limited && !hkey.isNullOrEmpty() && NetworkUtils.isOnline) { - deckPicker.sync() + startBackgroundSync(deckPicker) } else { val err = res.getString(CommonString.sync_error) if (limited) { val remainingTimeInSeconds = - max((INTENT_SYNC_MIN_INTERVAL - millisecondsSinceLastSync) / 1000, 1) + max((INTENT_SYNC_MIN_INTERVAL - millisecondsSinceLastSyncAttempt) / 1000, 1) // getQuantityString needs an int val remaining = min(Int.MAX_VALUE.toLong(), remainingTimeInSeconds).toInt() + Timber.i("Ignoring sync intent: rate limited for another %d seconds", remaining) val message = res.getQuantityString( CommonPlurals.sync_automatic_sync_needs_more_time, @@ -476,6 +503,7 @@ class IntentHandler : AbstractIntentHandler() { ) deckPicker.showSimpleNotification(err, message, NotificationChannel.SYNC) } else { + Timber.i("Ignoring sync intent: %s", if (hkey.isNullOrEmpty()) "not logged in" else "offline") deckPicker.showSimpleNotification( err, res.getString(CommonString.youre_offline), @@ -486,6 +514,23 @@ class IntentHandler : AbstractIntentHandler() { deckPicker.finish() } + private fun startBackgroundSync(deckPicker: DeckPicker) { + if (MeteredSyncPolicy.shouldBlock()) { + Timber.i("Ignoring sync intent: metered sync is disabled") + return + } + val auth = syncAuth() + if (auth == null) { + Timber.i("Ignoring sync intent: no sync credentials") + return + } + + // Keep collection and media sync running after DeckPicker finishes. + val syncMedia = shouldFetchMedia() + Timber.i("Enqueuing background sync from intent (syncMedia=%s)", syncMedia) + SyncWorker.start(deckPicker, auth, syncMedia = syncMedia) + } + override fun toMessage(): Message = emptyMessage(this.what) companion object { 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..008dd3a252a5 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,8 +200,7 @@ import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt import com.ichi2.anki.common.android.R as CommonR - -const val CALLER_KEY = "caller" +import com.ichi2.anki.noteeditor.R as NoteEditorR /** * Allows the user to edit a note, for instance if there is a typo. A card is a presentation of a note, and has two @@ -299,6 +299,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 +324,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 +352,7 @@ class NoteEditorFragment : } @VisibleForTesting - internal val multimediaController: NoteEditorMultimediaController by lazy { - NoteEditorMultimediaController(this, multimediaFragmentLauncher) - } + internal val multimediaController: NoteEditorMultimediaController = NoteEditorMultimediaController(this, multimediaFragmentLauncher) private val requestTemplateEditLauncher = registerForActivityResult( @@ -539,17 +538,17 @@ class NoteEditorFragment : fieldState.setInstanceState(savedInstanceState) val intent = requireActivity().intent if (savedInstanceState != null) { - caller = fromValue(savedInstanceState.getInt(CALLER_KEY)) + caller = fromValue(savedInstanceState.getInt(STATE_CALLER)) addNote = savedInstanceState.getBoolean("addNote") deckId = savedInstanceState.getLong("did") selectedNoteTypeId = if (savedInstanceState.containsKey("noteTypeId")) savedInstanceState.getLong("noteTypeId") else null selectedTags = savedInstanceState.getStringArrayList("tags") - reloadRequired = savedInstanceState.getBoolean(EXTRA_RELOAD_REQUIRED) + reloadRequired = savedInstanceState.getBoolean(STATE_RELOAD_REQUIRED) multimediaController.onRestoreInstanceState(savedInstanceState) toggleStickyText = savedInstanceState.getSerializableCompat>("toggleSticky")!! - changed = savedInstanceState.getBoolean(EXTRA_NOTE_CHANGED) + changed = savedInstanceState.getBoolean(STATE_NOTE_CHANGED) } else { caller = fromValue(requireArguments().getInt(EXTRA_CALLER, NoteEditorCaller.NO_CALLER.value)) if (caller == NoteEditorCaller.NO_CALLER) { @@ -615,12 +614,12 @@ class NoteEditorFragment : private fun addInstanceStateToBundle(savedInstanceState: Bundle) { Timber.i("Saving instance") - savedInstanceState.putInt(CALLER_KEY, caller.value) + savedInstanceState.putInt(STATE_CALLER, caller.value) savedInstanceState.putBoolean("addNote", addNote) savedInstanceState.putLong("did", deckId) selectedNoteTypeId?.let { savedInstanceState.putLong("noteTypeId", it) } - savedInstanceState.putBoolean(EXTRA_NOTE_CHANGED, changed) - savedInstanceState.putBoolean(EXTRA_RELOAD_REQUIRED, reloadRequired) + savedInstanceState.putBoolean(STATE_NOTE_CHANGED, changed) + savedInstanceState.putBoolean(STATE_RELOAD_REQUIRED, reloadRequired) savedInstanceState.putIntegerArrayList("customViewIds", customViewIds) multimediaController.onSaveInstanceState(savedInstanceState) savedInstanceState.putSerializable("toggleSticky", toggleStickyText) @@ -928,7 +927,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) @@ -1114,8 +1113,8 @@ class NoteEditorFragment : } else if (ACTION_CREATE_FLASHCARD == intent.action) { // mSourceLanguage = extras.getString(SOURCE_LANGUAGE); // mTargetLanguage = extras.getString(TARGET_LANGUAGE); - sourceText!![0] = extras.getString(SOURCE_TEXT) - sourceText!![1] = extras.getString(TARGET_TEXT) + sourceText!![0] = extras.getString(EXTRA_SOURCE_TEXT) + sourceText!![1] = extras.getString(EXTRA_TARGET_TEXT) } else { var first: String? var second: String? @@ -1501,15 +1500,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 +1583,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 +1597,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 +2344,7 @@ class NoteEditorFragment : } private fun updateToolbar() { - toolbar.isVisible = !shouldHideToolbar() + toolbar.isVisible = toolbarPreferences.isVisible applyBottomInset() if (!toolbar.isVisible) return toolbar.clearCustomItems() @@ -2398,7 +2390,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) @@ -2445,8 +2437,8 @@ class NoteEditorFragment : CustomToolbarButton( index = currentButtonIndex, buttonText = buttonText.ifEmpty { currentButton.buttonText }, - prefix = prefix.ifEmpty { currentButton.prefix }, - suffix = suffix.ifEmpty { currentButton.suffix }, + prefix = prefix, + suffix = suffix, ) saveToolbarButtons(toolbarButtons) @@ -2571,6 +2563,14 @@ class NoteEditorFragment : ) } editToolbarDialog.show() + + fun updateSaveButton() { + editToolbarDialog.positiveButton.isEnabled = !et.text.isNullOrBlank() || !et2.text.isNullOrBlank() + } + + et.doAfterTextChanged { updateSaveButton() } + et2.doAfterTextChanged { updateSaveButton() } + updateSaveButton() } private fun setNoteTypePosition() { @@ -2838,8 +2838,8 @@ class NoteEditorFragment : // the action is cancelled? // public static final String SOURCE_LANGUAGE = "SOURCE_LANGUAGE"; // public static final String TARGET_LANGUAGE = "TARGET_LANGUAGE"; - const val SOURCE_TEXT = "SOURCE_TEXT" - const val TARGET_TEXT = "TARGET_TEXT" + const val EXTRA_SOURCE_TEXT = "SOURCE_TEXT" + const val EXTRA_TARGET_TEXT = "TARGET_TEXT" const val EXTRA_CALLER = "CALLER" const val EXTRA_CARD_ID = "CARD_ID" const val EXTRA_CONTENTS = "CONTENTS" @@ -2852,10 +2852,13 @@ class NoteEditorFragment : const val ACTION_CREATE_FLASHCARD_SEND = "android.intent.action.SEND" const val EXTRA_NOTE_CHANGED = "noteChanged" const val EXTRA_RELOAD_REQUIRED = "reloadRequired" + private const val STATE_NOTE_CHANGED = "noteChanged" + private const val STATE_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" + private const val STATE_CALLER = "caller" // calling activity enum class NoteEditorCaller( @@ -2883,8 +2886,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 +2914,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/Reviewer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/Reviewer.kt index b09eb89aa188..ce88a57e441a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/Reviewer.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/Reviewer.kt @@ -266,7 +266,7 @@ open class Reviewer : } startLoadingCollection() registerOnForgetHandler { listOf(currentCardId!!) } - previousCardId = savedInstanceState?.getLongOrNull(KEY_PREVIOUS_CARD_ID) + previousCardId = savedInstanceState?.getLongOrNull(STATE_PREVIOUS_CARD_ID) } override fun onPause() { @@ -287,7 +287,7 @@ open class Reviewer : override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) - previousCardId?.let { outState.putLong(KEY_PREVIOUS_CARD_ID, it) } + previousCardId?.let { outState.putLong(STATE_PREVIOUS_CARD_ID, it) } } protected val flagToDisplay: Flag @@ -2143,7 +2143,7 @@ open class Reviewer : */ const val EXTRA_DECK_ID = "deckId" - private const val KEY_PREVIOUS_CARD_ID = "key_previous_card_id" + private const val STATE_PREVIOUS_CARD_ID = "key_previous_card_id" private const val REQUEST_AUDIO_PERMISSION = 0 private const val ANIMATION_DURATION = 200 diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt index 73023aa76ccc..172540ae087f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt @@ -51,7 +51,7 @@ import com.ichi2.anki.observability.ChangeManager import com.ichi2.anki.settings.Prefs import com.ichi2.anki.ui.internationalization.sentenceCase import com.ichi2.anki.utils.doOnApplyWindowInsets -import com.ichi2.anki.utils.ext.launchCollectionInLifecycleScope +import com.ichi2.anki.utils.ext.launchCollectionInViewLifecycleScope import com.ichi2.anki.utils.ext.setFragmentResultListener import com.ichi2.anki.utils.ext.showDialogFragment import com.ichi2.ui.CollectionMediaImageGetter @@ -114,7 +114,6 @@ class StudyOptionsFragment : displayedDeckId = savedInstanceState?.getLong(STATE_DISPLAYED_DECK_ID)?.takeIf { it != 0L } // Subscribe once per fragment: returning from another panel can recreate its view. ChangeManager.subscribe(this) - viewModel.flowOfState.launchCollectionInLifecycleScope(::rebuildUi) } override fun onCreateView( @@ -140,6 +139,7 @@ class StudyOptionsFragment : requireAnkiActivity().setToolbarText(title = "") } setupContentInsets(view) + viewModel.flowOfState.launchCollectionInViewLifecycleScope(::rebuildUi) refreshInterface() } @@ -356,7 +356,7 @@ class StudyOptionsFragment : fun refreshInterface() { Timber.d("Refreshing StudyOptionsFragment") - launchCatchingTask { viewModel.refreshData() } + launchCatchingViewTask { viewModel.refreshData() } } private fun rebuildUi(state: StudyOptionsState) { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsViewModel.kt index 528abc126751..d1079b4425fc 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsViewModel.kt @@ -10,6 +10,9 @@ import com.ichi2.anki.CollectionManager.withOpenColOrNull import com.ichi2.anki.libanki.Collection import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.observability.undoableOp +import com.ichi2.anki.study.DeckKind +import com.ichi2.anki.study.StudyAvailability +import com.ichi2.anki.study.currentDeckStudyState import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -69,18 +72,18 @@ class StudyOptionsViewModel : ViewModel() { */ private fun Collection.updateStateFromCollection() { val deck = decks.current() - val deckId = deck.id - val counts = sched.counts() + val study = currentDeckStudyState() + val deckId = study.deckId var buriedNew = 0 var buriedLearning = 0 var buriedReview = 0 val tree = sched.deckDueTree(deckId) if (tree != null) { - buriedNew = tree.newCount - counts.new - buriedLearning = tree.learnCount - counts.lrn - buriedReview = tree.reviewCount - counts.rev + buriedNew = tree.newCount - study.newCardsAvailable + buriedLearning = tree.learnCount - study.learningCardsAvailable + buriedReview = tree.reviewCount - study.reviewCardsAvailable } - val isDynamic = deck.isFiltered + val isDynamic = study.kind == DeckKind.Filtered val fullName = deck.getString("name") val description = if (isDynamic) { @@ -95,32 +98,31 @@ class StudyOptionsViewModel : ViewModel() { } val data = DeckStudyData( - newCardsToday = counts.new, - lrnCardsToday = counts.lrn, - revCardsToday = counts.rev, + newCardsToday = study.newCardsAvailable, + lrnCardsToday = study.learningCardsAvailable, + revCardsToday = study.reviewCardsAvailable, buriedNew = buriedNew, buriedLearning = buriedLearning, buriedReview = buriedReview, totalNewCards = sched.totalNewForCurrentDeck(), - numberOfCardsInDeck = decks.cardCount(deckId, includeSubdecks = true), + numberOfCardsInDeck = study.cardCount, ) flowOfIsFilteredDeck.value = isDynamic flowOfHaveBuried.value = sched.haveBuried() - flowOfSelectedDeckId.value = decks.selected() + flowOfSelectedDeckId.value = study.deckId // Only resolve the media directory when a description will render it: an in-memory // collection has no media folder and `media.dir` would throw. flowOfMediaDir.value = if (!description.isNullOrEmpty()) media.dir else null - val totalDue = data.newCardsToday + data.lrnCardsToday + data.revCardsToday flowOfState.value = when { - data.numberOfCardsInDeck == 0 && !isDynamic -> + study.isEmptyRegularDeck -> StudyOptionsState.Empty( deckName = fullName, data = data, ) - totalDue == 0 -> + study.availability == StudyAvailability.NoCardsAvailable -> StudyOptionsState.Congrats( deckName = fullName, isDynamic = isDynamic, diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt b/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt index bce8c357ca36..9e1f5d2e9bf7 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 @@ -88,7 +89,8 @@ fun getEndpoint(): String? { */ fun isLoggedIn(): Boolean = !Prefs.hkey.isNullOrEmpty() -fun millisecondsSinceLastSync() = TimeManager.time.intTimeMS() - Prefs.lastSyncTime +/** Elapsed milliseconds since the last manual or automatic sync attempt. */ +fun millisecondsSinceLastSyncAttempt() = TimeManager.time.intTimeMS() - Prefs.lastSyncTime fun DeckPicker.handleNewSync( conflict: ConflictResolution?, @@ -137,8 +139,6 @@ fun DeckPicker.handleNewSync( notifySubscribersAllValuesChanged(deckPicker) refreshState() } finally { - // Always update last sync time to prevent infinite retry loops - // when sync fails (e.g., collection too large). See issue #19776 setLastSyncTimeToNow() } } @@ -284,7 +284,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 +323,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) } @@ -420,6 +430,7 @@ fun DeckPicker.showSyncLogMessage( } } +/** Starts the retry delay after every sync attempt, regardless of outcome. */ fun setLastSyncTimeToNow() { Prefs.lastSyncTime = TimeManager.time.intTimeMS() } 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/account/AccountActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/account/AccountActivity.kt index a24fdf35cf23..152727598acf 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/account/AccountActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/account/AccountActivity.kt @@ -17,7 +17,7 @@ import com.ichi2.anki.isLoggedIn class AccountActivity : SingleFragmentActivity() { companion object { /** Sees if we want to go back to the DeckPicker after login*/ - const val START_FROM_DECKPICKER = "START_FOR_RESULT" + const val ARG_START_FOR_RESULT = "START_FOR_RESULT" /** * Returns an [Intent] to launch either [LoggedInFragment] or [LoginFragment] @@ -39,7 +39,7 @@ class AccountActivity : SingleFragmentActivity() { fragmentClass = if (isLoggedIn()) LoggedInFragment::class else LoginFragment::class, arguments = Bundle().apply { - putBoolean(START_FROM_DECKPICKER, forResult) + putBoolean(ARG_START_FOR_RESULT, forResult) }, ).setClass(context, AccountActivity::class.java) } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/account/LoginFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/account/LoginFragment.kt index 3965a07e2e32..ee2b33648365 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/account/LoginFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/account/LoginFragment.kt @@ -33,7 +33,7 @@ import com.ichi2.anki.CommonString import com.ichi2.anki.CrashReportData.Companion.toCrashReportData import com.ichi2.anki.DeckPicker import com.ichi2.anki.R -import com.ichi2.anki.account.AccountActivity.Companion.START_FROM_DECKPICKER +import com.ichi2.anki.account.AccountActivity.Companion.ARG_START_FOR_RESULT import com.ichi2.anki.dialogs.help.HelpDialog import com.ichi2.anki.exception.CollectionLockedException import com.ichi2.anki.getEndpoint @@ -235,7 +235,7 @@ class LoginFragment : Fragment(R.layout.fragment_my_account) { is LoginState.Success -> { Timber.i("Login Successful") val activity = requireActivity() - val isForResult = arguments?.getBoolean(START_FROM_DECKPICKER) ?: false + val isForResult = arguments?.getBoolean(ARG_START_FOR_RESULT) ?: false // If the user explicitly came from a sync prompt (onboarding/pressing sync) // then their intent was to sync after login success diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserSearchFeedback.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserSearchFeedback.kt new file mode 100644 index 000000000000..d573dc06deaf --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserSearchFeedback.kt @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.browser + +/** The feedback requested for a completed browser search. */ +enum class BrowserSearchFeedbackMode { + /** Suppress search feedback, including for operations which provide their own message. */ + NONE, + + /** Report search results, offering "Search all decks" when applicable. */ + SEARCH_RESULTS, + + /** Show the result count when it is hidden in the header. */ + COUNT_ONLY, +} + +/** The feedback to display after a browser search completes. */ +internal enum class BrowserSearchFeedback( + val offersSearchAllDecks: Boolean = false, +) { + NONE, + COUNT, + COUNT_WITH_SEARCH_ALL_DECKS(offersSearchAllDecks = true), + NO_CARDS_IN_SELECTED_DECK(offersSearchAllDecks = true), +} + +internal fun browserSearchFeedback( + feedbackMode: BrowserSearchFeedbackMode, + rowCount: Int, + allDecksSelected: Boolean, + isHeaderCountVisible: Boolean, +): BrowserSearchFeedback = + // The search facts come from the completed search; header visibility is checked when displaying it. + when (feedbackMode) { + // Ordinary opening, deck changes and sorting stay quiet (Issue 21242). + // Operations with their own feedback use this mode. + BrowserSearchFeedbackMode.NONE -> BrowserSearchFeedback.NONE + // User actions without their own message report the hidden count, without an action (Issue 22384). + BrowserSearchFeedbackMode.COUNT_ONLY -> + if (isHeaderCountVisible) BrowserSearchFeedback.NONE else BrowserSearchFeedback.COUNT + BrowserSearchFeedbackMode.SEARCH_RESULTS -> + when { + // A search within a deck also offers "Search all decks", even with a visible header (Issue 5159). + !allDecksSelected && rowCount == 0 -> BrowserSearchFeedback.NO_CARDS_IN_SELECTED_DECK + !allDecksSelected -> BrowserSearchFeedback.COUNT_WITH_SEARCH_ALL_DECKS + // Submitted searches show the count when the header hides it (Issue 3592). + isHeaderCountVisible -> BrowserSearchFeedback.NONE + else -> BrowserSearchFeedback.COUNT + } + } 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..a73890177f63 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt @@ -79,7 +79,6 @@ import com.ichi2.anki.browser.CardBrowserViewModel.ChangeMultiSelectMode.MultiSe import com.ichi2.anki.browser.CardBrowserViewModel.ChangeMultiSelectMode.SingleSelectCause import com.ichi2.anki.browser.CardBrowserViewModel.ChangeNoteTypeResponse import com.ichi2.anki.browser.CardBrowserViewModel.RowSelection -import com.ichi2.anki.browser.CardBrowserViewModel.SearchResultMessage import com.ichi2.anki.browser.CardBrowserViewModel.SearchState import com.ichi2.anki.browser.CardBrowserViewModel.SearchState.Initializing import com.ichi2.anki.browser.CardBrowserViewModel.SearchState.Searching @@ -99,17 +98,18 @@ 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 import com.ichi2.anki.common.annotations.NeedsTest import com.ichi2.anki.common.destinations.NoteEditorDestination import com.ichi2.anki.common.destinations.navigate import com.ichi2.anki.common.utils.ext.ifNotZero +import com.ichi2.anki.common.utils.ext.savedFilters 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 +326,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" }) } @@ -598,7 +598,7 @@ class CardBrowserFragment : } override fun onQueryTextSubmit(query: String): Boolean { - vm.setQuery(query, fromUserSearch = true) + vm.setQuery(query, feedbackMode = BrowserSearchFeedbackMode.SEARCH_RESULTS) legacySearchView!!.clearFocus() return true } @@ -1005,39 +1005,28 @@ class CardBrowserFragment : } fun onSearchCompleted(state: SearchState.Completed) { - // #3592: show the number of cards found the number of cards is not visible in the menu - val isMenuSubtitleVisible = legacySearchView != null && legacySearchView!!.isIconified - - fun showSnackbar( - message: String, - searchAllDecks: Boolean, - ) { - // Don't show a snackbar if the results are visible in the header. - // But do show the snackbar if an action is available - if (isMenuSubtitleVisible && !searchAllDecks) return - - showSnackbar(message, Snackbar.LENGTH_SHORT) { - if (!searchAllDecks) return@showSnackbar + val subtitle = legacySubtitle ?: requireActivity().findViewById(R.id.subtitle) + val feedback = + browserSearchFeedback( + feedbackMode = state.feedbackMode, + rowCount = state.rowCount, + allDecksSelected = state.allDecksSelected, + isHeaderCountVisible = subtitle?.isShown == true && legacySearchView?.isIconified != false, + ) + val message = + when (feedback) { + BrowserSearchFeedback.NONE -> return + BrowserSearchFeedback.COUNT, BrowserSearchFeedback.COUNT_WITH_SEARCH_ALL_DECKS -> state.formatCardCount(resources) + BrowserSearchFeedback.NO_CARDS_IN_SELECTED_DECK -> + getString(CommonString.card_browser_no_cards_in_deck, selectedDeckNameForUi) + } + showSnackbar(message, Snackbar.LENGTH_SHORT) { + if (feedback.offersSearchAllDecks) { setAction(CommonString.card_browser_search_all_decks) { activityViewModel.setSelectedDeck(SelectableDeck.AllDecks) } } } - - when (val result = state.resultMessage) { - // no message for browser open / deck change / order change: only user searches - null -> return - is SearchResultMessage.CardCount -> - showSnackbar( - message = state.formatCardCount(resources), - searchAllDecks = result.includeSearchAllDecksAction, - ) - SearchResultMessage.NoCardsInSelectedDeck -> - showSnackbar( - getString(CommonString.card_browser_no_cards_in_deck, selectedDeckNameForUi), - searchAllDecks = true, - ) - } } fun searchStateChanged(searchState: SearchState) { @@ -1154,7 +1143,7 @@ class CardBrowserFragment : launchCatchingTask { searchBar?.setText(value.toUserSpannable()) } Timber.i("relaying submitted search to activity") - activityViewModel.launchSearchForCards(value, forceRefresh = false, fromUserSearch = true) + activityViewModel.launchSearchForCards(value, forceRefresh = false, feedbackMode = BrowserSearchFeedbackMode.SEARCH_RESULTS) } fun onUserMessage(message: UserMessage) = @@ -1198,15 +1187,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 +1281,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/CardBrowserViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt index 98a3e0706b0f..37e9aa3ab1a3 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt @@ -186,17 +186,6 @@ class CardBrowserViewModel( companion object } - /** Result of a completed search, used to drive a snackbar in the UI */ - sealed interface SearchResultMessage { - /** "X cards/notes shown | Search all decks" */ - data class CardCount( - val includeSearchAllDecksAction: Boolean, - ) : SearchResultMessage - - /** "No cards in deck X" message; always paired with a "search all decks" action. */ - data object NoCardsInSelectedDeck : SearchResultMessage - } - /** text in the search box (potentially unsubmitted) */ // this does not currently bind to the value in the UI and is only used for posting val flowOfFilterQuery = MutableSharedFlow() @@ -1274,7 +1263,7 @@ class CardBrowserViewModel( fun setQuery( query: String, forceRefresh: Boolean = true, - fromUserSearch: Boolean = false, + feedbackMode: BrowserSearchFeedbackMode = BrowserSearchFeedbackMode.NONE, ) = viewModelScope.launch { val newValue = searchRequestFlow.value.copy(query = query) if (!forceRefresh && withCol { searchRequestFlow.value.toSearchString() == newValue.toSearchString() }) { @@ -1283,7 +1272,7 @@ class CardBrowserViewModel( } searchRequestFlow.value = newValue - launchSearchForCards(fromUserSearch = fromUserSearch) + launchSearchForCards(feedbackMode = feedbackMode) } /** @@ -1445,13 +1434,12 @@ class CardBrowserViewModel( /** * @param forceRefresh if `true`, perform a search even if the search query is unchanged - * @param fromUserSearch whether the user explicitly searched for something; controls whether a - * result message (snackbar) is surfaced. See [SearchState.Completed.resultMessage] + * @param feedbackMode the feedback requested for this search, resolved by [browserSearchFeedback] */ fun launchSearchForCards( searchRequest: SearchRequest, forceRefresh: Boolean, - fromUserSearch: Boolean = false, + feedbackMode: BrowserSearchFeedbackMode = BrowserSearchFeedbackMode.NONE, ) = viewModelScope.launch { Timber.d("launching search [new syntax]: '%s'", searchRequest) @@ -1463,7 +1451,7 @@ class CardBrowserViewModel( } searchRequestFlow.value = searchRequest - launchSearchForCards(fromUserSearch = fromUserSearch) + launchSearchForCards(feedbackMode = feedbackMode) } /** @@ -1472,7 +1460,7 @@ class CardBrowserViewModel( * @see com.ichi2.anki.searchForRows */ @NeedsTest("Invalid searches are handled. For instance: 'and'") - fun launchSearchForCards(fromUserSearch: Boolean = false) { + fun launchSearchForCards(feedbackMode: BrowserSearchFeedbackMode = BrowserSearchFeedbackMode.NONE) { if (!initCompleted) return viewModelScope.launch { @@ -1498,7 +1486,7 @@ class CardBrowserViewModel( } ensurePaneRowValid() if (isFragmented) flowOfNoteEditorCommand.emit(NoteEditorCommand.fromCurrentSearchState()) - flowOfSearchState.emit(SearchState.Completed.fromCurrentState(fromUserSearch)) + flowOfSearchState.emit(SearchState.Completed.fromCurrentState(feedbackMode)) selectUnvalidatedRowIds(pendingSelectionRestore) pendingSelectionRestore = emptyList() } @@ -1611,20 +1599,16 @@ class CardBrowserViewModel( /** * Builds a [SearchState.Completed] event reflecting the current ViewModel state. * - * @param fromUserSearch whether this search was triggered by the user searching for something. + * @param feedbackMode the feedback requested for this search. */ - private fun SearchState.Completed.Companion.fromCurrentState(fromUserSearch: Boolean = false): SearchState.Completed = + private fun SearchState.Completed.Companion.fromCurrentState( + feedbackMode: BrowserSearchFeedbackMode = BrowserSearchFeedbackMode.NONE, + ): SearchState.Completed = SearchState.Completed( rowCount = rowCount, cardsOrNotes = cardsOrNotes, - resultMessage = - when { - !fromUserSearch -> null - // TODO: better message if rowCount == 0 AND hasSelectedAllDecks - hasSelectedAllDecks() -> SearchResultMessage.CardCount(includeSearchAllDecksAction = false) - rowCount == 0 -> SearchResultMessage.NoCardsInSelectedDeck - else -> SearchResultMessage.CardCount(includeSearchAllDecksAction = true) - }, + feedbackMode = feedbackMode, + allDecksSelected = hasSelectedAllDecks(), ) /** Builds the post-search trailing-pane command from current ViewModel state (tablet only). */ @@ -1749,13 +1733,10 @@ class CardBrowserViewModel( data class Completed( val rowCount: Int, val cardsOrNotes: CardsOrNotes, - /** - * The message (snackbar) to surface for this search, or `null` if none should be shown. - * - * Only populated for explicit user searches; `null` for browser open, deck change and - * order/direction changes. - */ - val resultMessage: SearchResultMessage?, + /** The feedback requested by the caller for this search. */ + val feedbackMode: BrowserSearchFeedbackMode, + /** The deck scope used by this search, before any subsequent UI changes. */ + val allDecksSelected: Boolean, ) : SearchState { companion object } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/ColumnSelectionDialogFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/ColumnSelectionDialogFragment.kt index b029960412e2..8beeb9d072bd 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/ColumnSelectionDialogFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/ColumnSelectionDialogFragment.kt @@ -26,14 +26,14 @@ import timber.log.Timber class ColumnSelectionDialogFragment : DialogFragment() { private val viewModel: CardBrowserViewModel by activityViewModels() private val columnToReplace: ColumnHeading by lazy { - requireArguments().requireParcelable(SELECTED_COLUMN) + requireArguments().requireParcelable(ARG_SELECTED_COLUMN) } private var availableColumns: List = emptyList() override fun onSaveInstanceState(outState: Bundle) { with(outState) { - outState.putParcelableArrayList(AVAILABLE_COLUMNS, availableColumns.toCollection(ArrayList())) + outState.putParcelableArrayList(STATE_AVAILABLE_COLUMNS, availableColumns.toCollection(ArrayList())) super.onSaveInstanceState(this) } } @@ -87,7 +87,13 @@ class ColumnSelectionDialogFragment : DialogFragment() { if (savedInstanceState == null) { viewModel.previewColumnHeadings(viewModel.cardsOrNotes).second } else { - BundleCompat.getParcelableArrayList(savedInstanceState, AVAILABLE_COLUMNS, ColumnWithSample::class.java)!!.toList() + BundleCompat + .getParcelableArrayList( + savedInstanceState, + STATE_AVAILABLE_COLUMNS, + ColumnWithSample::class.java, + )!! + .toList() } adapter.clear() adapter.addAll(availableColumns) @@ -121,12 +127,12 @@ class ColumnSelectionDialogFragment : DialogFragment() { companion object { const val TAG = "ColumnSelectionDialog" - private const val SELECTED_COLUMN = "selected_column" - private const val AVAILABLE_COLUMNS = "availableColumns" + private const val ARG_SELECTED_COLUMN = "selected_column" + private const val STATE_AVAILABLE_COLUMNS = "availableColumns" fun newInstance(selectedColumn: ColumnHeading): ColumnSelectionDialogFragment = ColumnSelectionDialogFragment().apply { - arguments = Bundle().apply { putParcelable(SELECTED_COLUMN, selectedColumn) } + arguments = Bundle().apply { putParcelable(ARG_SELECTED_COLUMN, selectedColumn) } } } } 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/SavedSearches.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/SavedSearches.kt index bad2c71e6e1d..c6820608468e 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/SavedSearches.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/SavedSearches.kt @@ -3,25 +3,9 @@ package com.ichi2.anki.browser.search import com.ichi2.anki.CollectionManager.withCol -import com.ichi2.anki.libanki.Config +import com.ichi2.anki.common.utils.ext.savedFilters import timber.log.Timber -/** - * A named query for the Card Browser - * - * Selecting a saved search quickly allows a user to either: - * - search the given query - * - add additional terms to the query before searching - * - * @see SavedSearches - */ -data class SavedSearch( - val name: String, - val query: String, -) { - fun normalize() = SavedSearch(name = this.name, query = this.query.trim()) -} - /** * Manages saved searches (named search queries in the Card Browser) * @@ -88,20 +72,3 @@ object SavedSearches { /** Removes all saved searches from the Anki collection */ suspend fun clear() = saveToConfig(emptyList()) } - -/** - * The list of saved searches in the Anki Collection - * - * Ordering is NOT preserved in Anki Desktop. Searches are ordered based on the name and are - * case-sensitive - */ -var Config.savedFilters: List - get() = - get>("savedFilters") - .orEmpty() - .map { (key, value) -> SavedSearch(name = key, query = value) } - set(value) { - set("savedFilters", value.toMap()) - } - -fun List.toMap(): Map = associate { it.name to it.query } 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/cardviewer/ViewerCommand.kt b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/ViewerCommand.kt index 082f1f1c2826..31e47d728467 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/ViewerCommand.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/ViewerCommand.kt @@ -4,11 +4,12 @@ package com.ichi2.anki.cardviewer import android.content.SharedPreferences import android.view.KeyEvent -import com.ichi2.anki.reviewer.Binding.Companion.keyCode import com.ichi2.anki.reviewer.Binding.Companion.unicode +import com.ichi2.anki.reviewer.Binding.KeyCode import com.ichi2.anki.reviewer.Binding.ModifierKeys import com.ichi2.anki.reviewer.Binding.ModifierKeys.Companion.ctrl import com.ichi2.anki.reviewer.Binding.ModifierKeys.Companion.shift +import com.ichi2.anki.reviewer.BindingUnicodeCharacter import com.ichi2.anki.reviewer.CardSide import com.ichi2.anki.reviewer.MappableAction import com.ichi2.anki.reviewer.ReviewerBinding @@ -108,11 +109,11 @@ enum class ViewerCommand : MappableAction { keyCode(KeyEvent.KEYCODE_NUMPAD_4, CardSide.ANSWER), ) EDIT -> listOf(keyCode(KeyEvent.KEYCODE_E, CardSide.BOTH)) - MARK -> listOf(unicode('*', CardSide.BOTH)) - BURY_CARD -> listOf(unicode('-', CardSide.BOTH)) - BURY_NOTE -> listOf(unicode('=', CardSide.BOTH)) - SUSPEND_CARD -> listOf(unicode('@', CardSide.BOTH)) - SUSPEND_NOTE -> listOf(unicode('!', CardSide.BOTH)) + MARK -> listOf(unicode(BindingUnicodeCharacter('*'), CardSide.BOTH)) + BURY_CARD -> listOf(unicode(BindingUnicodeCharacter('-'), CardSide.BOTH)) + BURY_NOTE -> listOf(unicode(BindingUnicodeCharacter('='), CardSide.BOTH)) + SUSPEND_CARD -> listOf(unicode(BindingUnicodeCharacter('@'), CardSide.BOTH)) + SUSPEND_NOTE -> listOf(unicode(BindingUnicodeCharacter('!'), CardSide.BOTH)) PLAY_MEDIA -> listOf(keyCode(KeyEvent.KEYCODE_R, CardSide.BOTH), keyCode(KeyEvent.KEYCODE_F5, CardSide.BOTH)) REPLAY_VOICE -> listOf(keyCode(KeyEvent.KEYCODE_V, CardSide.BOTH)) RECORD_VOICE -> listOf(keyCode(KeyEvent.KEYCODE_V, CardSide.BOTH, shift())) @@ -192,10 +193,10 @@ enum class ViewerCommand : MappableAction { keycode: Int, side: CardSide, keys: ModifierKeys = ModifierKeys.none(), - ): ReviewerBinding = ReviewerBinding(keyCode(keycode, keys), side) + ): ReviewerBinding = ReviewerBinding(KeyCode(keycode, keys), side) private fun unicode( - c: Char, + c: BindingUnicodeCharacter, side: CardSide, ): ReviewerBinding = ReviewerBinding(unicode(c), side) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt index 7275d8faf50f..c25b02d5c60a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt @@ -118,7 +118,10 @@ class DeckPickerViewModel : if (tree == null) return@combine FlattenedDeckList.empty // TODO: use flowOfFocusedDeck once it's set on all instances - val currentDeckId = withCol { decks.current().getLong("id") } + // This flow can outlive the visible screen. Do not reopen the collection just to redraw it. + val currentDeckId = + withOpenColOrNull { decks.current().getLong("id") } + ?: return@combine FlattenedDeckList.empty Timber.i("currentDeckId: %d", currentDeckId) FlattenedDeckList( @@ -207,21 +210,17 @@ class DeckPickerViewModel : val isFilteredDeck: Boolean, ) - /** Prepares a confirmation dialog for deleting the focused deck. */ + /** Prepares a confirmation dialog for deleting the selected deck. */ fun requestDeleteDeckConfirmation() = launchCatchingIO { - val deckId = - focusedDeck ?: run { - Timber.w("no focused deck") - return@launchCatchingIO - } val request = withCol { + val deck = decks.current() DeleteDeckConfirmationRequest( - deckId = deckId, - deckName = decks.name(deckId), - totalCards = decks.cardCount(deckId, includeSubdecks = true), - isFilteredDeck = decks.isFiltered(deckId), + deckId = deck.id, + deckName = deck.name, + totalCards = decks.cardCount(deck.id, includeSubdecks = true), + isFilteredDeck = deck.isFiltered, ) } flowOfDeleteDeckConfirmation.emit(request) @@ -490,6 +489,8 @@ class DeckPickerViewModel : focusedDeck = withCol { decks.current().id } refreshUndoMenuState() + // Reopening can leave the counts and selection unchanged after a skipped display refresh. + flowOfRefreshDeckList.emit(Unit) flowOfDecksReloaded.emit(Unit) } this.loadDeckCounts = loadDeckCounts diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/BackupPromptDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/BackupPromptDialog.kt index 209fd02b2bd6..60f7490c42f0 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/BackupPromptDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/BackupPromptDialog.kt @@ -17,7 +17,7 @@ import com.ichi2.anki.common.time.TimeManager import com.ichi2.anki.compat.CompatHelper.Companion.getPackageInfoCompat import com.ichi2.anki.compat.PackageInfoFlagsCompat import com.ichi2.anki.isLoggedIn -import com.ichi2.anki.millisecondsSinceLastSync +import com.ichi2.anki.millisecondsSinceLastSyncAttempt import com.ichi2.anki.servicelayer.ScopedStorageService.collectionWillBeMadeInaccessibleAfterUninstall import com.ichi2.anki.servicelayer.ScopedStorageService.userIsPromptedToDeleteCollectionOnUninstall import com.ichi2.utils.Permissions @@ -242,8 +242,8 @@ class BackupPromptDialog private constructor( // If we are on a 'full' build, the user can always restore access to their collection. // But we want them to sync regularly as a backup if (isLoggedIn()) { - // Show dialog to sync if user hasn't synced in a while - return millisecondsSinceLastSync() >= ONE_DAY_IN_MS * 7 + // Show dialog to sync if no sync attempt has been recorded in a while + return millisecondsSinceLastSyncAttempt() >= ONE_DAY_IN_MS * 7 } // Android proposes the user deletes non-legacy locations on uninstall diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/BrowserOptionsDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/BrowserOptionsDialog.kt index 3c58d7e33298..6eddfbd595d3 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/BrowserOptionsDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/BrowserOptionsDialog.kt @@ -47,7 +47,7 @@ class BrowserOptionsDialog : AppCompatDialogFragment(R.layout.dialog_browser_opt } private val cardsOrNotes by lazy { - when (arguments?.getBoolean(CARDS_OR_NOTES_KEY)) { + when (arguments?.getBoolean(ARG_CARDS_OR_NOTES)) { true -> CardsOrNotes.CARDS false -> CardsOrNotes.NOTES null -> { @@ -59,7 +59,7 @@ class BrowserOptionsDialog : AppCompatDialogFragment(R.layout.dialog_browser_opt } private val isTruncated by lazy { - arguments?.getBoolean(IS_TRUNCATED_KEY) ?: run { + arguments?.getBoolean(ARG_IS_TRUNCATED) ?: run { Timber.w("BrowserOptionsDialog instantiated without configuration.") false } @@ -120,8 +120,8 @@ class BrowserOptionsDialog : AppCompatDialogFragment(R.layout.dialog_browser_opt } companion object { - private const val CARDS_OR_NOTES_KEY = "cardsOrNotes" - private const val IS_TRUNCATED_KEY = "isTruncated" + private const val ARG_CARDS_OR_NOTES = "cardsOrNotes" + private const val ARG_IS_TRUNCATED = "isTruncated" fun newInstance( cardsOrNotes: CardsOrNotes, @@ -131,8 +131,8 @@ class BrowserOptionsDialog : AppCompatDialogFragment(R.layout.dialog_browser_opt return BrowserOptionsDialog().apply { arguments = Bundle().apply { - putBoolean(CARDS_OR_NOTES_KEY, cardsOrNotes == CardsOrNotes.CARDS) - putBoolean(IS_TRUNCATED_KEY, isTruncated) + putBoolean(ARG_CARDS_OR_NOTES, cardsOrNotes == CardsOrNotes.CARDS) + putBoolean(ARG_IS_TRUNCATED, isTruncated) } } } 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..09d9f38462c8 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 @@ -113,7 +113,7 @@ class DeckSelectionDialog : AnalyticsDialogFragment() { } private fun getDeckNames(): ArrayList = - BundleCompat.getParcelableArrayList(requireArguments(), DECK_NAMES, SelectableDeck::class.java)!! + BundleCompat.getParcelableArrayList(requireArguments(), ARG_DECK_NAMES, SelectableDeck::class.java)!! private fun setupMenu() { val toolbar: Toolbar = binding.toolbar @@ -187,7 +187,7 @@ class DeckSelectionDialog : AnalyticsDialogFragment() { parentFragmentManager.setFragmentResult( requestKey, Bundle().apply { - putParcelable(ARG_SELECTED_DECK, deck) + putParcelable(RESULT_SELECTED_DECK, deck) }, ) } @@ -414,14 +414,14 @@ 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" const val ARG_SKIP_EMPTY_DEFAULT = "arg_skip_empty_default" private const val ARG_TITLE = "arg_title" private const val ARG_TEMPLATE_EDITOR_MESSAGE = "arg_template_editor_message" - private const val DECK_NAMES = "deckNames" + private const val ARG_DECK_NAMES = "deckNames" private const val ARG_ALLOW_MULTIPLE_SELECTION = "arg_allow_multiple_selection" /** Creates a new instance of [DeckSelectionDialog]. */ @@ -440,7 +440,7 @@ class DeckSelectionDialog : AnalyticsDialogFragment() { Bundle().apply { putString(ARG_TEMPLATE_EDITOR_MESSAGE, templateEditorMessage) putString(ARG_TITLE, title) - putParcelableArrayList(DECK_NAMES, ArrayList(decks)) + putParcelableArrayList(ARG_DECK_NAMES, ArrayList(decks)) putString(ARG_REQUEST_KEY, requestKey) putBoolean(ARG_ALLOW_MULTIPLE_SELECTION, allowMultipleSelection) putBoolean(ARG_ALLOW_ALL, allowAll) @@ -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/FieldSelectionDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/FieldSelectionDialog.kt index b68c78ac1c46..9718f0dde87f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/FieldSelectionDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/FieldSelectionDialog.kt @@ -36,7 +36,7 @@ class FieldSelectionDialog : DialogFragment() { } val resultType: ResultType by lazy { - requireArguments().requireParcelable(RESULT_TYPE) + requireArguments().requireParcelable(ARG_RESULT_TYPE) } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog = @@ -73,6 +73,7 @@ class FieldSelectionDialog : DialogFragment() { * Default value: `""` */ private const val RESULT_TYPE = "resultType" + private const val ARG_RESULT_TYPE = "resultType" /** * @param resultType optional result string returned in the Fragment Result API bundle under @@ -88,7 +89,7 @@ class FieldSelectionDialog : DialogFragment() { val bundle = Bundle().apply { - putParcelable(RESULT_TYPE, resultType) + putParcelable(ARG_RESULT_TYPE, resultType) putStringArrayList(ARG_FIELD_NAMES, ArrayList(fieldNames)) } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ImportDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ImportDialog.kt index 1eed98a0ecb7..e3dd8a3f288c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ImportDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ImportDialog.kt @@ -22,10 +22,10 @@ class ImportDialog : AsyncDialogFragment() { private val importViewModel: ImportViewModel by activityViewModels() private val dialogType: Type - get() = Type.fromCode(requireArguments().getInt(IMPORT_DIALOG_TYPE_KEY)) + get() = Type.fromCode(requireArguments().getInt(ARG_DIALOG_TYPE)) private val packagePath: String - get() = requireArguments().getString(IMPORT_DIALOG_PACKAGE_PATH_KEY)!! + get() = requireArguments().getString(ARG_PACKAGE_PATH)!! override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog { val dialog = AlertDialog.Builder(requireActivity()) @@ -90,8 +90,8 @@ class ImportDialog : AsyncDialogFragment() { } companion object { - const val IMPORT_DIALOG_TYPE_KEY = "dialogType" - const val IMPORT_DIALOG_PACKAGE_PATH_KEY = "packagePath" + const val ARG_DIALOG_TYPE = "dialogType" + const val ARG_PACKAGE_PATH = "packagePath" /** * A set of dialogs which deal with importing a file @@ -107,8 +107,8 @@ class ImportDialog : AsyncDialogFragment() { ImportDialog().apply { arguments = Bundle().apply { - putInt(IMPORT_DIALOG_TYPE_KEY, dialogType.code) - putString(IMPORT_DIALOG_PACKAGE_PATH_KEY, packagePath) + putInt(ARG_DIALOG_TYPE, dialogType.code) + putString(ARG_PACKAGE_PATH, packagePath) } } 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..8aee18496acd 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt @@ -35,11 +35,11 @@ import com.ichi2.anki.R import com.ichi2.anki.databinding.DialogGenericRecyclerViewBinding import com.ichi2.anki.databinding.DialogInsertFieldBinding import com.ichi2.anki.databinding.ItemInsertSpecialFieldBinding -import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.Companion.KEY_FIELD_ITEMS -import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.Companion.KEY_INSERT_FIELD_METADATA -import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.Companion.KEY_REQUEST_KEY +import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.Companion.ARG_FIELD_NAMES +import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.Companion.ARG_INSERT_FIELD_METADATA +import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.Companion.ARG_REQUEST_KEY import com.ichi2.anki.dialogs.InsertFieldDialogViewModel.Tab -import com.ichi2.anki.launchCatchingTask +import com.ichi2.anki.launchCatchingViewTask import com.ichi2.anki.model.SpecialField import com.ichi2.anki.model.SpecialFields import com.ichi2.anki.utils.ext.requireString @@ -56,7 +56,7 @@ import org.jetbrains.annotations.VisibleForTesting class InsertFieldDialog : DialogFragment(R.layout.dialog_insert_field) { private val viewModel by viewModels() private val requestKey: String by lazy { - requireArguments().requireString(KEY_REQUEST_KEY) + requireArguments().requireString(ARG_REQUEST_KEY) } override fun onCreate(savedInstanceState: Bundle?) { @@ -102,12 +102,12 @@ class InsertFieldDialog : DialogFragment(R.layout.dialog_insert_field) { ) // setup flows - launchCatchingTask { + launchCatchingViewTask { viewModel.selectedFieldFlow.collect { 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] @@ -149,9 +149,9 @@ class InsertFieldDialog : DialogFragment(R.layout.dialog_insert_field) { InsertFieldDialog().apply { arguments = Bundle().apply { - putStringArrayList(KEY_FIELD_ITEMS, ArrayList(fieldItems)) - putParcelable(KEY_INSERT_FIELD_METADATA, metadata) - putString(KEY_REQUEST_KEY, requestKey) + putStringArrayList(ARG_FIELD_NAMES, ArrayList(fieldItems)) + putParcelable(ARG_INSERT_FIELD_METADATA, metadata) + putString(ARG_REQUEST_KEY, requestKey) } } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModel.kt index 2279977c85fd..fbc73ca6c1bd 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialogViewModel.kt @@ -36,14 +36,14 @@ class InsertFieldDialogViewModel( var currentTab by currentTabFlow.asVar() /** The field names of the note type */ - val fieldNames = savedStateHandle.require>(KEY_FIELD_ITEMS).map(::FieldName) + val fieldNames = savedStateHandle.require>(ARG_FIELD_NAMES).map(::FieldName) /** * State of the selected card when the screen was opened * * Used for providing [special fields][SpecialFields] with the output they'd produce. */ - val metadata = savedStateHandle.require(KEY_INSERT_FIELD_METADATA) + val metadata = savedStateHandle.require(ARG_INSERT_FIELD_METADATA) val selectedFieldFlow = MutableStateFlow(null) @@ -112,9 +112,9 @@ class InsertFieldDialogViewModel( } companion object { - const val KEY_FIELD_ITEMS = "key_field_items" - const val KEY_INSERT_FIELD_METADATA = "key_field_options" - const val KEY_REQUEST_KEY = "key_request_key" + const val ARG_FIELD_NAMES = "key_field_items" + const val ARG_INSERT_FIELD_METADATA = "key_field_options" + const val ARG_REQUEST_KEY = "key_request_key" const val STATE_TAB = "state_tab" } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/LoadingDialogFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/LoadingDialogFragment.kt index 1de223be8fcf..8d7c317e4a4e 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/LoadingDialogFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/LoadingDialogFragment.kt @@ -23,10 +23,10 @@ import com.ichi2.utils.customView class LoadingDialogFragment : DialogFragment() { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val dialogView = layoutInflater.inflate(R.layout.fragment_loading, null) - val canBeCancelled = arguments?.getBoolean(KEY_CANCELLABLE) ?: true + val canBeCancelled = arguments?.getBoolean(ARG_CANCELLABLE) ?: true isCancelable = canBeCancelled dialogView.findViewById(R.id.text).text = - arguments?.getString(KEY_MESSAGE) ?: getString(CommonString.dialog_processing) + arguments?.getString(ARG_MESSAGE) ?: getString(CommonString.dialog_processing) return AlertDialog .Builder(requireContext()) .create { @@ -37,8 +37,8 @@ class LoadingDialogFragment : DialogFragment() { companion object { const val TAG = "LoadingDialogFragment" - private const val KEY_MESSAGE = "key_message" - private const val KEY_CANCELLABLE = "key_cancellable" + private const val ARG_MESSAGE = "key_message" + private const val ARG_CANCELLABLE = "key_cancellable" /** * Creates an instance of [LoadingDialogFragment]. @@ -53,8 +53,8 @@ class LoadingDialogFragment : DialogFragment() { ) = LoadingDialogFragment().apply { arguments = Bundle().apply { - putString(KEY_MESSAGE, message) - putBoolean(KEY_CANCELLABLE, cancellable) + putString(ARG_MESSAGE, message) + putBoolean(ARG_CANCELLABLE, cancellable) } isCancelable = cancellable } 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/NoteTypeFieldEditorContextMenu.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/NoteTypeFieldEditorContextMenu.kt index d52a35009ba7..023f4fa3746b 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/NoteTypeFieldEditorContextMenu.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/NoteTypeFieldEditorContextMenu.kt @@ -23,7 +23,7 @@ class NoteTypeFieldEditorContextMenu : AnalyticsDialogFragment() { val availableItems = NoteTypeFieldEditorContextMenuAction.entries.sortedBy { it.order } return AlertDialog.Builder(requireActivity()).create { - setTitle(requireArguments().getString(KEY_LABEL)) + setTitle(requireArguments().getString(ARG_LABEL)) setItems(availableItems.map { resources.getString(it.actionTextId) }.toTypedArray()) { _, index -> (activity as? NoteTypeFieldEditor)?.run { handleAction(availableItems[index]) } ?: Timber.e("ContextMenu used from outside of its target activity!") @@ -44,11 +44,11 @@ class NoteTypeFieldEditorContextMenu : AnalyticsDialogFragment() { companion object { @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) - const val KEY_LABEL = "key_label" + const val ARG_LABEL = "key_label" fun newInstance(label: String): NoteTypeFieldEditorContextMenu = NoteTypeFieldEditorContextMenu().apply { - arguments = Bundle().apply { putString(KEY_LABEL, label) } + arguments = Bundle().apply { putString(ARG_LABEL, label) } } } } 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/SchedulerUpgradeDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/SchedulerUpgradeDialog.kt index 69761a278b69..2264b7a1141a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/SchedulerUpgradeDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/SchedulerUpgradeDialog.kt @@ -3,9 +3,10 @@ package com.ichi2.anki.dialogs +import android.os.Bundle import android.view.KeyEvent import androidx.appcompat.app.AlertDialog -import androidx.appcompat.app.AppCompatActivity +import androidx.fragment.app.DialogFragment import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CommonString @@ -19,62 +20,60 @@ import com.ichi2.anki.utils.openUrl * This dialog is intended to be shown when the collection is using an outdated scheduler (v1), * and prevents interaction with the app until the upgrade is acknowledged or canceled. * - * @param activity The host activity used to show the dialog. - * @param onUpgrade Callback invoked when the user confirms the upgrade (via the positive button). - * @param onCancel Callback invoked when the dialog is canceled (via negative button or back press). + * Reports the decision to the current activity without retaining an activity-owned callback. */ -class SchedulerUpgradeDialog( - private val activity: AppCompatActivity, - private val onUpgrade: () -> Unit, - private val onCancel: (() -> Unit)? = null, -) { - fun showDialog() { - val dialog = - MaterialAlertDialogBuilder(activity) - .setTitle(CommonString.scheduler_upgrade_required_dialog_title) - .setMessage(TR.schedulingUpdateRequired()) - .setPositiveButton(CommonString.dialog_ok, null) - .setNegativeButton(CommonString.dialog_cancel, null) - .apply { - if (AdaptionUtil.hasWebBrowser(activity)) { - setNeutralButton(TR.schedulingUpdateMoreInfoButton(), null) - } - }.create() +class SchedulerUpgradeDialog : DialogFragment() { + override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog { + isCancelable = false + return MaterialAlertDialogBuilder(requireContext()) + .setTitle(CommonString.scheduler_upgrade_required_dialog_title) + .setMessage(TR.schedulingUpdateRequired()) + .setPositiveButton(CommonString.dialog_ok, null) + .setNegativeButton(CommonString.dialog_cancel, null) + .apply { + if (AdaptionUtil.hasWebBrowser(requireActivity())) { + setNeutralButton(TR.schedulingUpdateMoreInfoButton(), null) + } + }.create() + } - dialog.setCancelable(false) + override fun onStart() { + super.onStart() + val dialog = requireDialog() as AlertDialog dialog.setCanceledOnTouchOutside(false) - - fun dismissAndCancel() { - dialog.dismiss() - onCancel?.invoke() + dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener { + it.isEnabled = false // prevent multiple taps + reportDecision(upgrade = true) } - dialog.setOnShowListener { - dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener { - it.isEnabled = false // prevent multiple taps - onUpgrade.invoke() - dialog.dismiss() - } + dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.setOnClickListener { + reportDecision(upgrade = false) + } - dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.setOnClickListener { - dismissAndCancel() - } + dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.setOnClickListener { + requireActivity().openUrl(R.string.link_scheduler_upgrade_faq) + } - dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.setOnClickListener { - activity.openUrl(R.string.link_scheduler_upgrade_faq) + dialog.setOnKeyListener { _, keyCode, event -> + if (keyCode != KeyEvent.KEYCODE_BACK || event.action != KeyEvent.ACTION_UP) { + return@setOnKeyListener false } - dialog.setOnKeyListener { _, keyCode, event -> - if (keyCode != KeyEvent.KEYCODE_BACK || event.action != KeyEvent.ACTION_UP) { - return@setOnKeyListener false - } - - dismissAndCancel() - true - } + reportDecision(upgrade = false) + true } + } + + private fun reportDecision(upgrade: Boolean) { + val manager = parentFragmentManager + dismiss() + manager.setFragmentResult(REQUEST_SCHEDULER_UPGRADE, Bundle().apply { putBoolean(RESULT_UPGRADE, upgrade) }) + } - dialog.show() + companion object { + const val TAG = "schedulerUpgrade" + const val REQUEST_SCHEDULER_UPGRADE = "request_scheduler_upgrade" + const val RESULT_UPGRADE = "upgrade" } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/SyncErrorDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/SyncErrorDialog.kt index 64af9a440abc..341891a3e3e4 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/SyncErrorDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/SyncErrorDialog.kt @@ -61,7 +61,7 @@ class SyncErrorDialog : AsyncDialogFragment() { /** The type of the sync error dialog*/ private val dialogType: Type - get() = Type.fromCode(requireArguments().getInt(SYNC_ERROR_DIALOG_TYPE_KEY)) + get() = Type.fromCode(requireArguments().getInt(ARG_DIALOG_TYPE)) override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val dialog = @@ -254,7 +254,7 @@ class SyncErrorDialog : AsyncDialogFragment() { CommonString.sync_conflict_remote_confirm_new, ) DIALOG_SYNC_CORRUPT_COLLECTION -> { - val syncMessage = requireArguments().getString(DIALOG_MESSAGE_KEY) + val syncMessage = requireArguments().getString(ARG_DIALOG_MESSAGE) val repairUrl = res().getString(R.string.repair_deck) val dialogMessage = res().getString(CommonString.sync_corrupt_database, repairUrl) joinSyncMessages(dialogMessage, syncMessage) @@ -262,10 +262,10 @@ class SyncErrorDialog : AsyncDialogFragment() { DIALOG_SYNC_CLOCK_OFF -> res().getString( CommonString.sync_clock_off_with_current_time, - requireArguments().getString(DIALOG_MESSAGE_KEY).orEmpty(), + requireArguments().getString(ARG_DIALOG_MESSAGE).orEmpty(), DateFormat.getDateTimeInstance().format(TimeManager.time.currentDate), ) - else -> requireArguments().getString(DIALOG_MESSAGE_KEY) + else -> requireArguments().getString(ARG_DIALOG_MESSAGE) } private fun joinSyncMessages( @@ -302,7 +302,7 @@ class SyncErrorDialog : AsyncDialogFragment() { override val dialogHandlerMessage: SyncErrorDialogMessageHandler get() { - val dialogMessage = requireArguments().getString(DIALOG_MESSAGE_KEY) + val dialogMessage = requireArguments().getString(ARG_DIALOG_MESSAGE) return SyncErrorDialogMessageHandler(dialogType, dialogMessage) } @@ -341,12 +341,12 @@ class SyncErrorDialog : AsyncDialogFragment() { /** * Key for the ordinal of the sync error in Type */ - const val SYNC_ERROR_DIALOG_TYPE_KEY = "dialogType" + const val ARG_DIALOG_TYPE = "dialogType" /** * Key for the message to display in the dialog */ - const val DIALOG_MESSAGE_KEY = "dialogMessage" + const val ARG_DIALOG_MESSAGE = "dialogMessage" /** * A set of dialogs belonging to AnkiActivity which deal with sync problems @@ -361,8 +361,8 @@ class SyncErrorDialog : AsyncDialogFragment() { ) = SyncErrorDialog().apply { arguments = Bundle().apply { - putInt(SYNC_ERROR_DIALOG_TYPE_KEY, dialogType.code) - putString(DIALOG_MESSAGE_KEY, dialogMessage) + putInt(ARG_DIALOG_TYPE, dialogType.code) + putString(ARG_DIALOG_MESSAGE, dialogMessage) } } } @@ -383,15 +383,15 @@ class SyncErrorDialog : AsyncDialogFragment() { what = this@SyncErrorDialogMessageHandler.what data = Bundle().apply { - putInt(SYNC_ERROR_DIALOG_TYPE_KEY, dialogType.code) - putString(DIALOG_MESSAGE_KEY, dialogMessage) + putInt(ARG_DIALOG_TYPE, dialogType.code) + putString(ARG_DIALOG_MESSAGE, dialogMessage) } } companion object { fun fromMessage(message: Message): SyncErrorDialogMessageHandler { - val dialogType = Type.fromCode(message.data.getInt(SYNC_ERROR_DIALOG_TYPE_KEY)) - val dialogMessage = message.data.getString(DIALOG_MESSAGE_KEY) + val dialogType = Type.fromCode(message.data.getInt(ARG_DIALOG_TYPE)) + val dialogMessage = message.data.getString(ARG_DIALOG_MESSAGE) return SyncErrorDialogMessageHandler(dialogType, dialogMessage) } } 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..357c6158e7dc 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 } } } @@ -820,7 +823,7 @@ class CustomStudyDialog : AnalyticsDialogFragment() { CustomStudyDialog().apply { arguments = Bundle().apply { - putLong(CustomStudyViewModel.KEY_DID, deckId) + putLong(CustomStudyViewModel.ARG_DECK_ID, deckId) } } @@ -837,7 +840,7 @@ class CustomStudyDialog : AnalyticsDialogFragment() { CustomStudyDialog().apply { arguments = Bundle().apply { - putLong(CustomStudyViewModel.KEY_DID, deckId) + putLong(CustomStudyViewModel.ARG_DECK_ID, deckId) putInt(ARG_SUB_DIALOG_ID, contextMenuAttribute.ordinal) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/CustomStudyViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/CustomStudyViewModel.kt index 4e84e06cad03..b09e7d003b95 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/CustomStudyViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/CustomStudyViewModel.kt @@ -29,7 +29,7 @@ class CustomStudyViewModel( /** Required [DeckId] of the [Deck] for which the custom study session is being built. */ val deckId: DeckId - get() = savedStateHandle.get(KEY_DID) ?: error("Deck id was not provided!") + get() = savedStateHandle.get(ARG_DECK_ID) ?: error("Deck id was not provided!") /* * Translates the user's selection into a specific study type. @@ -48,7 +48,7 @@ class CustomStudyViewModel( /** * Required key for a [DeckId] which [CustomStudyDialog] expects to receive as an argument. */ - const val KEY_DID = "key_did" + const val ARG_DECK_ID = "key_did" private const val KEY_CARDS_SELECTION_INDEX = "key_cards_selection_index" } } 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/dialogs/tags/TagsList.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsList.kt index 07c928de51d4..cc9787ef021c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsList.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsList.kt @@ -55,17 +55,23 @@ class TagsList( indeterminateTags = TreeSet(java.lang.String.CASE_INSENSITIVE_ORDER) if (uncheckedTags != null) { this.allTags.addAll(uncheckedTags) - // intersection between mCheckedTags and uncheckedTags - indeterminateTags.addAll(this.checkedTags) - val uncheckedSet: MutableSet = TreeSet(java.lang.String.CASE_INSENSITIVE_ORDER) - uncheckedSet.addAll(uncheckedTags) - indeterminateTags.retainAll(uncheckedSet) + for (tag in uncheckedTags) { + val checkedTag = findCheckedTagIgnoringCase(tag) ?: continue + indeterminateTags.add(checkedTag) + } this.checkedTags.removeAll(indeterminateTags) } partiallySelectedTags.addAll(indeterminateTags) prepareTagHierarchy() } + /** Returns the matching checked tag with its stored spelling, or `null` if absent. */ + private fun findCheckedTagIgnoringCase(tag: String): String? { + // O(log n); returns "Biology" for a "biology" search or vice versa + val candidate = checkedTags.ceiling(tag) ?: return null + return candidate.takeIf { it.equals(tag, ignoreCase = true) } + } + /** * Return true if a tag is checked given its index in the list * diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/exception/SanitizedReportCollector.kt b/AnkiDroid/src/main/java/com/ichi2/anki/exception/SanitizedReportCollector.kt new file mode 100644 index 000000000000..b79e7dab0087 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/exception/SanitizedReportCollector.kt @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.exception + +import android.content.Context +import org.acra.ReportField +import org.acra.builder.ReportBuilder +import org.acra.collector.Collector +import org.acra.config.CoreConfiguration +import org.acra.data.CrashReportData + +/** + * Filters logcat from sanitized reports due to PII: private messages may have been logged before + * the uncaught exception handler ran. ACRA saves the report after all collectors have finished. + */ +class SanitizedReportCollector : Collector { + // LogCatCollector runs FIRST; filter its output after collection, including in parallel mode. + override val order = Collector.Order.LAST + + override fun collect( + context: Context, + config: CoreConfiguration, + reportBuilder: ReportBuilder, + crashReportData: CrashReportData, + ) { + if (reportBuilder.exception is SanitizedThrowable && crashReportData.containsKey(ReportField.LOGCAT)) { + crashReportData.put( + ReportField.LOGCAT, + "Logcat filtered because it may contain personally identifiable information (PII).", + ) + } + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/exception/SanitizedThrowable.kt b/AnkiDroid/src/main/java/com/ichi2/anki/exception/SanitizedThrowable.kt new file mode 100644 index 000000000000..2e0b58630838 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/exception/SanitizedThrowable.kt @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.exception + +import com.ichi2.utils.ExceptionUtil.causesAndSuppressed +import java.util.IdentityHashMap + +/** + * Wraps an exception, stripping PII. + */ +internal class SanitizedThrowable private constructor( + original: Throwable, +) : Throwable(original.javaClass.name) { + init { + stackTrace = original.stackTrace + } + + // ACRA prints the throwable, so retain the original class name in its stack trace. + override fun toString(): String = message!! + + companion object { + /** Copy causes and suppressed exceptions, breaking cycles for downstream crash reporters. */ + fun from(throwable: Throwable): SanitizedThrowable { + val copies = IdentityHashMap() + // Build from leaves to root, linking only to completed copies to break cycles. + for (original in throwable.causesAndSuppressed().toList().asReversed()) { + val copy = SanitizedThrowable(original) + copy.initCause(copies[original.cause]) + original.suppressed.mapNotNull { copies[it] }.forEach(copy::addSuppressed) + copies[original] = copy + } + return copies.getValue(throwable) + } + } +} 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/model/SortType.kt b/AnkiDroid/src/main/java/com/ichi2/anki/model/SortType.kt index 9a6f682fff36..bf864be3e6a8 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/model/SortType.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/model/SortType.kt @@ -9,6 +9,10 @@ import com.ichi2.anki.CardBrowser import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.R import com.ichi2.anki.browser.BrowserColumnKey +import com.ichi2.anki.common.utils.ext.browserSortCardsBackwards +import com.ichi2.anki.common.utils.ext.browserSortCardsColumn +import com.ichi2.anki.common.utils.ext.browserSortNotesBackwards +import com.ichi2.anki.common.utils.ext.browserSortNotesColumn import com.ichi2.anki.libanki.BrowserConfig import com.ichi2.anki.libanki.SortOrder import com.ichi2.anki.model.CardsOrNotes.NOTES @@ -50,13 +54,18 @@ sealed class SortType : Parcelable { when (this) { is NoOrdering -> Prefs.cardBrowserNoSorting = true is CollectionOrdering -> { - val isNotesMode = cardsOrNotes == NOTES - - val sortKey = BrowserConfig.sortColumnKey(isNotesMode) - val reverseKey = BrowserConfig.sortBackwardsKey(isNotesMode) - - withCol { config.set(sortKey, this@SortType.key.value) } - withCol { config.set(reverseKey, this@SortType.reverse) } + withCol { + when (cardsOrNotes) { + CardsOrNotes.CARDS -> { + config.browserSortCardsColumn = this@SortType.key.value + config.browserSortCardsBackwards = this@SortType.reverse + } + NOTES -> { + config.browserSortNotesColumn = this@SortType.key.value + config.browserSortNotesBackwards = this@SortType.reverse + } + } + } Prefs.cardBrowserNoSorting = false } @@ -103,16 +112,12 @@ var PrefsRepository.cardBrowserNoSorting: Boolean putBoolean(R.string.pref_browser_no_sorting, value) } -private suspend fun getBrowserColumnKey(cardsOrNotes: CardsOrNotes): String { - val isNotesMode = cardsOrNotes == NOTES - val sortKey = BrowserConfig.sortColumnKey(isNotesMode) - - return withCol { config.get(sortKey) } ?: "noteFld" -} - -private suspend fun getSortBackwards(cardsOrNotes: CardsOrNotes): Boolean { - val isNotesMode = cardsOrNotes == NOTES - val sortBackwardsKey = BrowserConfig.sortBackwardsKey(isNotesMode) +private suspend fun getBrowserColumnKey(cardsOrNotes: CardsOrNotes): String = + withCol { + if (cardsOrNotes == NOTES) config.browserSortNotesColumn else config.browserSortCardsColumn + } - return withCol { config.get(sortBackwardsKey) } ?: false -} +private suspend fun getSortBackwards(cardsOrNotes: CardsOrNotes): Boolean = + withCol { + if (cardsOrNotes == NOTES) config.browserSortNotesBackwards else config.browserSortCardsBackwards + } 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/navigation/AnkiDroidNavigator.kt b/AnkiDroid/src/main/java/com/ichi2/anki/navigation/AnkiDroidNavigator.kt index 465edb896116..86ac926f4572 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/navigation/AnkiDroidNavigator.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/navigation/AnkiDroidNavigator.kt @@ -18,10 +18,12 @@ import com.ichi2.anki.common.destinations.PreferencesDestination import com.ichi2.anki.common.destinations.ReviewDeckDestination import com.ichi2.anki.common.destinations.StatisticsDestination import com.ichi2.anki.common.destinations.StudyOptionsDestination +import com.ichi2.anki.common.destinations.WidgetConfigDestination import com.ichi2.anki.noteeditor.toIntent import com.ichi2.anki.pages.toIntent import com.ichi2.anki.preferences.toIntent import com.ichi2.anki.toIntent +import com.ichi2.widget.toIntent /** AnkiDroid's [Navigator] implementation. */ object AnkiDroidNavigator : Navigator { @@ -44,6 +46,7 @@ object AnkiDroidNavigator : Navigator { is ReviewDeckDestination -> destination.toIntent(navContext) is StatisticsDestination -> destination.toIntent(navContext) is StudyOptionsDestination -> destination.toIntent(navContext) + is WidgetConfigDestination -> destination.toIntent(navContext) } } 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..7e4f5db6167a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNotetypes.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNotetypes.kt @@ -8,7 +8,6 @@ import android.content.Intent import android.os.Bundle import android.view.Menu import android.view.MenuItem -import android.view.View import android.view.ViewGroup import androidx.activity.OnBackPressedCallback import androidx.activity.enableEdgeToEdge @@ -20,20 +19,19 @@ import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AlertDialog import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.Toolbar -import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat.Type.displayCutout import androidx.core.view.WindowInsetsCompat.Type.ime import androidx.core.view.WindowInsetsCompat.Type.systemBars import androidx.core.view.isVisible +import androidx.core.view.marginBottom import androidx.core.view.updateMargins import androidx.core.view.updateMarginsRelative import androidx.core.view.updatePadding import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle -import com.google.android.material.behavior.HideViewOnScrollBehavior import com.ichi2.anki.AnkiActivity import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CommonPlurals @@ -41,10 +39,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,13 +104,12 @@ 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) launchCatchingTask { addNewNotesType.showAddNewNotetypeDialog() } } - val params = (layoutParams as? CoordinatorLayout.LayoutParams) - (params?.behavior as? HideViewOnScrollBehavior)?.setViewEdge(HideViewOnScrollBehavior.EDGE_BOTTOM) } binding.btnClearSelection.setOnClickListener { viewModel.clearSelection() } @@ -169,15 +165,23 @@ class ManageNotetypes : AnkiActivity(R.layout.activity_manage_note_types) { } private fun applyInsets() { + // Material 3 supplies the FAB size before layout. + val fabSize = binding.floatingActionButton.customSize + val fabBottomMargin = binding.floatingActionButton.marginBottom ViewCompat.setOnApplyWindowInsetsListener(binding.rootLayout) { _, insets -> val constraints = insets.getInsets(systemBars() or displayCutout() or ime()) Timber.d("Applying insets: $constraints") binding.appBarLayout.updatePadding(left = constraints.left, top = constraints.top, right = constraints.right) - binding.noteTypesList.updatePadding(left = constraints.left, right = constraints.right, bottom = constraints.bottom) + binding.noteTypesList.updatePadding( + left = constraints.left, + right = constraints.right, + // FAB size + its layout margin + a 16dp gap. + bottom = constraints.bottom + fabSize + fabBottomMargin + 16.dp.toPx(this), + ) val fabLayoutParams = binding.floatingActionButton.layoutParams as ViewGroup.MarginLayoutParams // also applies the 32dp bottom/end margin to not sit right on top of navigation bar fabLayoutParams.updateMarginsRelative( - bottom = constraints.bottom + 32.dp.toPx(this), + bottom = constraints.bottom + fabBottomMargin, end = constraints.right + 32.dp.toPx(this), ) // needed otherwise the updated relative margins are not seen @@ -205,11 +209,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/pages/AnkiPackageImporterFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/pages/AnkiPackageImporterFragment.kt index 470f8cec5825..56bd6f6077fe 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/pages/AnkiPackageImporterFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/pages/AnkiPackageImporterFragment.kt @@ -19,7 +19,7 @@ import com.ichi2.utils.WebViewVersion class AnkiPackageImporterFragment : PageFragment() { override val pagePath: String by lazy { - val filePath = requireArguments().getString(KEY_FILE_PATH) + val filePath = requireArguments().getString(ARG_FILE_PATH) "import-anki-package$filePath" } @@ -85,13 +85,13 @@ class AnkiPackageImporterFragment : PageFragment() { } companion object { - private const val KEY_FILE_PATH = "filePath" + private const val ARG_FILE_PATH = "filePath" fun getIntent( context: Context, filePath: String, ): Intent { - val arguments = Bundle().apply { putString(KEY_FILE_PATH, filePath) } + val arguments = Bundle().apply { putString(ARG_FILE_PATH, filePath) } return SingleFragmentActivity.getIntent(context, AnkiPackageImporterFragment::class, arguments) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/pages/CardInfoDestination.kt b/AnkiDroid/src/main/java/com/ichi2/anki/pages/CardInfoDestination.kt index 9a465e6c4244..c58b5f626ba2 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/pages/CardInfoDestination.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/pages/CardInfoDestination.kt @@ -17,8 +17,8 @@ fun CardInfoDestination.toIntent(context: Context): Intent { val title = entryPoint.toCardInfoTitle(context) val arguments = Bundle().apply { - putString(CardInfoFragment.KEY_TITLE, title) - putLong(CardInfoFragment.KEY_CARD_ID, cardId) + putString(CardInfoFragment.ARG_TITLE, title) + putLong(CardInfoFragment.ARG_CARD_ID, cardId) } return SingleFragmentActivity.getIntent( context, diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/pages/CardInfoFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/pages/CardInfoFragment.kt index 58cb6d70e61b..149558c54e69 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/pages/CardInfoFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/pages/CardInfoFragment.kt @@ -9,7 +9,7 @@ import com.ichi2.anki.R class CardInfoFragment : PageFragment() { override val pagePath: String by lazy { - val cardId = requireArguments().getLong(KEY_CARD_ID) + val cardId = requireArguments().getLong(ARG_CARD_ID) "card-info/$cardId" } @@ -18,14 +18,14 @@ class CardInfoFragment : PageFragment() { savedInstanceState: Bundle?, ) { super.onViewCreated(view, savedInstanceState) - val title = requireArguments().getString(KEY_TITLE) + val title = requireArguments().getString(ARG_TITLE) if (title != null) { view.findViewById(R.id.toolbar)?.setTitle(title) } } companion object { - const val KEY_CARD_ID = "cardId" - const val KEY_TITLE = "title" + const val ARG_CARD_ID = "cardId" + const val ARG_TITLE = "title" } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/pages/CongratsPage.kt b/AnkiDroid/src/main/java/com/ichi2/anki/pages/CongratsPage.kt index 3231227bdd5e..a13fa289e088 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/pages/CongratsPage.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/pages/CongratsPage.kt @@ -7,7 +7,6 @@ import android.content.Intent import android.os.Bundle import android.view.View import androidx.appcompat.app.AlertDialog -import androidx.fragment.app.FragmentActivity import androidx.fragment.app.setFragmentResultListener import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle @@ -21,25 +20,20 @@ import com.google.android.material.appbar.MaterialToolbar import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.CommonString -import com.ichi2.anki.DeckPicker import com.ichi2.anki.OnErrorListener import com.ichi2.anki.R import com.ichi2.anki.SingleFragmentActivity import com.ichi2.anki.common.destinations.DeckOptionsDestination import com.ichi2.anki.common.destinations.StudyOptionsDestination import com.ichi2.anki.common.destinations.navigate -import com.ichi2.anki.common.preferences.sharedPrefs -import com.ichi2.anki.common.time.SECONDS_PER_DAY -import com.ichi2.anki.common.time.TIME_HOUR -import com.ichi2.anki.common.time.TIME_MINUTE -import com.ichi2.anki.common.utils.android.showThemedToast import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog.CustomStudyAction import com.ichi2.anki.launchCatchingIO import com.ichi2.anki.launchCatchingTask import com.ichi2.anki.observability.ChangeManager import com.ichi2.anki.observability.undoableOp -import com.ichi2.anki.snackbar.showSnackbar +import com.ichi2.anki.study.StudyAvailability +import com.ichi2.anki.study.currentDeckStudyState import com.ichi2.anki.ui.internationalization.sentenceCase import com.ichi2.utils.listItems import com.ichi2.utils.negativeButton @@ -49,7 +43,6 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import timber.log.Timber -import kotlin.math.round class CongratsPage : PageFragment(), @@ -191,65 +184,6 @@ class CongratsPage : companion object { fun getIntent(context: Context): Intent = SingleFragmentActivity.getIntent(context, fragmentClass = CongratsPage::class) - - private fun displayNewCongratsScreen(context: Context): Boolean = context.sharedPrefs().getBoolean("new_congrats_screen", false) - - fun display(activity: FragmentActivity) { - if (displayNewCongratsScreen(activity)) { - activity.startActivity(getIntent(activity)) - } else { - activity.launchCatchingTask { - val message = getDeckFinishedMessage(activity) - showThemedToast(activity, message, false) - } - } - } - - fun onReviewsCompleted( - activity: FragmentActivity, - cardsInDeck: Boolean, - ) { - if (displayNewCongratsScreen(activity)) { - activity.startActivity(getIntent(activity)) - return - } - - // Show a message when reviewing has finished - if (cardsInDeck) { - activity.launchCatchingTask { - val message = getDeckFinishedMessage(activity) - activity.showSnackbar(message) - } - } else { - activity.showSnackbar(CommonString.studyoptions_no_cards_due) - } - } - - // based in https://github.com/ankitects/anki/blob/9b4dd54312de8798a3f2bee07892bb3a488d1f9b/ts/routes/congrats/lib.ts#L8C17-L8C34 - private suspend fun getDeckFinishedMessage(activity: FragmentActivity): String { - val info = withCol { sched.congratulationsInfo() } - val secsUntilNextLearn = info.secsUntilNextLearn - if (secsUntilNextLearn >= SECONDS_PER_DAY) { - return activity.getString(CommonString.studyoptions_congrats_finished) - } - // https://github.com/ankitects/anki/blob/9b4dd54312de8798a3f2bee07892bb3a488d1f9b/ts/lib/tslib/time.ts#L22 - val (unit, amount) = - if (secsUntilNextLearn < TIME_MINUTE) { - "seconds" to secsUntilNextLearn.toDouble() - } else if (secsUntilNextLearn < TIME_HOUR) { - "minutes" to secsUntilNextLearn / TIME_MINUTE - } else { - "hours" to secsUntilNextLearn / TIME_HOUR - } - - val nextLearnDue = TR.schedulingNextLearnDue(unit, round(amount).toInt()) - return activity.getString(CommonString.studyoptions_congrats_next_due_in, nextLearnDue) - } - - fun DeckPicker.onDeckCompleted() { - Timber.i("Opening CongratsPage") - startActivity(getIntent(this)) - } } } @@ -310,8 +244,8 @@ class CongratsViewModel : } } - /** Called every time the screen becomes STARTED. [isDeckFinished] is cheap so it's always - * run, but the (latency-sensitive) reload/navigation only happens if something was actually + /** Called every time the screen becomes STARTED. [isDeckFinished] is always checked, + * but reload/navigation only happens if something was actually * missed while unwatched, or the deck no longer needs the congrats page. */ suspend fun refreshIfNeeded() { // desktop's Overview re-checks sched._is_finished() on every refresh instead of @@ -332,7 +266,7 @@ class CongratsViewModel : ) } - private suspend fun isDeckFinished(): Boolean = withCol { sched.counts().count() == 0 } + private suspend fun isDeckFinished(): Boolean = withCol { currentDeckStudyState().availability == StudyAvailability.NoCardsAvailable } } sealed class UnburyState { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/pages/CsvImporter.kt b/AnkiDroid/src/main/java/com/ichi2/anki/pages/CsvImporter.kt index 0bdf0f94eb28..649aa083f921 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/pages/CsvImporter.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/pages/CsvImporter.kt @@ -23,7 +23,7 @@ import com.ichi2.utils.WebViewVersion */ class CsvImporter : PageFragment() { override val pagePath: String by lazy { - val filePath = requireArguments().getString(KEY_FILE_PATH) + val filePath = requireArguments().getString(ARG_FILE_PATH) "import-csv$filePath" } @@ -90,7 +90,7 @@ class CsvImporter : PageFragment() { } companion object { - private const val KEY_FILE_PATH = "filePath" + private const val ARG_FILE_PATH = "filePath" /** * @param filePath path of the csv file that will be imported, which should be accessible by AnkiDroid @@ -100,7 +100,7 @@ class CsvImporter : PageFragment() { context: Context, filePath: String, ): Intent { - val arguments = Bundle().apply { putString(KEY_FILE_PATH, filePath) } + val arguments = Bundle().apply { putString(ARG_FILE_PATH, filePath) } return SingleFragmentActivity.getIntent(context, fragmentClass = CsvImporter::class, arguments) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/pages/DeckOptions.kt b/AnkiDroid/src/main/java/com/ichi2/anki/pages/DeckOptions.kt index 7bc8b3ae0623..af6ef7f961c2 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/pages/DeckOptions.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/pages/DeckOptions.kt @@ -39,10 +39,10 @@ import java.util.concurrent.atomic.AtomicInteger @NeedsTest("15130: pressing back: icon + button should return to options if the manual is open") @NeedsTest("17905: pressing back before the webpage is ready closes the screen") class DeckOptions : PageFragment() { - private val deckId: DeckId by lazy { requireArguments().getLong(KEY_DECK_ID) } + private val deckId: DeckId by lazy { requireArguments().getLong(ARG_DECK_ID) } override val pagePath: String by lazy { - val deckId = requireArguments().getLong(KEY_DECK_ID) + val deckId = requireArguments().getLong(ARG_DECK_ID) "deck-options/$deckId" } private var webViewIsReady = false @@ -433,7 +433,7 @@ class DeckOptions : PageFragment() { } companion object { - private const val KEY_DECK_ID = "deckId" + private const val ARG_DECK_ID = "deckId" fun getIntent( context: Context, @@ -442,7 +442,7 @@ class DeckOptions : PageFragment() { SingleFragmentActivity.getIntent( context, fragmentClass = DeckOptions::class, - arguments = Bundle().apply { putLong(KEY_DECK_ID, deckId) }, + arguments = Bundle().apply { putLong(ARG_DECK_ID, deckId) }, ) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/pages/Statistics.kt b/AnkiDroid/src/main/java/com/ichi2/anki/pages/Statistics.kt index 0ccdbc002a96..dbb77aa9d4ac 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/pages/Statistics.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/pages/Statistics.kt @@ -60,7 +60,7 @@ class Statistics : PageFragment(R.layout.page_statistics) { requireActivity().launchCatchingTask { withProgress { val deckName = - savedInstanceState?.getString(KEY_DECK_NAME, null) ?: withCol { decks.current().name } + savedInstanceState?.getString(STATE_DECK_NAME, null) ?: withCol { decks.current().name } changeDeck(deckName) } } @@ -100,7 +100,7 @@ class Statistics : PageFragment(R.layout.page_statistics) { override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) - outState.putString(KEY_DECK_NAME, binding.deckName.text.toString()) + outState.putString(STATE_DECK_NAME, binding.deckName.text.toString()) } /** @@ -124,7 +124,7 @@ class Statistics : PageFragment(R.layout.page_statistics) { companion object { const val ARG_HIDE_BACK_BUTTON = "hideBackButton" - private const val KEY_DECK_NAME = "key_deck_name" + private const val STATE_DECK_NAME = "key_deck_name" } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/DeveloperOptionsFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/DeveloperOptionsFragment.kt index c879fc2a4517..1f2111311d30 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/DeveloperOptionsFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/DeveloperOptionsFragment.kt @@ -10,6 +10,7 @@ import androidx.preference.SwitchPreferenceCompat import com.ichi2.anki.AnkiDroidApp import com.ichi2.anki.BuildConfig import com.ichi2.anki.CollectionManager.withCol +import com.ichi2.anki.CommonPreferenceKey import com.ichi2.anki.CommonString import com.ichi2.anki.R import com.ichi2.anki.analytics.AnkiDroidUsageAnalytics @@ -173,7 +174,7 @@ class DeveloperOptionsFragment : SettingsFragment() { * with a "Review reminders" button, so we need to immediately reload the settings activity * to make this change show up. */ - requirePreference(R.string.pref_new_review_reminders).setOnPreferenceChangeListener { _, _ -> + requirePreference(CommonPreferenceKey.pref_new_review_reminders).setOnPreferenceChangeListener { _, _ -> ActivityCompat.recreate(requireActivity()) true } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/NotificationsSettingsFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/NotificationsSettingsFragment.kt index 0fa56bebdcbe..e52c9eda0b02 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/NotificationsSettingsFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/NotificationsSettingsFragment.kt @@ -8,6 +8,7 @@ import android.content.Intent import androidx.core.app.PendingIntentCompat import androidx.preference.ListPreference import androidx.preference.SwitchPreferenceCompat +import com.ichi2.anki.CommonPreferenceKey import com.ichi2.anki.R import com.ichi2.anki.common.android.AdaptionUtil import com.ichi2.anki.common.time.TimeManager @@ -32,7 +33,7 @@ class NotificationsSettingsFragment : SettingsFragment() { } // Minimum cards due // The number of cards that should be due today in a deck to justify adding a notification. - requirePreference(R.string.pref_notifications_minimum_cards_due_key).apply { + requirePreference(CommonPreferenceKey.pref_notifications_minimum_cards_due_key).apply { updateNotificationPreference(this) setOnPreferenceChangeListener { preference, newValue -> updateNotificationPreference(preference as ListPreference) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/SettingsFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/SettingsFragment.kt index e9da4fd0f242..c1e000437884 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/SettingsFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/SettingsFragment.kt @@ -125,7 +125,7 @@ abstract class SettingsFragment : (preference as? DialogFragmentProvider)?.makeDialogFragment() ?: return super.onDisplayPreferenceDialog(preference) Timber.d("displaying custom preference: ${dialogFragment::class.simpleName}") - dialogFragment.arguments = Bundle().apply { putString(PREF_DIALOG_KEY, preference.key) } + dialogFragment.arguments = Bundle().apply { putString(ARG_PREFERENCE_KEY, preference.key) } dialogFragment.setTargetFragment(this, 0) dialogFragment.show(parentFragmentManager, "androidx.preference.PreferenceFragment.DIALOG") } @@ -145,7 +145,7 @@ abstract class SettingsFragment : } companion object { - const val PREF_DIALOG_KEY = "key" + const val ARG_PREFERENCE_KEY = "key" /** * Converts a preference value to a numeric number that diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/reviewer/ViewerAction.kt b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/reviewer/ViewerAction.kt index d19849561f62..b0100fb67249 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/reviewer/ViewerAction.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/reviewer/ViewerAction.kt @@ -16,9 +16,11 @@ import com.ichi2.anki.preferences.reviewer.MenuDisplayType.DISABLED import com.ichi2.anki.preferences.reviewer.MenuDisplayType.MENU_ONLY import com.ichi2.anki.reviewer.Binding import com.ichi2.anki.reviewer.Binding.AppDefinedModifierKeys +import com.ichi2.anki.reviewer.Binding.KeyCode import com.ichi2.anki.reviewer.Binding.ModifierKeys import com.ichi2.anki.reviewer.Binding.ModifierKeys.Companion.ctrl import com.ichi2.anki.reviewer.Binding.ModifierKeys.Companion.shift +import com.ichi2.anki.reviewer.BindingUnicodeCharacter import com.ichi2.anki.reviewer.CardSide import com.ichi2.anki.reviewer.MappableAction import com.ichi2.anki.reviewer.ReviewerBinding @@ -120,13 +122,13 @@ enum class ViewerAction( when (this) { UNDO -> listOf(keycode(KeyEvent.KEYCODE_Z, ctrl())) REDO -> listOf(keycode(KeyEvent.KEYCODE_Z, ModifierKeys(shift = true, ctrl = true))) - MARK -> listOf(unicode('*')) + MARK -> listOf(unicode(BindingUnicodeCharacter('*'))) EDIT -> listOf(keycode(KeyEvent.KEYCODE_E)) ADD_NOTE -> listOf(keycode(KeyEvent.KEYCODE_A)) - BURY_NOTE -> listOf(unicode('=')) - BURY_CARD -> listOf(unicode('-')) - SUSPEND_NOTE -> listOf(unicode('!')) - SUSPEND_CARD -> listOf(unicode('@')) + BURY_NOTE -> listOf(unicode(BindingUnicodeCharacter('='))) + BURY_CARD -> listOf(unicode(BindingUnicodeCharacter('-'))) + SUSPEND_NOTE -> listOf(unicode(BindingUnicodeCharacter('!'))) + SUSPEND_CARD -> listOf(unicode(BindingUnicodeCharacter('@'))) TOGGLE_AUTO_ADVANCE -> listOf(keycode(KeyEvent.KEYCODE_A, shift())) SHOW_HINT -> listOf(keycode(KeyEvent.KEYCODE_H)) SHOW_ALL_HINTS -> listOf(keycode(KeyEvent.KEYCODE_G)) @@ -316,12 +318,12 @@ enum class ViewerAction( keys: ModifierKeys = ModifierKeys.none(), side: CardSide = CardSide.BOTH, ): ReviewerBinding { - val binding = Binding.keyCode(keycode, keys) + val binding = KeyCode(keycode, keys) return ReviewerBinding(binding = binding, side = side) } private fun unicode( - unicodeChar: Char, + unicodeChar: BindingUnicodeCharacter, keys: ModifierKeys = AppDefinedModifierKeys.allowShift(), side: CardSide = CardSide.BOTH, ): ReviewerBinding { 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/PreviewerAction.kt b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerAction.kt index 01657da992fc..bcd599a90083 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerAction.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerAction.kt @@ -4,9 +4,10 @@ package com.ichi2.anki.previewer import android.content.SharedPreferences import android.view.KeyEvent -import com.ichi2.anki.reviewer.Binding.Companion.keyCode import com.ichi2.anki.reviewer.Binding.Companion.unicode +import com.ichi2.anki.reviewer.Binding.KeyCode import com.ichi2.anki.reviewer.Binding.ModifierKeys.Companion.ctrl +import com.ichi2.anki.reviewer.BindingUnicodeCharacter import com.ichi2.anki.reviewer.MappableAction import com.ichi2.anki.reviewer.MappableBinding @@ -39,19 +40,19 @@ enum class PreviewerAction : MappableAction { private val defaultBindings: List get() { val binding = when (this) { - BACK -> keyCode(KeyEvent.KEYCODE_DPAD_LEFT) - NEXT -> keyCode(KeyEvent.KEYCODE_DPAD_RIGHT) - MARK -> unicode('*') - REPLAY_AUDIO -> keyCode(KeyEvent.KEYCODE_R) - EDIT -> keyCode(KeyEvent.KEYCODE_E) - TOGGLE_BACKSIDE_ONLY -> keyCode(KeyEvent.KEYCODE_B) - TOGGLE_FLAG_RED -> keyCode(KeyEvent.KEYCODE_1, ctrl()) - TOGGLE_FLAG_ORANGE -> keyCode(KeyEvent.KEYCODE_2, ctrl()) - TOGGLE_FLAG_GREEN -> keyCode(KeyEvent.KEYCODE_3, ctrl()) - TOGGLE_FLAG_BLUE -> keyCode(KeyEvent.KEYCODE_4, ctrl()) - TOGGLE_FLAG_PINK -> keyCode(KeyEvent.KEYCODE_5, ctrl()) - TOGGLE_FLAG_TURQUOISE -> keyCode(KeyEvent.KEYCODE_6, ctrl()) - TOGGLE_FLAG_PURPLE -> keyCode(KeyEvent.KEYCODE_7, ctrl()) + BACK -> KeyCode(KeyEvent.KEYCODE_DPAD_LEFT) + NEXT -> KeyCode(KeyEvent.KEYCODE_DPAD_RIGHT) + MARK -> unicode(BindingUnicodeCharacter('*')) + REPLAY_AUDIO -> KeyCode(KeyEvent.KEYCODE_R) + EDIT -> KeyCode(KeyEvent.KEYCODE_E) + TOGGLE_BACKSIDE_ONLY -> KeyCode(KeyEvent.KEYCODE_B) + TOGGLE_FLAG_RED -> KeyCode(KeyEvent.KEYCODE_1, ctrl()) + TOGGLE_FLAG_ORANGE -> KeyCode(KeyEvent.KEYCODE_2, ctrl()) + TOGGLE_FLAG_GREEN -> KeyCode(KeyEvent.KEYCODE_3, ctrl()) + TOGGLE_FLAG_BLUE -> KeyCode(KeyEvent.KEYCODE_4, ctrl()) + TOGGLE_FLAG_PINK -> KeyCode(KeyEvent.KEYCODE_5, ctrl()) + TOGGLE_FLAG_TURQUOISE -> KeyCode(KeyEvent.KEYCODE_6, ctrl()) + TOGGLE_FLAG_PURPLE -> KeyCode(KeyEvent.KEYCODE_7, ctrl()) UNSET_FLAG -> return emptyList() } return listOf(MappableBinding(binding)) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerFragment.kt index 2b46b8fd5f6b..d5d3bdcb528d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerFragment.kt @@ -29,7 +29,7 @@ import com.ichi2.anki.common.annotations.NeedsTest import com.ichi2.anki.common.destinations.navigate import com.ichi2.anki.common.utils.android.showThemedToast import com.ichi2.anki.databinding.FragmentPreviewerBinding -import com.ichi2.anki.previewer.PreviewerFragment.Companion.CARD_IDS_FILE_ARG +import com.ichi2.anki.previewer.PreviewerFragment.Companion.ARG_CARD_IDS_FILE import com.ichi2.anki.reviewer.BindingMap import com.ichi2.anki.reviewer.BindingProcessor import com.ichi2.anki.reviewer.MappableBinding @@ -62,12 +62,12 @@ class PreviewerFragment : // Read before constructing the ViewModel, which immediately loads the first card. val ids = try { - arguments.requireParcelable(CARD_IDS_FILE_ARG).getIds() + arguments.requireParcelable(ARG_CARD_IDS_FILE).getIds() } catch (e: IOException) { throw UnavailableSelectionException(e) } if (ids.isEmpty()) throw UnavailableSelectionException() - PreviewerViewModel(handle, ids, requireNotNull(arguments.getIntOrNull(CURRENT_INDEX_ARG))) + PreviewerViewModel(handle, ids, requireNotNull(arguments.getIntOrNull(ARG_CURRENT_INDEX))) } } @@ -308,11 +308,11 @@ class PreviewerFragment : ) : Exception("Unable to load preview selection", cause) companion object { - /** Index of the card to be first displayed among the IDs provided by [CARD_IDS_FILE_ARG] */ - const val CURRENT_INDEX_ARG = "currentIndex" + /** Index of the card to be first displayed among the IDs provided by [ARG_CARD_IDS_FILE] */ + const val ARG_CURRENT_INDEX = "currentIndex" /** Argument key to a [IdsFile] with the IDs of the cards to be displayed */ - const val CARD_IDS_FILE_ARG = "cardIdsFile" + const val ARG_CARD_IDS_FILE = "cardIdsFile" fun getIntent( context: Context, @@ -321,8 +321,8 @@ class PreviewerFragment : ): Intent { val arguments = Bundle().apply { - putInt(CURRENT_INDEX_ARG, currentIndex) - putParcelable(CARD_IDS_FILE_ARG, idsFile) + putInt(ARG_CURRENT_INDEX, currentIndex) + putParcelable(ARG_CARD_IDS_FILE, idsFile) } return CardViewerActivity.getIntent(context, PreviewerFragment::class, arguments) } 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/reviewer/Binding.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/Binding.kt index d7cacd83ed28..2a0724406ccb 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/Binding.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/Binding.kt @@ -4,7 +4,6 @@ package com.ichi2.anki.reviewer import android.content.Context import android.view.KeyEvent -import androidx.annotation.VisibleForTesting import com.ichi2.anki.cardviewer.Gesture import com.ichi2.anki.common.utils.StringUtils import com.ichi2.anki.common.utils.ext.ifNotZero @@ -95,9 +94,14 @@ sealed interface Binding { override fun hashCode(): Int = Objects.hash(keycode) } + /** + * A keyboard shortcut matched by the character produced by a key press. + * + * [KeyCode] is insufficient: e.g. `@` doesn't have a consistent keypress across keyboards. + */ @Suppress("EqualsOrHashCode") data class UnicodeCharacter( - val unicodeCharacter: Char, + val unicodeCharacter: BindingUnicodeCharacter, override val modifierKeys: ModifierKeys = AppDefinedModifierKeys.allowShift(), ) : KeyBinding { override fun toDisplayString(context: Context): String = @@ -105,27 +109,18 @@ sealed interface Binding { append(KEY_PREFIX) append(' ') append(modifierKeys.toString()) - append(unicodeCharacter) + append(unicodeCharacter.value) } override fun toString(): String = buildString { append(UNICODE_PREFIX) append(modifierKeys.toString()) - append(unicodeCharacter) + append(unicodeCharacter.value) } // don't include the modifierKeys - override fun hashCode(): Int = Objects.hash(unicodeCharacter) - } - - data object UnknownBinding : Binding { - override fun toDisplayString(context: Context): String = "" - - override fun toString(): String = "" - - override val isValid: Boolean - get() = false + override fun hashCode(): Int = Objects.hash(unicodeCharacter.value) } fun toDisplayString(context: Context): String @@ -220,8 +215,6 @@ sealed interface Binding { } companion object { - const val FORBIDDEN_UNICODE_CHAR = MappableBinding.PREF_SEPARATOR - /** * https://www.fileformat.info/info/unicode/char/2328/index.htm (Keyboard) */ @@ -249,45 +242,43 @@ sealed interface Binding { fun possibleKeyBindings(event: KeyEvent): List { val modifiers = ModifierKeys(event.isShiftPressed, event.isCtrlPressed, event.isAltPressed) val ret: MutableList = ArrayList() - event.keyCode.ifNotZero { keyCode -> ret.add(keyCode(keyCode, modifiers)) } + event.keyCode.ifNotZero { keyCode -> ret.add(KeyCode(keyCode, modifiers)) } // passing in metaState: 0 means that Ctrl+1 returns '1' instead of '\0' // NOTE: We do not differentiate on upper/lower case via KeyEvent.META_CAPS_LOCK_ON event .getUnicodeChar(event.metaState and (KeyEvent.META_SHIFT_ON or KeyEvent.META_NUM_LOCK_ON)) .ifNotZero { unicodeChar -> - try { - ret.add(unicode(unicodeChar.toChar(), modifiers) as KeyBinding) - } catch (e: Exception) { - // very slight chance it returns unknown() - Timber.w(e) + BindingUnicodeCharacter.fromCharOrNull(unicodeChar.toChar())?.let { character -> + ret.add(unicode(character, modifiers)) } } return ret } - fun fromString(from: String): Binding { - if (from.isEmpty()) return UnknownBinding + fun fromString(from: String): Binding? { + if (from.isEmpty()) return null try { return when (from[0]) { JOYSTICK_CHAR_PREFIX -> AxisButtonBinding.from(from.substring(1)) GESTURE_PREFIX -> GestureInput(Gesture.valueOf(from.substring(1))) UNICODE_PREFIX -> { val (modifierKeys, char) = ModifierKeys.parse(from.substring(1)) - UnicodeCharacter(char[0], modifierKeys) + val unicodeChar = BindingUnicodeCharacter.fromCharOrNull(char[0]) ?: return null + UnicodeCharacter(unicodeChar, modifierKeys) } KEY_PREFIX -> { val (modifierKeys, keyCodeAsString) = ModifierKeys.parse(from.substring(1)) val keyCode = keyCodeAsString.toInt() KeyCode(keyCode, modifierKeys) } - else -> UnknownBinding + else -> null } } catch (ex: Exception) { Timber.w(ex) } - return UnknownBinding + return null } /** @@ -295,21 +286,10 @@ sealed interface Binding { * See [AppDefinedModifierKeys] */ fun unicode( - unicodeChar: Char, + unicodeChar: BindingUnicodeCharacter, modifierKeys: ModifierKeys = AppDefinedModifierKeys.allowShift(), - ): Binding { - if (unicodeChar == FORBIDDEN_UNICODE_CHAR) return unknown() - return UnicodeCharacter(unicodeChar, modifierKeys) - } - - fun keyCode( - keyCode: Int, - modifiers: ModifierKeys = ModifierKeys.none(), - ) = KeyCode(keyCode, modifiers) + ): KeyBinding = UnicodeCharacter(unicodeChar, modifierKeys) fun gesture(gesture: Gesture) = GestureInput(gesture) - - @VisibleForTesting - fun unknown() = UnknownBinding } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/BindingMap.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/BindingMap.kt index 8b4af224cf1e..157f81158052 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/BindingMap.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/BindingMap.kt @@ -49,7 +49,6 @@ class BindingMap>( gestureMap[binding.gesture] = mutableListOf(action to mappableBinding) } } - else -> {} } } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/BindingUnicodeCharacter.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/BindingUnicodeCharacter.kt new file mode 100644 index 000000000000..b08e4e5dcfc2 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/BindingUnicodeCharacter.kt @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.reviewer + +/** @see Binding.UnicodeCharacter */ +@JvmInline +value class BindingUnicodeCharacter( + val value: Char, +) { + init { + require(value != FORBIDDEN_UNICODE_CHAR) { "The preference separator cannot be bound" } + } + + companion object { + /** Separates bindings in preferences; using it as a key would split the binding during deserialization. */ + private const val FORBIDDEN_UNICODE_CHAR = MappableBinding.PREF_SEPARATOR + + /** Returns null for the preference separator, which cannot be stored as a binding. */ + fun fromCharOrNull(value: Char): BindingUnicodeCharacter? = + if (value == FORBIDDEN_UNICODE_CHAR) null else BindingUnicodeCharacter(value) + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/MappableBinding.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/MappableBinding.kt index 239bc840d48f..6931186da6d5 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/MappableBinding.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/MappableBinding.kt @@ -52,9 +52,10 @@ open class MappableBinding( @CheckResult fun fromPreferenceString(string: String?): List { if (string.isNullOrEmpty()) return emptyList() - return getPreferenceSubstrings(string).map { - val binding = Binding.fromString(it) - MappableBinding(binding) + return getPreferenceSubstrings(string).mapNotNull { substring -> + Binding.fromString(substring)?.let { binding -> + MappableBinding(binding) + } } } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/ReviewerBinding.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/ReviewerBinding.kt index 9baca34f25e9..4af2724df680 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/ReviewerBinding.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/ReviewerBinding.kt @@ -69,7 +69,7 @@ class ReviewerBinding( StringBuilder(string) .substring(0, string.length - 1) .removePrefix(PREFIX.toString()) - val binding = Binding.fromString(bindingString) + val binding = Binding.fromString(bindingString) ?: return null val side = when (string.last()) { QUESTION_SUFFIX -> CardSide.QUESTION @@ -79,8 +79,7 @@ class ReviewerBinding( return ReviewerBinding(binding, side) } - val strings = getPreferenceSubstrings(prefString) - return strings.mapNotNull { fromString(it) } + return getPreferenceSubstrings(prefString).mapNotNull { fromString(it) } } @CheckResult 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..52938bd2c1c3 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/ForgetCardsDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/ForgetCardsDialog.kt @@ -74,8 +74,8 @@ class ForgetCardsDialog : AnalyticsDialogFragment() { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val sharedPrefs = requireContext().sharedPrefs() if (savedInstanceState != null) { - restoreOriginalPositionIfPossible = savedInstanceState.getBoolean(ARG_RESTORE_ORIGINAL, true) - resetRepetitionAndLapseCounts = savedInstanceState.getBoolean(ARG_RESET_REPETITION, false) + restoreOriginalPositionIfPossible = savedInstanceState.getBoolean(STATE_RESTORE_ORIGINAL, true) + resetRepetitionAndLapseCounts = savedInstanceState.getBoolean(STATE_RESET_REPETITION, false) } else { // If no saved instance state, load the last saved preferences for restore position and reset counts. with(sharedPrefs) { @@ -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) }, ) } @@ -126,14 +126,19 @@ class ForgetCardsDialog : AnalyticsDialogFragment() { override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) - outState.putBoolean(ARG_RESTORE_ORIGINAL, restoreOriginalPositionIfPossible) - outState.putBoolean(ARG_RESET_REPETITION, resetRepetitionAndLapseCounts) + outState.putBoolean(STATE_RESTORE_ORIGINAL, restoreOriginalPositionIfPossible) + outState.putBoolean(STATE_RESET_REPETITION, resetRepetitionAndLapseCounts) } 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" + private const val STATE_RESTORE_ORIGINAL = "key_restore_original" + private const val STATE_RESET_REPETITION = "key_reset_repetition" } } @@ -147,8 +152,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 +168,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/servicelayer/PreferenceUpgradeService.kt b/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/PreferenceUpgradeService.kt index 69ae4e8a66a1..2e37cd0c96e8 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/PreferenceUpgradeService.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/PreferenceUpgradeService.kt @@ -27,7 +27,6 @@ import com.ichi2.anki.browser.CardBrowserColumn.REVIEWS import com.ichi2.anki.browser.CardBrowserColumn.SFLD import com.ichi2.anki.browser.CardBrowserColumn.TAGS import com.ichi2.anki.cardviewer.Gesture -import com.ichi2.anki.common.annotations.NeedsTest import com.ichi2.anki.common.preferences.sharedPrefs import com.ichi2.anki.common.utils.HashUtil.hashSetInit import com.ichi2.anki.libanki.Consts @@ -36,7 +35,7 @@ import com.ichi2.anki.model.CardsOrNotes import com.ichi2.anki.noteeditor.CustomToolbarButton import com.ichi2.anki.preferences.reviewer.ViewerAction import com.ichi2.anki.reviewer.Binding -import com.ichi2.anki.reviewer.Binding.Companion.keyCode +import com.ichi2.anki.reviewer.Binding.KeyCode import com.ichi2.anki.reviewer.CardSide import com.ichi2.anki.reviewer.FullScreenMode import com.ichi2.anki.reviewer.MappableBinding @@ -364,7 +363,7 @@ object PreferenceUpgradeService { oldGesturePreferenceKey: String, volumeKeyCode: Int, ) { - upgradeBinding(preferences, oldGesturePreferenceKey, keyCode(volumeKeyCode)) + upgradeBinding(preferences, oldGesturePreferenceKey, KeyCode(volumeKeyCode)) } private fun upgradeGestureToBinding( @@ -675,7 +674,6 @@ object PreferenceUpgradeService { } } - @NeedsTest("long touch gesture is removed from preferences") internal class RemoveLongTouchGesture : PreferenceUpgrade(21) { private val keys = listOf( @@ -734,15 +732,20 @@ object PreferenceUpgradeService { override fun upgrade(preferences: SharedPreferences) { for (key in keys) { val value = preferences.getString(key, null) ?: continue - val bindings = fromPreferenceString(value) - val unknown = bindings.filter { it.binding is Binding.UnknownBinding } - if (unknown.isEmpty()) continue - val newBindings = bindings - unknown + val (newBindings, error) = parseBindingsAndError(value) + if (!error) continue preferences.edit { putString(key, newBindings.toPreferenceString()) } } } + + /** The list of bindings, and whether there are bindings that could not be interpreted. */ + private fun parseBindingsAndError(prefString: String): Pair, Boolean> { + val substringCount = MappableBinding.getPreferenceSubstrings(prefString).size + val bindings = ReviewerBinding.fromPreferenceString(prefString) + return bindings to (substringCount != bindings.size) + } } internal class UpgradeDoubleTapTimeout : PreferenceUpgrade(22) { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/ThrowableFilterService.kt b/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/ThrowableFilterService.kt index 8b479484308b..84c7a6532518 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/ThrowableFilterService.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/ThrowableFilterService.kt @@ -5,12 +5,17 @@ package com.ichi2.anki.servicelayer import androidx.annotation.VisibleForTesting +import com.ichi2.anki.exception.SanitizedThrowable import com.ichi2.anki.exception.StorageAccessException +import com.ichi2.utils.ExceptionUtil.causeChain +import com.ichi2.utils.ExceptionUtil.causesAndSuppressed import net.ankiweb.rsdroid.BackendException.BackendSchedulerUpgradeRequiredException import net.ankiweb.rsdroid.exceptions.BackendNetworkException import net.ankiweb.rsdroid.exceptions.BackendSyncException import net.ankiweb.rsdroid.exceptions.BackendSyncException.BackendSyncServerMessageException import timber.log.Timber +import java.util.Collections +import java.util.IdentityHashMap object ThrowableFilterService { @FunctionalInterface @@ -25,11 +30,15 @@ object ThrowableFilterService { Timber.w("unexpected: thread was null") return@FilteringExceptionHandler } - if (shouldDiscardThrowable(throwable)) { - Timber.i("discarding throwable") - return@FilteringExceptionHandler - } - originalUncaughtExceptionHandler?.uncaughtException(thread, throwable) + val reportable = + if (shouldDiscardThrowable(throwable)) { + // Retain diagnostic class names and frames without exposing private messages. + Timber.i("sanitizing uncaught throwable") + SanitizedThrowable.from(throwable) + } else { + throwable + } + originalUncaughtExceptionHandler?.uncaughtException(thread, reportable) } fun initialize() { @@ -38,7 +47,7 @@ object ThrowableFilterService { } /** - * We want to filter any exceptions with PII, then chain to other handlers + * Sanitize filtered uncaught exceptions before delegating to the reporting handlers. */ @Synchronized @VisibleForTesting @@ -69,14 +78,18 @@ object ThrowableFilterService { initialize() } + /** + * Whether a handled report should be discarded. Uncaught errors are sanitized instead, so they + * can still reach the reporting handlers. + */ fun shouldDiscardThrowable(t: Throwable): Boolean { - // Note that an exception may have a nested BackendSyncException, - // so we check if it is safe from PII despite also filtering by type - return exceptionIsUnwanted(t) || !t.safeFromPII() + // PII-bearing exceptions may be nested or suppressed. ACRA's stack-trace hashing also + // assumes the main cause chain is acyclic, so circular causes cannot be reported as-is. + return exceptionIsUnwanted(t) || !t.safeFromPII() || t.hasCyclicCause() } - // There are few exception types that are common, but are unwanted in - // our analytics or crash report service because they are not actionable + // These common types are noise in handled reports. Uncaught instances still indicate crashes + // and must reach reporting after sanitization. fun exceptionIsUnwanted(t: Throwable): Boolean { Timber.v("exceptionIsUnwanted - examining %s", t.javaClass.simpleName) when (t) { @@ -90,12 +103,14 @@ object ThrowableFilterService { } /** - * Checks if the [Throwable] is safe from Personally Identifiable Information (PII) + * Checks this throwable, its causes and suppressed exceptions for known PII-bearing types. * @return `false` if the [Throwable] contains PII, otherwise `true` */ - fun Throwable.safeFromPII(): Boolean { - if (this.containsPIINonRecursive()) return false - return this.cause?.safeFromPII() != false + fun Throwable.safeFromPII(): Boolean = causesAndSuppressed().none { it.containsPIINonRecursive() } + + private fun Throwable.hasCyclicCause(): Boolean { + val seen = Collections.newSetFromMap(IdentityHashMap()) + return causeChain.any { !seen.add(it) } } private fun Throwable.containsPIINonRecursive(): Boolean { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/services/BootService.kt b/AnkiDroid/src/main/java/com/ichi2/anki/services/BootService.kt index 9e31c80617a8..5ba25539a05c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/services/BootService.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/services/BootService.kt @@ -9,9 +9,9 @@ import android.content.Context import android.content.Intent import androidx.core.app.PendingIntentCompat import com.ichi2.anki.CollectionManager +import com.ichi2.anki.CommonPreferenceKey import com.ichi2.anki.CommonString import com.ichi2.anki.IntentHandler.Companion.grantedStoragePermissions -import com.ichi2.anki.R import com.ichi2.anki.common.android.AnkiBroadcastReceiver import com.ichi2.anki.common.annotations.LegacyNotifications import com.ichi2.anki.common.annotations.NeedsTest @@ -149,7 +149,7 @@ class BootService : AnkiBroadcastReceiver() { // Don't schedule a notification if the due reminders setting is not enabled if (sp .getString( - context.getString(R.string.pref_notifications_minimum_cards_due_key), + context.getString(CommonPreferenceKey.pref_notifications_minimum_cards_due_key), PENDING_NOTIFICATIONS_ONLY.toString(), )!! .toInt() >= PENDING_NOTIFICATIONS_ONLY diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/services/NotificationService.kt b/AnkiDroid/src/main/java/com/ichi2/anki/services/NotificationService.kt index ff5683440a44..86a914c00cec 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/services/NotificationService.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/services/NotificationService.kt @@ -13,6 +13,7 @@ import androidx.core.app.PendingIntentCompat import androidx.core.content.getSystemService import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.CommonPlurals +import com.ichi2.anki.CommonPreferenceKey import com.ichi2.anki.CommonString import com.ichi2.anki.DeckPicker import com.ichi2.anki.IntentHandler @@ -362,7 +363,7 @@ class NotificationService : AnkiBroadcastReceiver() { val minCardsDue = preferences .getString( - context.getString(R.string.pref_notifications_minimum_cards_due_key), + context.getString(CommonPreferenceKey.pref_notifications_minimum_cards_due_key), PENDING_NOTIFICATIONS_ONLY.toString(), )!! .toInt() diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/settings/Prefs.kt b/AnkiDroid/src/main/java/com/ichi2/anki/settings/Prefs.kt index 3b2a89ebf967..3536596aef84 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/settings/Prefs.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/settings/Prefs.kt @@ -10,9 +10,11 @@ import androidx.annotation.VisibleForTesting import androidx.core.content.edit import com.ichi2.anki.AnkiDroidApp import com.ichi2.anki.BuildConfig +import com.ichi2.anki.CommonPreferenceKey import com.ichi2.anki.R import com.ichi2.anki.cardviewer.TapGestureMode import com.ichi2.anki.common.preferences.AnimationPreferences +import com.ichi2.anki.common.preferences.NotificationPreferences import com.ichi2.anki.common.preferences.sharedPrefs import com.ichi2.anki.common.utils.isRunningAsUnitTest import com.ichi2.anki.preferences.SharedPreferencesProvider @@ -36,7 +38,8 @@ object Prefs : PrefsRepository(AnkiDroidApp.sharedPreferencesProvider, AnkiDroid open class PrefsRepository protected constructor( private val preferences: SharedPreferencesProvider, private val resources: Resources, -) : AnimationPreferences { +) : AnimationPreferences, + NotificationPreferences { constructor(sharedPrefs: SharedPreferences, resources: Resources) : this(SharedPreferencesProvider { sharedPrefs }, resources) constructor(context: Context) : this(context.sharedPrefs(), context.resources) @@ -246,6 +249,13 @@ open class PrefsRepository protected constructor( var hkey by stringPref(R.string.hkey_key) var currentSyncUri by stringPref(R.string.current_sync_uri_key) + /** + * End time of the last manual or automatic sync attempt, in epoch milliseconds. + * Rate-limits foreground auto-sync and sync intents, including failed, canceled and no-change attempts. + * + * If a request is blocked before starting (rate limit; metered connection etc...) do not update it, + * so it can try again once the block is lifted. + */ var lastSyncTime by longPref(R.string.last_sync_time_key, defaultValue = 0L) val shouldFetchMedia: ShouldFetchMedia @@ -276,7 +286,12 @@ open class PrefsRepository protected constructor( /** * Whether to enable the new review reminders notification system. */ - var newReviewRemindersEnabled by booleanPref(R.string.pref_new_review_reminders, false) + override var newReviewRemindersEnabled by booleanPref(CommonPreferenceKey.pref_new_review_reminders, defaultValue = false) + + private val minimumCardsDueForNotification by stringPref(CommonPreferenceKey.pref_notifications_minimum_cards_due_key, "1000000") + + override val legacyNotificationsEnabled: Boolean + get() = minimumCardsDueForNotification!!.toInt() < 1000000 /** * Review reminder IDs are unique, starting at 0 and climbing upwards by one each time a new one is created. diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksActivity.kt index 7f084abab01a..eced03d09cda 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksActivity.kt @@ -195,7 +195,7 @@ class SharedDecksActivity : AnkiActivity(R.layout.activity_shared_decks) { companion object { const val SHARED_DECKS_DOWNLOAD_FRAGMENT = "SharedDecksDownloadFragment" - const val DOWNLOAD_FILE = "DownloadFile" + const val ARG_DOWNLOAD_FILE = "DownloadFile" const val HTTP_STATUS_TOO_MANY_REQUESTS = 429 } @@ -239,7 +239,7 @@ class SharedDecksActivity : AnkiActivity(R.layout.activity_shared_decks) { val sharedDecksDownloadFragment = SharedDecksDownloadFragment() sharedDecksDownloadFragment.arguments = Bundle().apply { - putSerializable(DOWNLOAD_FILE, DownloadFile(url, userAgent, contentDisposition, mimetype)) + putSerializable(ARG_DOWNLOAD_FILE, DownloadFile(url, userAgent, contentDisposition, mimetype)) } supportFragmentManager.commit { add( diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksDownloadFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksDownloadFragment.kt index 085fbbb6d228..4df219b34c70 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksDownloadFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksDownloadFragment.kt @@ -6,10 +6,12 @@ package com.ichi2.anki.shareddeck import android.app.DownloadManager import android.content.ActivityNotFoundException import android.content.BroadcastReceiver +import android.content.ContentResolver import android.content.Context import android.content.Intent import android.content.IntentFilter import android.database.Cursor +import android.net.Uri import android.os.Bundle import android.os.Handler import android.os.Looper @@ -42,7 +44,7 @@ import com.ichi2.anki.common.time.TimeManager import com.ichi2.anki.common.utils.android.showThemedToast import com.ichi2.anki.compat.CompatHelper.Companion.getSerializableCompat import com.ichi2.anki.compat.CompatHelper.Companion.registerReceiverCompat -import com.ichi2.anki.shareddeck.SharedDecksActivity.Companion.DOWNLOAD_FILE +import com.ichi2.anki.shareddeck.SharedDecksActivity.Companion.ARG_DOWNLOAD_FILE import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.utils.ext.launchCollectionInLifecycleScope import com.ichi2.anki.utils.openUrl @@ -64,13 +66,13 @@ import com.ichi2.anki.common.android.R as CommonR class SharedDecksDownloadFragment : Fragment() { private val viewModel: SharedDecksDownloadViewModel by viewModels() - private val fileToBeDownloaded by lazy { arguments?.getSerializableCompat(DOWNLOAD_FILE)!! } + private val fileToBeDownloaded by lazy { arguments?.getSerializableCompat(ARG_DOWNLOAD_FILE)!! } private var downloadId: Long = 0 private var fileName: String? = null - private var completedFile: File? = null + private var completedDeck: Uri? = null private var handler: Handler = Handler(Looper.getMainLooper()) private var isProgressCheckerRunning = false @@ -160,7 +162,7 @@ class SharedDecksDownloadFragment : Fragment() { }, onImportClick = { Timber.i("Import deck button clicked") - completedFile?.let { openDownloadedDeck(requireContext(), it) } + completedDeck?.let { openDownloadedDeck(requireContext(), it) } }, onTryAgainClick = ::retryDownload, onOpenInBrowserClick = ::openInBrowser, @@ -257,11 +259,17 @@ class SharedDecksDownloadFragment : Fragment() { } val currentFileName = fileToBeDownloaded.toFileName(extension = "apkg") - val downloadRequest = generateDeckDownloadRequest(fileToBeDownloaded, currentFileName) - - completedFile = null + completedDeck = null // Store unique download ID to be used when onReceiveBroadcast() of AnkiBroadcastReceiver gets executed. - downloadId = downloadManager.enqueue(downloadRequest) + downloadId = + try { + enqueueDownload(fileToBeDownloaded, currentFileName) + } catch (e: SecurityException) { + CrashReportService.sendExceptionReport(e, "SharedDecksDownloadFragment::downloadFile") + viewModel.onDownloadStarted(currentFileName) + onDownloadFinished(DownloadOutcome.Failed) + return + } fileName = currentFileName isDownloadInProgress = true onBackPressedCallback.isEnabled = isDownloadInProgress @@ -271,6 +279,24 @@ class SharedDecksDownloadFragment : Fragment() { startDownloadProgressChecker() } + private fun enqueueDownload( + fileToBeDownloaded: DownloadFile, + currentFileName: String, + ): Long { + val request = + generateDeckDownloadRequest(fileToBeDownloaded, currentFileName).setDestinationInExternalFilesDir( + context, + null, + "$SHARED_DECKS_DOWNLOAD_FOLDER/$currentFileName", + ) + return try { + downloadManager.enqueue(request) + } catch (_: SecurityException) { + Timber.w("DownloadManager rejected the app folder, downloading into its own storage") + downloadManager.enqueue(generateDeckDownloadRequest(fileToBeDownloaded, currentFileName)) + } + } + private fun generateDeckDownloadRequest( fileToBeDownloaded: DownloadFile, currentFileName: String, @@ -286,11 +312,6 @@ class SharedDecksDownloadFragment : Fragment() { request.setTitle(currentFileName) request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) - request.setDestinationInExternalFilesDir( - context, - null, - "$SHARED_DECKS_DOWNLOAD_FOLDER/$currentFileName", - ) return request } @@ -309,9 +330,9 @@ class SharedDecksDownloadFragment : Fragment() { Timber.i("Download might be complete now, verify and continue with import") /** - * @return The completed download's file, or null if it cannot be imported. + * @return The completed download's location, or null if it cannot be imported. */ - fun getImportableDeck(): File? { + fun getImportableDeck(): Uri? { if (fileName == null) { // Send ACRA report CrashReportService.sendExceptionReport( @@ -376,11 +397,11 @@ class SharedDecksDownloadFragment : Fragment() { onDownloadFinished(DownloadOutcome.Failed) return null } - return localUri.toUri().toFile() + return localUri.toUri() } } - val downloadedFile = + val downloadedDeck = try { getImportableDeck() } catch (exception: Exception) { @@ -389,20 +410,20 @@ class SharedDecksDownloadFragment : Fragment() { return } - if (downloadedFile == null) { + if (downloadedDeck == null) { // Could be a retryable fault (we received notification of another file) // Otherwise, onDownloadFinished should have been called // to update the UI return } - completedFile = downloadedFile + completedDeck = downloadedDeck // the progress checker can stop before it sees 100%, so complete it here viewModel.onDownloadComplete() Timber.i("Opening downloaded deck for import") - openDownloadedDeck(context, downloadedFile) + openDownloadedDeck(context, downloadedDeck) Timber.d("Download finished") onDownloadFinished(DownloadOutcome.Success) @@ -515,25 +536,30 @@ class SharedDecksDownloadFragment : Fragment() { */ private fun openDownloadedDeck( context: Context, - downloadedFile: File, + downloadedDeck: Uri, ) { - // A successful DownloadManager entry can outlive its file, e.g. after another import. - if (!downloadedFile.isFile) { - Timber.w("Downloaded shared deck no longer exists") - onDownloadFinished(DownloadOutcome.Failed) - return - } + val fileUri = + if (downloadedDeck.scheme == ContentResolver.SCHEME_FILE) { + val downloadedFile = downloadedDeck.toFile() + // A successful DownloadManager entry can outlive its file, e.g. after another import. + if (!downloadedFile.isFile) { + Timber.w("Downloaded shared deck no longer exists") + onDownloadFinished(DownloadOutcome.Failed) + return + } + FileProvider.getUriForFile( + context, + context.packageName + ".apkgfileprovider", + downloadedFile, + ) + } else { + downloadedDeck + } - val mimeType = URLConnection.guessContentTypeFromName(downloadedFile.name) + val mimeType = URLConnection.guessContentTypeFromName(fileName) val fileIntent = Intent(context, IntentHandler::class.java) fileIntent.action = Intent.ACTION_VIEW - val fileUri = - FileProvider.getUriForFile( - context, - context.packageName + ".apkgfileprovider", - downloadedFile, - ) Timber.d("File URI -> $fileUri") fileIntent.setDataAndType(fileUri, mimeType) fileIntent.flags = Intent.FLAG_ACTIVITY_NEW_TASK diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksDownloadScreen.kt b/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksDownloadScreen.kt index e95bf48da145..f3e9dfba3b59 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksDownloadScreen.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksDownloadScreen.kt @@ -45,6 +45,8 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -63,6 +65,7 @@ import com.ichi2.anki.CommonString import com.ichi2.anki.R import com.ichi2.compose.theme.AnkiDroidTheme import com.ichi2.compose.theme.dimensions +import com.ichi2.compose.theme.widgetBorder import com.ichi2.compose.ui.preview.ThemePreviews private val MaxContentWidth = 600.dp @@ -137,20 +140,36 @@ private fun ProgressRing( state: SharedDecksDownloadUiState, modifier: Modifier = Modifier, ) { + val border = MaterialTheme.widgetBorder + val colors = MaterialTheme.colorScheme + val trackColor = if (border != null) colors.surface else colors.surfaceVariant + val sizeTextColor = if (border != null) colors.onSurface else colors.onSurfaceVariant + val indicatorModifier = + Modifier.size(ProgressRingSize).drawWithContent { + drawContent() + if (border != null) { + // The thin outline distinguishes the track from the thicker progress arc. + drawCircle( + brush = border.brush, + radius = (size.minDimension - ProgressRingStroke.toPx()) / 2, + style = Stroke(width = border.width.toPx()), + ) + } + } Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) { val percent = state.percent if (percent == null) { CircularProgressIndicator( - modifier = Modifier.size(ProgressRingSize), + modifier = indicatorModifier, strokeWidth = ProgressRingStroke, - trackColor = MaterialTheme.colorScheme.surfaceVariant, + trackColor = trackColor, ) } else { CircularProgressIndicator( progress = { (percent / 100f).coerceIn(0f, 1f) }, - modifier = Modifier.size(ProgressRingSize), + modifier = indicatorModifier, strokeWidth = ProgressRingStroke, - trackColor = MaterialTheme.colorScheme.surfaceVariant, + trackColor = trackColor, ) } Column(horizontalAlignment = Alignment.CenterHorizontally) { @@ -163,7 +182,7 @@ private fun ProgressRing( Text( text = it, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = sizeTextColor, modifier = Modifier.padding(top = MaterialTheme.dimensions.space50), ) } @@ -329,12 +348,29 @@ private fun TonalCard( modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit, ) { - Card( - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), - elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), - modifier = modifier, - content = content, - ) + val border = MaterialTheme.widgetBorder + val colors = MaterialTheme.colorScheme + // Outlined e-ink cards use white surfaces and black content without changing the app palette. + val cardColors = + if (border != null) { + colors.copy( + surfaceVariant = colors.surface, + onSurfaceVariant = colors.onSurface, + tertiary = colors.onSurface, + onTertiary = colors.surface, + ) + } else { + colors + } + MaterialTheme(colorScheme = cardColors) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + elevation = CardDefaults.cardElevation(defaultElevation = 0.dp), + border = border, + modifier = modifier, + content = content, + ) + } } /** Warning shown while the download waits for a network connection. */ diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/study/DeckStudyState.kt b/AnkiDroid/src/main/java/com/ichi2/anki/study/DeckStudyState.kt new file mode 100644 index 000000000000..ae27cedbc06d --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/study/DeckStudyState.kt @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.study + +import anki.scheduler.CongratsInfoResponse +import com.ichi2.anki.common.time.SECONDS_PER_DAY +import com.ichi2.anki.libanki.Collection +import com.ichi2.anki.libanki.DeckId +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * Collection facts about the selected deck, independent of the screen displaying them. + * + * Contents and availability are separate: cards can be buried, limited, scheduled for later, + * or temporarily borrowed by a filtered deck. Completion details retain those distinctions. + */ +data class DeckStudyState( + val deckId: DeckId, + val kind: DeckKind, + /** Includes subdecks and cards temporarily borrowed by filtered decks. */ + val cardCount: Int, + val newCardsAvailable: Int, + val learningCardsAvailable: Int, + val reviewCardsAvailable: Int, + val congratulations: CongratsInfoResponse, +) { + val availability: StudyAvailability + get() = + if (newCardsAvailable + learningCardsAvailable + reviewCardsAvailable > 0) { + StudyAvailability.CardsAvailable + } else { + StudyAvailability.NoCardsAvailable + } + + val isEmptyRegularDeck: Boolean get() = kind == DeckKind.Regular && cardCount == 0 + + /** Time until learning becomes available, accounting for the learn-ahead limit. */ + val nextLearningIn: Duration? + get() = congratulations.secsUntilNextLearn.takeIf { it < SECONDS_PER_DAY }?.seconds +} + +enum class DeckKind { + Regular, + Filtered, +} + +enum class StudyAvailability { + CardsAvailable, + NoCardsAvailable, +} + +/** Read under the collection lock so identity, contents and scheduling refer to the same deck. */ +fun Collection.currentDeckStudyState(): DeckStudyState { + val deck = decks.current() + val counts = sched.counts() + return DeckStudyState( + deckId = deck.id, + kind = if (deck.isFiltered) DeckKind.Filtered else DeckKind.Regular, + cardCount = decks.cardCount(deck.id, includeSubdecks = true), + newCardsAvailable = counts.new, + learningCardsAvailable = counts.lrn, + reviewCardsAvailable = counts.rev, + congratulations = sched.congratulationsInfo(), + ) +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/study/StudyCompletionUi.kt b/AnkiDroid/src/main/java/com/ichi2/anki/study/StudyCompletionUi.kt new file mode 100644 index 000000000000..1dc4c5fdd303 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/study/StudyCompletionUi.kt @@ -0,0 +1,68 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.study + +import android.content.Context +import androidx.fragment.app.FragmentActivity +import com.ichi2.anki.CollectionManager.TR +import com.ichi2.anki.CollectionManager.withCol +import com.ichi2.anki.CommonString +import com.ichi2.anki.common.preferences.sharedPrefs +import com.ichi2.anki.common.time.TIME_HOUR +import com.ichi2.anki.common.time.TIME_MINUTE +import com.ichi2.anki.common.utils.android.showThemedToast +import com.ichi2.anki.launchCatchingTask +import com.ichi2.anki.pages.CongratsPage +import com.ichi2.anki.snackbar.showSnackbar +import kotlin.math.round + +/** Presents the end of a review session in the activity that owns its result. */ +object StudyCompletionUi { + fun openCongratsPage(activity: FragmentActivity) { + activity.startActivity(CongratsPage.getIntent(activity)) + } + + fun displayFromShortcut(activity: FragmentActivity) { + if (displayNewCongratsScreen(activity)) { + openCongratsPage(activity) + } else { + activity.launchCatchingTask { + val state = withCol { currentDeckStudyState() } + showThemedToast(activity, finishedMessage(activity, state), false) + } + } + } + + fun onReviewsCompleted( + activity: FragmentActivity, + state: DeckStudyState, + ) { + if (displayNewCongratsScreen(activity)) { + openCongratsPage(activity) + return + } + when (state.availability) { + StudyAvailability.NoCardsAvailable -> activity.showSnackbar(finishedMessage(activity, state)) + StudyAvailability.CardsAvailable -> activity.showSnackbar(CommonString.studyoptions_no_cards_due) + } + } + + private fun displayNewCongratsScreen(context: Context) = context.sharedPrefs().getBoolean("new_congrats_screen", false) + + private fun finishedMessage( + context: Context, + state: DeckStudyState, + ): String { + val seconds = state.nextLearningIn?.inWholeSeconds ?: return context.getString(CommonString.studyoptions_congrats_finished) + val (unit, amount) = + if (seconds < TIME_MINUTE) { + "seconds" to seconds.toDouble() + } else if (seconds < TIME_HOUR) { + "minutes" to seconds / TIME_MINUTE + } else { + "hours" to seconds / TIME_HOUR + } + val nextLearnDue = TR.schedulingNextLearnDue(unit, round(amount).toInt()) + return context.getString(CommonString.studyoptions_congrats_next_due_in, nextLearnDue) + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt index 31d528a4b9a4..79a513faf39c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt @@ -10,6 +10,7 @@ import com.ichi2.anki.libanki.Tags import com.ichi2.anki.observability.undoableOp import com.ichi2.anki.tags.ManageTagsState.Error import com.ichi2.anki.tags.UserMessage.ClearedUnusedTags +import com.ichi2.anki.tags.UserMessage.TagMoved import com.ichi2.anki.tags.UserMessage.TagRemoved import com.ichi2.anki.tags.UserMessage.TagRenamed import com.ichi2.anki.utils.MessageQueue @@ -83,6 +84,9 @@ class ManageTagsViewModel : ViewModel() { /** Acknowledges that the UI has finished displaying a message. */ fun messageShown(id: MessageId) = messageQueue.acknowledge(id) + /** Checks the complete tag tree, including tags hidden by search or collapsed ancestors. */ + fun tagExists(tag: TagName): Boolean = tagList.any { it.fullTag == tag } + /** Returns the visible node for [tag], or null with a warning if not found or not loaded */ private fun findVisibleNode(tag: TagName): TagListItemState? { val loaded = state.value as? ManageTagsState.Content @@ -113,7 +117,7 @@ class ManageTagsViewModel : ViewModel() { return@launchTagOperation } val newCollapsed = !node.collapsed - withCol { tags.setCollapsed(tag, newCollapsed) } + withCol { tags.setCollapsed(node.fullTag, newCollapsed) } // Update the in-memory list with the new collapsed state tagList = tagList.map { @@ -133,14 +137,14 @@ class ManageTagsViewModel : ViewModel() { } /** - * Renames [oldName] to [newName], updating all notes and child tags. + * Renames [oldName] to [newName] on notes, including matching child tags. * @see Tags.rename */ fun renameTag( oldName: TagName, newName: TagName, ) = launchTagOpAndRefresh { - val result = undoableOp { tags.rename(oldName, newName) } + val result = undoableOp { renameTagTree(oldName, newName) } messageQueue.enqueue(TagRenamed(result.count)) } @@ -155,12 +159,27 @@ class ManageTagsViewModel : ViewModel() { messageQueue.enqueue(ClearedUnusedTags(result.count)) } + /** + * Reparents [tag] and its descendants on notes under [newParent]. An empty [newParent] moves + * [tag] to the top level. + * @see moveTagTree + */ + fun moveTag( + tag: TagName, + newParent: TagName?, + ) = launchTagOpAndRefresh { + Timber.i("Moving tag") + val result = undoableOp { moveTagTree(tag, newParent) } + messageQueue.enqueue(TagMoved(result.count)) + } + private suspend fun loadTags() { Timber.i("Loading tags from collection") tagList = flattenTree(withCol { tags.tree() }) state.value = ManageTagsState.Content( visibleNodes = computeVisibleNodes(searchQuery.value), + totalTagCount = tagList.size, ) } @@ -349,12 +368,16 @@ data class TagListItemState( sealed class ManageTagsState { data object Loading : ManageTagsState() + /** + * @param totalTagCount every tag in the collection, unaffected by search/collapse + */ data class Content( val visibleNodes: List, /** `true` while a tag operation runs, allowing the UI to show progress without hiding the list. */ val isWorking: Boolean = false, /** Most recent operation failure, cleared when the next operation starts. */ val error: Throwable? = null, + val totalTagCount: Int = 0, ) : ManageTagsState() data class Error( @@ -373,6 +396,11 @@ sealed interface UserMessage { val notesAffected: Int, ) : UserMessage + /** @param notesAffected number of notes whose tags were reparented */ + data class TagMoved( + val notesAffected: Int, + ) : UserMessage + /** @param count number of unused tags removed from the collection */ data class ClearedUnusedTags( val count: Int, diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/tags/TagName.kt b/AnkiDroid/src/main/java/com/ichi2/anki/tags/TagName.kt index b4962bb3674f..ce89de1bfc15 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/tags/TagName.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/tags/TagName.kt @@ -2,20 +2,31 @@ package com.ichi2.anki.tags +import android.icu.lang.UCharacter import anki.collection.OpChanges import anki.collection.OpChangesWithCount import com.ichi2.anki.libanki.Tags +import com.ichi2.anki.libanki.utils.RustCompat import timber.log.Timber +import java.text.Normalizer /** * A tag name, e.g. "science::biology". * + * Equality, hashing and hierarchy checks use normalized Unicode case folding. [value] retains + * the name's case for display and case-only renames; compare it explicitly when + * checking whether the spelling changed. + * * Tag names should not be logged using `Timber.i` or above to avoid leaking PII to crash reports. */ -@JvmInline -value class TagName private constructor( +class TagName private constructor( val value: String, ) { + private val identity = foldCase(normalize(value)) + + /** Includes [root] itself; `a::b` is a descendant of `a`, but `ab` is not. */ + fun isSameOrDescendantOf(root: TagName): Boolean = identity == root.identity || identity.startsWith("${root.identity}::") + /** Ancestor tags, e.g. `TagName.build("a::b::c").ancestors` returns `["a", "a::b"]` */ val ancestors: List get() { @@ -32,6 +43,10 @@ value class TagName private constructor( } } + override fun equals(other: Any?): Boolean = other is TagName && identity == other.identity + + override fun hashCode(): Int = identity.hashCode() + override fun toString(): String = value companion object { @@ -41,6 +56,38 @@ value class TagName private constructor( // But "_" is converted to " " in some visual displays return TagName(tagName.replace(" ", "_")) } + + /** Match the three-character expansion order in Anki's pinned unicase 2.6.0. */ + private fun foldCase(spelling: String): String = + buildString { + var offset = 0 + while (offset < spelling.length) { + val codePoint = spelling.codePointAt(offset) + val folded = UCharacter.foldCase(String(Character.toChars(codePoint)), true) + // unicase's Fold::Three yields the third character before the first two. + // Fold each input character separately: literal "ffi" must stay "ffi", + // while the ligature "ffi" must become "iff". + if (folded.codePointCount(0, folded.length) == 3) { + val last = folded.offsetByCodePoints(0, 2) + append(folded, last, folded.length) + append(folded, 0, last) + } else { + append(folded) + } + offset += Character.charCount(codePoint) + } + } + + // Mirror tags/register.rs for identity only. Preserve the supplied spelling + // and leave mutation validation (e.g. forbidden separators) to the backend. + private fun normalize(spelling: String): String = + spelling.split("::").joinToString("::") { component -> + Normalizer + .normalize(component, Normalizer.Form.NFC) + .filterNot { it <= '\u001f' || it == '\u007f' || it == '\u3000' } + .trim(RustCompat::isWhitespace) + .ifEmpty { "blank" } + } } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/tags/TagTreeOperations.kt b/AnkiDroid/src/main/java/com/ichi2/anki/tags/TagTreeOperations.kt new file mode 100644 index 000000000000..9de59f30336c --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/tags/TagTreeOperations.kt @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.tags + +import anki.collection.OpChangesWithCount +import anki.collection.copy +import anki.collection.opChangesWithCount +import com.ichi2.anki.libanki.Collection + +/** + * Renames tags on notes, repairing duplicates when subtrees merge. + * + * Works around [Anki issue 5857](https://github.com/ankitects/anki/issues/5857). + * + * Follows Anki's backend behavior: an entirely unused subtree is left unchanged. When + * renaming a subtree used by notes, unused cached descendants are removed, not renamed. + * See https://github.com/ankitects/anki/blob/main/rslib/src/tags/rename.rs. + */ +internal fun Collection.renameTagTree( + oldName: TagName, + newName: TagName, +): OpChangesWithCount { + val affectedNotes = findNotes(oldName.literalSubtreeSearch()) + val result = tags.rename(oldName, newName) + if (result.count == 0) return result + + // Renaming into an existing tag can leave duplicate tags on a note. Saving only those + // notes canonifies their tags and keeps unrelated notes untouched. + val duplicates = affectedNotes.map(::getNote).filter { note -> note.tags.distinctBy(TagName::build).size != note.tags.size } + if (duplicates.isEmpty()) return result + + val undoStep = undoStatus().lastStep + duplicates.forEach { it.tags = it.tags.distinctBy(TagName::build).toMutableList() } + // The combined changes from mergeUndoEntries below include these note updates. + @Suppress("CheckResult") + updateNotes(duplicates) + return result.copy { changes = mergeUndoEntries(undoStep) } +} + +/** Move tags on notes, merging overlapping subtrees with the same semantics as [renameTagTree]. */ +internal fun Collection.moveTagTree( + tag: TagName, + newParent: TagName?, +): OpChangesWithCount { + require(newParent == null || !newParent.isSameOrDescendantOf(tag)) { "A tag cannot be moved under itself" } + val leaf = tag.value.substringAfterLast("::") + val destination = TagName.build(newParent?.let { "${it.value}::$leaf" } ?: leaf)!! + if (destination == tag) return opChangesWithCount { } + // Moving one subtree is a prefix rename. The backend reparent operation also rejects + // valid sibling prefixes (e.g. parent::a beneath parent::ab). + return renameTagTree(tag, destination) +} + +/** A literal regex avoids tag-search wildcards and reserved names such as "none". */ +private fun TagName.literalSubtreeSearch(): String { + val escaped = + buildString { + for (char in value) { + if (char in "\\.^$|?*+()[]{}") append('\\') + append(char) + } + }.replace("\"", "\\\"") + return "\"tag:re:^$escaped(?:::|$)\"" +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt index 6837ad870d09..3a7db00cdfed 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt @@ -396,11 +396,12 @@ class RecyclerFastScroller true } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - handle.isSelected = false + handle.isPressed = false if (recyclerView != null) { recyclerView?.let { removeCallbacks(scrollTask) } scrollTask.run() } + postAutoHide() false } else -> super.onTouchEvent(event) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/ResizablePaneManager.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/ResizablePaneManager.kt index cabe100ae948..73fc5ce1e25c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/ResizablePaneManager.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/ResizablePaneManager.kt @@ -9,7 +9,9 @@ import android.view.PointerIcon import android.view.View import android.widget.LinearLayout import androidx.core.content.edit +import androidx.core.view.doOnPreDraw import com.ichi2.anki.R +import com.ichi2.utils.dp import timber.log.Timber /** @@ -24,7 +26,6 @@ class ResizablePaneManager( private val sharedPrefs: SharedPreferences, private val startPaneWeightKey: String, private val endPaneWeightKey: String, - private val minWeight: Float = 0.5f, // Minimum weight for each pane private val dragColor: Int = divider.context.getColor(R.color.drag_divider_color), private val idleColor: Int = divider.context.getColor(R.color.idle_divider_color), private val onResizingChanged: (Boolean) -> Unit = {}, @@ -36,6 +37,10 @@ class ResizablePaneManager( private fun setupResizableDivider() { // Load saved weights if available loadSavedWeights() + parentLayout.doOnPreDraw { clampWeights() } + parentLayout.addOnLayoutChangeListener { _, left, _, right, _, oldLeft, _, oldRight, _ -> + if (right - left != oldRight - oldLeft) parentLayout.doOnPreDraw { clampWeights() } + } var initialTouchX = 0f var initialStartWeight = 0f @@ -97,6 +102,7 @@ class ResizablePaneManager( // Clamp the new weight for the start pane // Ensures it's not too small and not too large (leaving space for the other pane's minWeight) + val minWeight = minWeight(sumOfInitialWeights) newStartWeight = newStartWeight.coerceIn(minWeight, sumOfInitialWeights - minWeight) val newEndWeight = sumOfInitialWeights - newStartWeight @@ -151,6 +157,33 @@ class ResizablePaneManager( Timber.w(e, "Failed to load saved pane weights") } } + + /** The weight that keeps a pane [MIN_PANE_WIDTH] wide, or half of [totalWeight] if two of those don't fit */ + private fun minWeight(totalWeight: Float): Float { + val paneSpace = parentLayout.width - divider.width + if (paneSpace <= 0) return 0f + val minPaneWidth = MIN_PANE_WIDTH.toPx(parentLayout.context) * parentLayout.resources.configuration.fontScale + return (totalWeight * minPaneWidth / paneSpace).coerceAtMost(totalWeight / 2) + } + + /** Moves the divider back within the limits when a saved split or a narrower screen leaves a pane too small */ + private fun clampWeights() { + val startParams = startPane.layoutParams as LinearLayout.LayoutParams + val endParams = endPane.layoutParams as LinearLayout.LayoutParams + val totalWeight = startParams.weight + endParams.weight + val minWeight = minWeight(totalWeight) + val clampedStartWeight = startParams.weight.coerceIn(minWeight, totalWeight - minWeight) + if (clampedStartWeight == startParams.weight) return + startParams.weight = clampedStartWeight + endParams.weight = totalWeight - clampedStartWeight + startPane.layoutParams = startParams + endPane.layoutParams = endParams + } + + companion object { + /** Narrowest a pane can be, times the font scale: the panes hold text, which needs more room at a larger font size */ + private val MIN_PANE_WIDTH = 240.dp + } } private fun Float.invertIfRtl(view: View): Float = if (view.layoutDirection == View.LAYOUT_DIRECTION_RTL) -this else this 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/anki/utils/ext/Flow.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Flow.kt index 5dcd982c60a8..33b471acaf44 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Flow.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Flow.kt @@ -49,6 +49,20 @@ fun Flow.launchCollectionInLifecycleScope(block: suspend (T) -> Unit) { } } +/** + * Collects while the fragment's current view is started. Call from [Fragment.onViewCreated]. + */ +context(fragment: Fragment) +fun Flow.launchCollectionInViewLifecycleScope(block: suspend (T) -> Unit): Job { + // Capture the owner before launching: the view may be destroyed or recreated before the job starts. + val owner = fragment.viewLifecycleOwner + return owner.lifecycleScope.launch { + owner.repeatOnLifecycle(Lifecycle.State.STARTED) { + this@launchCollectionInViewLifecycleScope.collect(block) + } + } +} + // Workaround a bug in overload resolution. Replace with the following when the compiler is fixed: // state: Lifecycle.State = Lifecycle.State.STARTED context(activity: AnkiActivity) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncWorker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncWorker.kt index 2247b215e391..31d638e8508b 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncWorker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncWorker.kt @@ -19,6 +19,7 @@ import androidx.work.WorkManager import androidx.work.WorkerParameters import anki.collection.Progress import anki.sync.SyncCollectionResponse +import com.ichi2.anki.BackupManager import com.ichi2.anki.CollectionManager import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CollectionManager.withCol @@ -27,11 +28,15 @@ import com.ichi2.anki.NotificationChannel import com.ichi2.anki.R import com.ichi2.anki.cancelSync import com.ichi2.anki.common.permissions.canPostNotifications +import com.ichi2.anki.libanki.Collection import com.ichi2.anki.notifications.NotificationId +import com.ichi2.anki.reopen import com.ichi2.anki.setLastSyncTimeToNow import com.ichi2.anki.settings.Prefs +import com.ichi2.anki.sync.MediaSync import com.ichi2.anki.sync.MeteredSyncPolicy import com.ichi2.anki.sync.SyncAuth +import com.ichi2.anki.sync.fullUploadOrDownload import com.ichi2.anki.sync.syncCollection import com.ichi2.anki.utils.ext.trySetForeground import kotlinx.coroutines.Dispatchers @@ -56,7 +61,8 @@ val syncLock = Mutex() * * Media is synced by enqueueing a [SyncMediaWorker] work, which doesn't block the collection. * - * Note that one-way syncs will be ignored, since they require user input. + * Full uploads and downloads run when the server specifies the direction. + * Conflicts requiring the user to choose a direction are ignored. * * That is useful when the user isn't interacting with * the app, like when doing an automatic sync after leaving the app. @@ -102,12 +108,13 @@ class SyncWorker( } Timber.d("SyncWorker: showing failure notification") return Result.failure() + } finally { + setLastSyncTimeToNow() } Timber.d("SyncWorker: cancelling progress notification (sync completed)") notificationManager?.cancel(NotificationId.SYNC) Timber.d("SyncWorker: success") - setLastSyncTimeToNow() return Result.success() } @@ -116,7 +123,7 @@ class SyncWorker( syncMedia: Boolean, ) { Timber.v("SyncWorker::syncCollection") - val response = + val syncAuth = coroutineScope { val monitor = launch(Dispatchers.IO) { @@ -134,41 +141,64 @@ class SyncWorker( } try { withCol { - syncCollection(auth, syncMedia = false) + val response = syncCollection(auth, syncMedia = false) + val syncAuth = + if (response.hasNewEndpoint() && response.newEndpoint.isNotEmpty()) { + Prefs.currentSyncUri = response.newEndpoint + auth.withEndpoint(response.newEndpoint) + } else { + auth + } + Timber.i("Sync required: %s", response.required) + // Keep the direction decision and transfer on the queue so the collection + // cannot change between checking whether it is empty and replacing it. + when (response.required) { + SyncCollectionResponse.ChangesRequired.NO_CHANGES -> _loadScheduler() + SyncCollectionResponse.ChangesRequired.FULL_DOWNLOAD -> fullSync(syncAuth, upload = false) + SyncCollectionResponse.ChangesRequired.FULL_UPLOAD -> fullSync(syncAuth, upload = true) + SyncCollectionResponse.ChangesRequired.FULL_SYNC -> { + Timber.d("Sync direction required: Skipping background sync") + return@withCol null + } + SyncCollectionResponse.ChangesRequired.UNRECOGNIZED, + SyncCollectionResponse.ChangesRequired.NORMAL_SYNC, + null, + -> TODO("should never happen") + } + syncAuth } } finally { Timber.d("Collection sync completed. Cancelling monitor...") // this doesn't wait for completion; the outer `coroutineScope` does. monitor.cancel() } + } ?: return + if (syncMedia) syncMedia(syncAuth) + } + + private fun Collection.fullSync( + auth: SyncAuth, + upload: Boolean, + ) { + val title = if (upload) TR.syncUploadingToAnkiweb() else TR.syncDownloadingFromAnkiweb() + notify(getProgressNotification(title)) + if (!upload) { + val created = + createBackup( + BackupManager.getBackupDirectoryFromCollection(colDb), + force = true, + waitForCompletion = true, + ) + if (!created) { + Timber.i("SyncWorker: Backup skipped before full download: collection unchanged") } - Timber.i("Sync required: %s", response.required) - when (response.required) { - // a successful sync returns this value - SyncCollectionResponse.ChangesRequired.NO_CHANGES -> { - withCol { _loadScheduler() } // scheduler version may have changed - if (!syncMedia) return - val syncAuth = - if (response.hasNewEndpoint() && response.newEndpoint.isNotEmpty()) { - Prefs.currentSyncUri = response.newEndpoint - auth.withEndpoint(response.newEndpoint) - } else { - auth - } - syncMedia(syncAuth) - } - SyncCollectionResponse.ChangesRequired.FULL_SYNC, - SyncCollectionResponse.ChangesRequired.FULL_DOWNLOAD, - SyncCollectionResponse.ChangesRequired.FULL_UPLOAD, - -> { - Timber.d("One-way sync required: Skipping background sync") - } - SyncCollectionResponse.ChangesRequired.UNRECOGNIZED, - SyncCollectionResponse.ChangesRequired.NORMAL_SYNC, - null, - -> { - TODO("should never happen") - } + } + close(downgrade = false, forFullSync = true) + try { + // Media starts separately so WorkManager can enforce its network constraints. + fullUploadOrDownload(auth, upload = upload, mediaSync = MediaSync.Skip) + } finally { + reopen(afterFullSync = true) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/compose/theme/Borders.kt b/AnkiDroid/src/main/java/com/ichi2/compose/theme/Borders.kt new file mode 100644 index 000000000000..f5bd463bad4e --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/compose/theme/Borders.kt @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.compose.theme + +import androidx.compose.foundation.BorderStroke +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import androidx.core.content.withStyledAttributes +import com.ichi2.anki.R + +/** The XML theme's widget outline; e-ink uses it to distinguish white surfaces. */ +val MaterialTheme.widgetBorder: BorderStroke? + @Composable + get() { + val context = LocalContext.current + val density = LocalDensity.current + return remember(context, density) { + var border: BorderStroke? = null + context.withStyledAttributes(attrs = intArrayOf(R.attr.widgetStrokeWidth, R.attr.widgetStrokeColor)) { + val width = with(density) { getDimension(0, 0f).toDp() } + if (width > 0.dp) { + border = BorderStroke(width, Color(getColor(1, android.graphics.Color.TRANSPARENT))) + } + } + border + } + } 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..7a4c78027a43 100644 --- a/AnkiDroid/src/main/java/com/ichi2/imagecropper/ImageCropper.kt +++ b/AnkiDroid/src/main/java/com/ichi2/imagecropper/ImageCropper.kt @@ -68,7 +68,7 @@ class ImageCropper : cropRect = Rect(100, 300, 500, 1200) } val originalImageUri = - BundleCompat.getParcelable(requireArguments(), CROP_IMAGE_URI, Uri::class.java) + BundleCompat.getParcelable(requireArguments(), ARG_IMAGE_URI, Uri::class.java) ?: error("No image identifier was provided for cropping") viewLifecycleOwner.lifecycleScope.launch { withProgress { @@ -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) }, @@ -220,12 +220,12 @@ class ImageCropper : /** * The key for the original image URI passed as an argument. */ - const val CROP_IMAGE_URI = "image_uri" + const val ARG_IMAGE_URI = "image_uri" /** * 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/imagecropper/ImageCropperLauncher.kt b/AnkiDroid/src/main/java/com/ichi2/imagecropper/ImageCropperLauncher.kt index 1a0165fd1b86..f0079869ea3f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/imagecropper/ImageCropperLauncher.kt +++ b/AnkiDroid/src/main/java/com/ichi2/imagecropper/ImageCropperLauncher.kt @@ -36,7 +36,7 @@ sealed interface ImageCropperLauncher { ) : ImageCropperLauncher { override fun toBundle(): Bundle = Bundle().apply { - putParcelable(ImageCropper.CROP_IMAGE_URI, imageUri) + putParcelable(ImageCropper.ARG_IMAGE_URI, imageUri) } } } diff --git a/AnkiDroid/src/main/java/com/ichi2/preferences/ControlPreference.kt b/AnkiDroid/src/main/java/com/ichi2/preferences/ControlPreference.kt index 8c33d7626ca8..ca0f3028c484 100644 --- a/AnkiDroid/src/main/java/com/ichi2/preferences/ControlPreference.kt +++ b/AnkiDroid/src/main/java/com/ichi2/preferences/ControlPreference.kt @@ -221,7 +221,7 @@ open class ControlPreferenceDialogFragment : DialogFragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val key = requireArguments().requireString(SettingsFragment.PREF_DIALOG_KEY) + val key = requireArguments().requireString(SettingsFragment.ARG_PREFERENCE_KEY) preference = (targetFragment as PreferenceFragmentCompat).requirePreference(key) } diff --git a/AnkiDroid/src/main/java/com/ichi2/preferences/ListPreferenceTrait.kt b/AnkiDroid/src/main/java/com/ichi2/preferences/ListPreferenceTrait.kt index 73c38d4e162c..7f9a0fcdf75a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/preferences/ListPreferenceTrait.kt +++ b/AnkiDroid/src/main/java/com/ichi2/preferences/ListPreferenceTrait.kt @@ -95,17 +95,17 @@ class ListPreferenceDialogFragment : PreferenceDialogFragmentCompat() { entries = preference.entryKeys.toTypedArray() entryValues = preference.entryValues.toTypedArray() } else { - clickedDialogEntryIndex = savedInstanceState.getInt(SAVE_STATE_INDEX, 0) - entries = savedInstanceState.getCharSequenceArray(SAVE_STATE_ENTRIES)!! - entryValues = savedInstanceState.getCharSequenceArray(SAVE_STATE_ENTRY_VALUES)!! + clickedDialogEntryIndex = savedInstanceState.getInt(STATE_INDEX, 0) + entries = savedInstanceState.getCharSequenceArray(STATE_ENTRIES)!! + entryValues = savedInstanceState.getCharSequenceArray(STATE_ENTRY_VALUES)!! } } override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) - outState.putInt(SAVE_STATE_INDEX, clickedDialogEntryIndex) - outState.putCharSequenceArray(SAVE_STATE_ENTRIES, entries) - outState.putCharSequenceArray(SAVE_STATE_ENTRY_VALUES, entryValues) + outState.putInt(STATE_INDEX, clickedDialogEntryIndex) + outState.putCharSequenceArray(STATE_ENTRIES, entries) + outState.putCharSequenceArray(STATE_ENTRY_VALUES, entryValues) } private val listPreference get() = preference as ListPreferenceTrait @@ -142,9 +142,9 @@ class ListPreferenceDialogFragment : PreferenceDialogFragmentCompat() { } companion object { - private const val SAVE_STATE_INDEX = "ListPreferenceDialogFragment.index" - private const val SAVE_STATE_ENTRIES = "ListPreferenceDialogFragment.entries" - private const val SAVE_STATE_ENTRY_VALUES = "ListPreferenceDialogFragment.entryValues" + private const val STATE_INDEX = "ListPreferenceDialogFragment.index" + private const val STATE_ENTRIES = "ListPreferenceDialogFragment.entries" + private const val STATE_ENTRY_VALUES = "ListPreferenceDialogFragment.entryValues" fun newInstance(key: String?): ListPreferenceDialogFragment = ListPreferenceDialogFragment().apply { diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/ExceptionUtil.kt b/AnkiDroid/src/main/java/com/ichi2/utils/ExceptionUtil.kt index f62214ff7a5a..524043f48838 100644 --- a/AnkiDroid/src/main/java/com/ichi2/utils/ExceptionUtil.kt +++ b/AnkiDroid/src/main/java/com/ichi2/utils/ExceptionUtil.kt @@ -9,8 +9,33 @@ import com.ichi2.anki.common.crashreporting.CrashReportService import com.ichi2.anki.common.utils.android.showThemedToast import java.io.PrintWriter import java.io.StringWriter +import java.util.Collections +import java.util.IdentityHashMap object ExceptionUtil { + /** This throwable followed lazily by its causes. Cyclic chains repeat instances indefinitely. */ + internal val Throwable.causeChain: Sequence + get() = generateSequence(this) { it.cause } + + /** + * Produces a sequence of this throwable and all throwables reachable through + * [Throwable.cause] or [Throwable.getSuppressed]. + */ + internal fun Throwable.causesAndSuppressed(): Sequence = + sequence { + val seen = Collections.newSetFromMap(IdentityHashMap()) + val pending = ArrayDeque() + pending.add(this@causesAndSuppressed) + while (pending.isNotEmpty()) { + val throwable = pending.removeLast() + if (!seen.add(throwable)) continue + yield(throwable) + // Visit causes first so shared suppressed references cannot truncate the cause chain. + pending.addAll(throwable.suppressed) + throwable.cause?.let { pending.add(it) } + } + } + @CheckResult fun getExceptionMessage(e: Throwable?): String = getExceptionMessage(e, "\n") diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/ImportUtils.kt b/AnkiDroid/src/main/java/com/ichi2/utils/ImportUtils.kt index b5443720bb4c..ff5b282380c4 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) @@ -432,6 +430,14 @@ object ImportUtils { // The selected file may have been deleted since the provider returned its URI. Timber.w(e, "Import source file is unavailable") CacheFileResult.SourceFileUnavailable(e) + } catch (e: IllegalStateException) { + if (e.message?.contains("pending/trashed") == true) { + Timber.w(e, "Import source file is pending or trashed") + CacheFileResult.SourceFileUnavailable(e) + } else { + Timber.w("Could not open import source") + CacheFileResult.Error(e) + } } catch (e: Exception) { Timber.w("Could not open import source") CacheFileResult.Error(e) @@ -462,7 +468,7 @@ object ImportUtils { ) : CacheFileResult() data class SourceFileUnavailable( - val exception: FileNotFoundException, + val exception: Exception, ) : CacheFileResult() data object ContentProviderCrashed : CacheFileResult() diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/WidgetConfigDestination.kt b/AnkiDroid/src/main/java/com/ichi2/widget/WidgetConfigDestination.kt new file mode 100644 index 000000000000..deb5cfb1d287 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/widget/WidgetConfigDestination.kt @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.widget + +import android.appwidget.AppWidgetManager +import android.content.Context +import android.content.Intent +import com.ichi2.anki.common.destinations.WidgetConfigDestination +import com.ichi2.widget.cardanalysis.CardAnalysisWidgetConfig +import com.ichi2.widget.deckpicker.DeckPickerWidgetConfig + +fun WidgetConfigDestination.toIntent(context: Context): Intent { + val activity = + when (this) { + is WidgetConfigDestination.CardAnalysis -> CardAnalysisWidgetConfig::class.java + is WidgetConfigDestination.DeckPicker -> DeckPickerWidgetConfig::class.java + } + return Intent(context, activity).apply { + putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId) + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidgetConfig.kt b/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidgetConfig.kt index 735464be36df..5e8320f7c2df 100644 --- a/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidgetConfig.kt +++ b/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidgetConfig.kt @@ -90,7 +90,7 @@ class CardAnalysisWidgetConfig : AnkiActivity(R.layout.activity_card_analysis_wi deck = BundleCompat.getParcelable( savedInstanceState, - KEY_DECK, + STATE_DECK, SelectableDeck.Deck::class.java, ) binding.deckName.text = deck?.name @@ -109,7 +109,7 @@ class CardAnalysisWidgetConfig : AnkiActivity(R.layout.activity_card_analysis_wi override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) - outState.putParcelable(KEY_DECK, deck) + outState.putParcelable(STATE_DECK, deck) } override fun onDestroy() { @@ -209,6 +209,6 @@ class CardAnalysisWidgetConfig : AnkiActivity(R.layout.activity_card_analysis_wi } companion object { - private const val KEY_DECK = "key_deck" + private const val STATE_DECK = "key_deck" } } 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/drawable/note_types_selection_background.xml b/AnkiDroid/src/main/res/drawable/note_types_selection_background.xml index dde937930c75..dce7a1bbb6ac 100644 --- a/AnkiDroid/src/main/res/drawable/note_types_selection_background.xml +++ b/AnkiDroid/src/main/res/drawable/note_types_selection_background.xml @@ -1,5 +1,6 @@ + - \ No newline at end of file + diff --git a/AnkiDroid/src/main/res/layout/activity_manage_note_types.xml b/AnkiDroid/src/main/res/layout/activity_manage_note_types.xml index 1af245186d7a..99639653d12e 100644 --- a/AnkiDroid/src/main/res/layout/activity_manage_note_types.xml +++ b/AnkiDroid/src/main/res/layout/activity_manage_note_types.xml @@ -49,7 +49,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="8dp" - android:textColor="?attr/colorOnPrimary" + android:textColor="?attr/actionBarTextColor" android:textAppearance="?textAppearanceBodyMediumEmphasized" tools:text="5 note types selected" /> @@ -59,7 +59,7 @@ android:layout_height="wrap_content" app:icon="@drawable/ic_delete" style="@style/Widget.Material3.Button.IconButton" - app:iconTint="@color/white"/> + app:iconTint="?attr/actionBarTextColor"/> + app:iconTint="?attr/actionBarTextColor"/> + app:backgroundTint="?attr/fab_normal"/> \ 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/main/res/values/preferences.xml b/AnkiDroid/src/main/res/values/preferences.xml index 9d79985089ed..f5a27977dc47 100644 --- a/AnkiDroid/src/main/res/values/preferences.xml +++ b/AnkiDroid/src/main/res/values/preferences.xml @@ -212,7 +212,6 @@ resetLanguages notificationsScreen - minimumCardsDueForNotification widgetVibrate widgetBlink @@ -233,7 +232,6 @@ debug_corrupt_fsrs_params new_congrats_screen newReviewerOptions - newReviewReminders devOptionsEnabledByUser workInProgressDevOptions reviewerPort diff --git a/AnkiDroid/src/main/res/values/styles.xml b/AnkiDroid/src/main/res/values/styles.xml index f5ba1bb96faa..a150c5a11280 100644 --- a/AnkiDroid/src/main/res/values/styles.xml +++ b/AnkiDroid/src/main/res/values/styles.xml @@ -236,7 +236,7 @@ diff --git a/AnkiDroid/src/main/res/values/theme_black.xml b/AnkiDroid/src/main/res/values/theme_black.xml index f8164534279c..fc6503b5a6ab 100644 --- a/AnkiDroid/src/main/res/values/theme_black.xml +++ b/AnkiDroid/src/main/res/values/theme_black.xml @@ -12,6 +12,11 @@ @color/black @color/material_blue_500 #252525 + #252525 + #CCCCCC + #555555 + #CCCCCC + #000000 #151515 @color/text_color_dark @color/text_color_secondary_dark diff --git a/AnkiDroid/src/main/res/values/theme_dark.xml b/AnkiDroid/src/main/res/values/theme_dark.xml index b1d5eccc3865..527000c78f59 100644 --- a/AnkiDroid/src/main/res/values/theme_dark.xml +++ b/AnkiDroid/src/main/res/values/theme_dark.xml @@ -25,6 +25,11 @@ @color/material_theme_grey @color/material_theme_grey #3C5A6E + #263E50 + #B6D8EF + #5B819B + #78CAFF + #00324D @color/white ?attr/colorSurfaceContainer diff --git a/AnkiDroid/src/main/res/values/theme_eink.xml b/AnkiDroid/src/main/res/values/theme_eink.xml index f3a0bde56ea7..f11a4ae4b1e4 100644 --- a/AnkiDroid/src/main/res/values/theme_eink.xml +++ b/AnkiDroid/src/main/res/values/theme_eink.xml @@ -15,6 +15,11 @@ @android:color/white @color/black #CCCCCC + #EEEEEE + #444444 + #777777 + #444444 + #FFFFFF @color/white @color/black @color/black diff --git a/AnkiDroid/src/main/res/values/theme_light.xml b/AnkiDroid/src/main/res/values/theme_light.xml index bf1736b81336..a7b87fb32663 100644 --- a/AnkiDroid/src/main/res/values/theme_light.xml +++ b/AnkiDroid/src/main/res/values/theme_light.xml @@ -12,6 +12,11 @@ @color/white @color/white #C1E7FF + #D6ECFA + #36566F + #9BBDD4 + #006DA8 + #FFFFFF ?attr/colorSurfaceContainer #E4F5FD diff --git a/AnkiDroid/src/main/res/values/theme_plain.xml b/AnkiDroid/src/main/res/values/theme_plain.xml index fe6fd65760af..b918a59a23b6 100644 --- a/AnkiDroid/src/main/res/values/theme_plain.xml +++ b/AnkiDroid/src/main/res/values/theme_plain.xml @@ -13,6 +13,11 @@ @color/theme_plain_accent #0F9E9E9E #DDDDDD + #EEEEEE + #555555 + #BBBBBB + #555555 + #FFFFFF #F4F4F4 @color/text_color_light @color/text_color_secondary_light diff --git a/AnkiDroid/src/main/resources/META-INF/services/org.acra.collector.Collector b/AnkiDroid/src/main/resources/META-INF/services/org.acra.collector.Collector new file mode 100644 index 000000000000..f48bdc38bd9e --- /dev/null +++ b/AnkiDroid/src/main/resources/META-INF/services/org.acra.collector.Collector @@ -0,0 +1,3 @@ +# SPDX-License-Identifier: GPL-3.0-or-later + +com.ichi2.anki.exception.SanitizedReportCollector 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/AcraCrashReporterAnalyticsTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/AcraCrashReporterAnalyticsTest.kt index cd06ac990a32..155f53d1a347 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/AcraCrashReporterAnalyticsTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/AcraCrashReporterAnalyticsTest.kt @@ -11,6 +11,8 @@ import com.ichi2.anki.common.crashreporting.CrashReportService import com.ichi2.anki.common.crashreporting.CrashReporter.Companion.FEEDBACK_REPORT_KEY import com.ichi2.anki.common.crashreporting.CrashReporter.Companion.FEEDBACK_REPORT_NEVER import com.ichi2.anki.common.preferences.sharedPrefs +import com.ichi2.testutils.AndroidTest +import com.ichi2.testutils.targetContext import io.mockk.every import io.mockk.mockkObject import io.mockk.unmockkObject @@ -22,18 +24,16 @@ import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) -class AcraCrashReporterAnalyticsTest : RobolectricTest() { +class AcraCrashReporterAnalyticsTest : AndroidTest { @Before - override fun setUp() { - super.setUp() + fun setUp() { targetContext.sharedPrefs().edit { putString(FEEDBACK_REPORT_KEY, FEEDBACK_REPORT_NEVER) } mockkObject(Analytics) every { Analytics.sendAnalyticsException(any(), any()) } returns Unit } @After - override fun tearDown() { - super.tearDown() + fun tearDown() { unmockkObject(Analytics) } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ActivityStartupMetaTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ActivityStartupMetaTest.kt index bfa3565d3349..58fc0d512f1d 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ActivityStartupMetaTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ActivityStartupMetaTest.kt @@ -7,13 +7,20 @@ import com.ichi2.anki.compat.CompatHelper.Companion.getPackageInfoCompat import com.ichi2.anki.compat.GET_ACTIVITIES_L import com.ichi2.anki.compat.PackageInfoFlagsCompat import com.ichi2.testutils.ActivityList +import com.ichi2.testutils.AndroidTest +import com.ichi2.testutils.EmptyApplication +import com.ichi2.testutils.targetContext import org.hamcrest.MatcherAssert import org.hamcrest.Matchers import org.junit.Test +import org.junit.experimental.categories.Category import org.junit.runner.RunWith +import org.robolectric.annotation.Config @RunWith(AndroidJUnit4::class) -class ActivityStartupMetaTest : RobolectricTest() { +@Config(application = EmptyApplication::class) +@Category(EmptyApplicationCategory::class) +class ActivityStartupMetaTest : AndroidTest { @Test fun ensureAllActivitiesAreTested() { // if this fails, you may need to add the missing activity to ActivityList.allActivitiesAndIntents() diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/AllActivitiesRecreationTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/AllActivitiesRecreationTest.kt index 9a05d9ef7550..9f80f7d83681 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/AllActivitiesRecreationTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/AllActivitiesRecreationTest.kt @@ -5,6 +5,8 @@ package com.ichi2.anki import android.os.Build import android.os.Bundle import androidx.core.content.edit +import com.google.testing.junit.testparameterinjector.TestParameter +import com.google.testing.junit.testparameterinjector.TestParameterValuesProvider import com.ichi2.anki.account.AccountActivity import com.ichi2.anki.common.preferences.sharedPrefs import com.ichi2.anki.multimedia.MultimediaActivity @@ -23,23 +25,20 @@ import org.hamcrest.Matchers.equalTo import org.junit.Before import org.junit.Test import org.junit.runner.RunWith -import org.robolectric.ParameterizedRobolectricTestRunner +import org.robolectric.RobolectricTestParameterInjector import org.robolectric.android.controller.ActivityController /** * Tests "Don't keep activities" for all activities in the manifest. * * @see ActivityList.allActivitiesAndIntents - * - * TODO: consider RobolectricTestParameterInjector after #14796 is fixed */ -@RunWith(ParameterizedRobolectricTestRunner::class) +@RunWith(RobolectricTestParameterInjector::class) class AllActivitiesRecreationTest : RobolectricTest() { - @ParameterizedRobolectricTestRunner.Parameter - @JvmField // required for Parameter - var config: ActivityConfig? = null + @TestParameter(valuesProvider = ActivityLauncherProvider::class) + lateinit var config: ActivityConfig - private val launcher: ActivityLaunchParam get() = config!!.launcher + private val launcher: ActivityLaunchParam get() = config.launcher @Before fun startAsAnExistingUser() { @@ -144,16 +143,15 @@ class AllActivitiesRecreationTest : RobolectricTest() { override fun toString(): String = launcher.simpleName } - companion object { - // Use the same sandbox as the other activity tests until the backend supports multiple classloaders. - @ParameterizedRobolectricTestRunner.Parameters(name = "{0}") - @JvmStatic // required for initParameters - fun initParameters(): List = + class ActivityLauncherProvider : TestParameterValuesProvider() { + override fun provideValues(context: Context?): List = ActivityList .allActivitiesAndIntents() .filterNot { handled.contains(it.activity) } .map { ActivityConfig(it) } + } + companion object { /** * Activities which finish on the way up given what this test provides: an empty * collection, no card to review, no notetype and no widget id. 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..6db8de823d27 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt @@ -47,6 +47,7 @@ import com.ichi2.anki.RobolectricTest.Companion.advanceRobolectricLooperUntil import com.ichi2.anki.browser.BrowserColumnKey import com.ichi2.anki.browser.BrowserMultiColumnAdapter import com.ichi2.anki.browser.BrowserMultiColumnAdapter.Companion.LINES_VISIBLE_WHEN_COLLAPSED +import com.ichi2.anki.browser.BrowserSearchFeedbackMode import com.ichi2.anki.browser.CardBrowserColumn import com.ichi2.anki.browser.CardBrowserColumn.DECK import com.ichi2.anki.browser.CardBrowserColumn.QUESTION @@ -57,13 +58,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 @@ -526,7 +527,8 @@ class CardBrowserTest : RobolectricTest() { CardBrowserViewModel.SearchState.Completed( rowCount = count, cardsOrNotes = mode, - resultMessage = CardBrowserViewModel.SearchResultMessage.CardCount(includeSearchAllDecksAction = false), + feedbackMode = BrowserSearchFeedbackMode.SEARCH_RESULTS, + allDecksSelected = true, ) assertThat(search.formatCardCount(targetContext.resources), equalTo(expected)) } @@ -832,8 +834,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 +2114,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..78a3e2372a6a 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,251 @@ 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)) + } + + @Test + fun `a failed save does not add card types - Issue 19953`() { + addBasicNote() + val noteType = col.notetypes.basic + val savedNames = noteType.templatesNames + + withCardTemplateEditor(noteType) { + repeat(2) { + assertTrue("Unable to add a card type", shadowOf(this).clickMenuItem(R.id.action_add)) + advanceRobolectricLooper() + clickAlertDialogButton { positiveButton } + advanceRobolectricLooper() + } + assertEquals("The new card types should share a front", tempNoteType!!.getTemplate(1).qfmt, tempNoteType!!.getTemplate(2).qfmt) + + throwOnShowError = false + saveChanges() + assertEquals("A failed save must not add card types", savedNames, col.notetypes.get(noteType.id)!!.templatesNames) + + templateFragment(2).binding.editText.setText("{{Back}} {{Front}}") + val editedNames = tempNoteType!!.notetype.templatesNames + saveChanges() + assertEquals("Saving again should add each card type once", editedNames, col.notetypes.get(noteType.id)!!.templatesNames) + } + } + + @Test + fun `deleting the first card type keeps the cards of the second`() { + val note = addBasicAndReversedNote() + val reverseCardId = note.cards().single { it.ord == 1 }.id + + withCardTemplateEditor(col.notetypes.basicAndReversed) { + deleteSelectedCardType() + saveChanges() + } + + assertEquals("Only the deleted card type's card should be removed", listOf(reverseCardId), note.cids()) + } + + @Test + fun `deleting the first card type keeps the cards of the second after recreation`() { + val note = addBasicAndReversedNote() + val reverseCardId = note.cards().single { it.ord == 1 }.id + val intent = CardEditor(ntid = col.notetypes.basicAndReversed.id).toIntent(targetContext) + val controller = startActivityControllerNormallyOpenCollectionWithIntent(CardTemplateEditor::class.java, intent) + + controller.get().deleteSelectedCardType() + controller.recreate() + advanceRobolectricLooper() + controller.get().saveChanges() + + assertEquals("Only the deleted card type's card should be removed", listOf(reverseCardId), note.cids()) + } + + private fun CardTemplateEditor.deleteSelectedCardType() { + assertTrue("Unable to delete the card type", shadowOf(this).clickMenuItem(R.id.action_delete)) + advanceRobolectricLooper() + clickAlertDialogButton { positiveButton } + advanceRobolectricLooper() + } + + private fun CardTemplateEditor.saveChanges() { + assertTrue("Unable to save", shadowOf(this).clickMenuItem(R.id.action_confirm)) + advanceRobolectricLooper() + } + 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/CardTemplateNotetypeTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CardTemplateNotetypeTest.kt index 6201f6aa4ea1..e0ae07c9e633 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CardTemplateNotetypeTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CardTemplateNotetypeTest.kt @@ -9,6 +9,8 @@ import com.ichi2.anki.CardTemplateNotetype.ChangeType.DELETE import com.ichi2.anki.CardTemplateNotetype.TemplateChange import com.ichi2.anki.compat.CompatHelper.Companion.getSerializableCompat import com.ichi2.anki.libanki.NotetypeJson +import com.ichi2.testutils.AndroidTest +import com.ichi2.testutils.targetContext import org.json.JSONObject import org.junit.Assert import org.junit.Test @@ -23,7 +25,7 @@ import java.io.Serializable import kotlin.test.junit5.JUnit5Asserter.assertNotNull @RunWith(AndroidJUnit4::class) -class CardTemplateNotetypeTest : RobolectricTest() { +class CardTemplateNotetypeTest : AndroidTest { @Test @Throws(Exception::class) fun testTempNoteTypeStorage() { @@ -91,38 +93,31 @@ class CardTemplateNotetypeTest : RobolectricTest() { // Assume you start with a 2 template model (like "Basic (and reversed)") // Add a 3rd new template, remove the 2nd, remove the 1st, add a new now-2nd, remove 1st again // ...and it should reduce to just removing the original 1st/2nd and adding the final as first - val tempNotetype = CardTemplateNotetype(NotetypeJson("{ \"foo\": \"bar\" }")) + val tempNotetype = CardTemplateNotetype(NotetypeJson("{ \"foo\": \"bar\", \"tmpls\": [] }")) tempNotetype.addTemplateChange(ADD, 3) val expected1 = arrayOf(TemplateChange(3, ADD)) // 3 templates and one change now assertTemplateChangesEqual(expected1, tempNotetype.templateChanges) - assertTemplateChangesEqual(expected1, tempNotetype.adjustedTemplateChanges) Assert.assertArrayEquals(intArrayOf(3), tempNotetype.getDeleteDbOrds(3)) tempNotetype.addTemplateChange(DELETE, 2) // 2 templates and two changes now val expected2 = arrayOf(TemplateChange(3, ADD), TemplateChange(2, DELETE)) - val adjExpected2 = arrayOf(TemplateChange(2, ADD), TemplateChange(2, DELETE)) assertTemplateChangesEqual(expected2, tempNotetype.templateChanges) - assertTemplateChangesEqual(adjExpected2, tempNotetype.adjustedTemplateChanges) Assert.assertArrayEquals(intArrayOf(2, 4), tempNotetype.getDeleteDbOrds(3)) tempNotetype.addTemplateChange(DELETE, 1) // 1 template and three changes now Assert.assertArrayEquals(intArrayOf(2, 1, 5), tempNotetype.getDeleteDbOrds(3)) val expected3 = arrayOf(TemplateChange(3, ADD), TemplateChange(2, DELETE), TemplateChange(1, DELETE)) - val adjExpected3 = arrayOf(TemplateChange(1, ADD), TemplateChange(2, DELETE), TemplateChange(1, DELETE)) assertTemplateChangesEqual(expected3, tempNotetype.templateChanges) - assertTemplateChangesEqual(adjExpected3, tempNotetype.adjustedTemplateChanges) tempNotetype.addTemplateChange(ADD, 2) // 2 templates and 4 changes now Assert.assertArrayEquals(intArrayOf(2, 1, 5), tempNotetype.getDeleteDbOrds(3)) val expected4 = arrayOf(TemplateChange(3, ADD), TemplateChange(2, DELETE), TemplateChange(1, DELETE), TemplateChange(2, ADD)) - val adjExpected4 = arrayOf(TemplateChange(1, ADD), TemplateChange(2, DELETE), TemplateChange(1, DELETE), TemplateChange(2, ADD)) assertTemplateChangesEqual(expected4, tempNotetype.templateChanges) - assertTemplateChangesEqual(adjExpected4, tempNotetype.adjustedTemplateChanges) // Make sure we can resurrect these changes across lifecycle val outBundle = tempNotetype.toBundle() @@ -135,17 +130,13 @@ class CardTemplateNotetypeTest : RobolectricTest() { // 1 template and 3 changes now (the delete just cancelled out one of the adds) Assert.assertArrayEquals(intArrayOf(2, 1, 5), tempNotetype.getDeleteDbOrds(3)) val expected5 = arrayOf(TemplateChange(2, DELETE), TemplateChange(1, DELETE), TemplateChange(1, ADD)) - val adjExpected5 = arrayOf(TemplateChange(2, DELETE), TemplateChange(1, DELETE), TemplateChange(1, ADD)) assertTemplateChangesEqual(expected5, tempNotetype.templateChanges) - assertTemplateChangesEqual(adjExpected5, tempNotetype.adjustedTemplateChanges) tempNotetype.addTemplateChange(ADD, 2) // 2 template and 4 changes now (the delete just cancelled out one of the adds) Assert.assertArrayEquals(intArrayOf(2, 1, 5), tempNotetype.getDeleteDbOrds(3)) val expected6 = arrayOf(TemplateChange(2, DELETE), TemplateChange(1, DELETE), TemplateChange(1, ADD), TemplateChange(2, ADD)) - val adjExpected6 = arrayOf(TemplateChange(2, DELETE), TemplateChange(1, DELETE), TemplateChange(1, ADD), TemplateChange(2, ADD)) assertTemplateChangesEqual(expected6, tempNotetype.templateChanges) - assertTemplateChangesEqual(adjExpected6, tempNotetype.adjustedTemplateChanges) tempNotetype.addTemplateChange(ADD, 3) // 2 template and 4 changes now (the delete just cancelled out one of the adds) @@ -158,24 +149,13 @@ class CardTemplateNotetypeTest : RobolectricTest() { TemplateChange(2, ADD), TemplateChange(3, ADD), ) - val adjExpected7 = - arrayOf( - TemplateChange(2, DELETE), - TemplateChange(1, DELETE), - TemplateChange(1, ADD), - TemplateChange(2, ADD), - TemplateChange(3, ADD), - ) assertTemplateChangesEqual(expected7, tempNotetype.templateChanges) - assertTemplateChangesEqual(adjExpected7, tempNotetype.adjustedTemplateChanges) tempNotetype.addTemplateChange(DELETE, 3) // 1 template and 3 changes now (two deletes cancelled out adds) Assert.assertArrayEquals(intArrayOf(2, 1, 5), tempNotetype.getDeleteDbOrds(3)) val expected8 = arrayOf(TemplateChange(2, DELETE), TemplateChange(1, DELETE), TemplateChange(1, ADD), TemplateChange(2, ADD)) - val adjExpected8 = arrayOf(TemplateChange(2, DELETE), TemplateChange(1, DELETE), TemplateChange(1, ADD), TemplateChange(2, ADD)) assertTemplateChangesEqual(expected8, tempNotetype.templateChanges) - assertTemplateChangesEqual(adjExpected8, tempNotetype.adjustedTemplateChanges) } @Suppress("UNCHECKED_CAST") diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CardUtilsTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CardUtilsTest.kt index 8a9aa58831c2..c3b74a73f6db 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CardUtilsTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CardUtilsTest.kt @@ -3,14 +3,12 @@ package com.ichi2.anki -import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.testutils.JvmTest import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.equalTo import org.junit.Test -import org.junit.runner.RunWith -@RunWith(AndroidJUnit4::class) -class CardUtilsTest : RobolectricTest() { +class CardUtilsTest : JvmTest() { @Test fun getDeckIdForCard_regularDeck_returnsDid() { val note = addBasicNote("Front", "Back") diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionLeaseTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionLeaseTest.kt index 8c56fa4b4368..bdf4f1550c65 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionLeaseTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionLeaseTest.kt @@ -11,6 +11,7 @@ import com.ichi2.anki.CollectionManager.withLeaseForTest import com.ichi2.anki.exception.CollectionLockedException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.async @@ -144,6 +145,7 @@ class CollectionLeaseTest : RobolectricTest() { fun `a lease cannot overtake brief work paused before queue dispatch`() = runTest { val queue = StandardTestDispatcher(testScheduler) + val pausedBrief = CoroutineName("paused brief") var dispatchBrief: (() -> Unit)? = null val pausedQueue = object : CoroutineDispatcher() { @@ -151,8 +153,10 @@ class CollectionLeaseTest : RobolectricTest() { context: CoroutineContext, block: Runnable, ) { - if (dispatchBrief == null) { + if (context[CoroutineName] === pausedBrief) { // Pause after admission but before submitting to the serial queue. + // Only this coroutine may save dispatchBrief: application background + // work can dispatch concurrently and must not overwrite the saved task. dispatchBrief = { queue.dispatch(context, block) } } else { queue.dispatch(context, block) @@ -161,8 +165,11 @@ class CollectionLeaseTest : RobolectricTest() { } withCollectionQueue(pausedQueue) { val events = mutableListOf() + // Application startup can submit unrelated work (for example, the rollover alarm). + // It must not steal the pause intended for the brief call below. + launch(start = CoroutineStart.UNDISPATCHED) { withCol { } } val brief = - async(start = CoroutineStart.UNDISPATCHED) { + async(pausedBrief, start = CoroutineStart.UNDISPATCHED) { tryWithCol { events.add("brief") } } val otherBrief = diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerStackTraceTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerStackTraceTest.kt index 7c3c407fa678..3a7d786e4ba1 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerStackTraceTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerStackTraceTest.kt @@ -2,19 +2,17 @@ package com.ichi2.anki -import androidx.test.ext.junit.runners.AndroidJUnit4 import com.ichi2.anki.exception.CollectionLockedException +import com.ichi2.testutils.JvmTest import kotlinx.coroutines.test.StandardTestDispatcher import net.ankiweb.rsdroid.BackendException.BackendDbException.BackendDbLockedException import org.junit.Test -import org.junit.runner.RunWith import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertTrue -@RunWith(AndroidJUnit4::class) -class CollectionManagerStackTraceTest : RobolectricTest() { +class CollectionManagerStackTraceTest : JvmTest() { @Test fun `opening failure records the withCol caller`() = runTest { diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CoroutineHelpersRobolectricTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CoroutineHelpersRobolectricTest.kt index a62291e9bde4..482e8b0f511a 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CoroutineHelpersRobolectricTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CoroutineHelpersRobolectricTest.kt @@ -17,6 +17,7 @@ import com.ichi2.anki.exception.CollectionLockedException import com.ichi2.anki.exception.StorageNotConfiguredException import com.ichi2.anki.preferences.PreferencesActivity import com.ichi2.testutils.BackendEmulatingOpenConflict +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest import org.hamcrest.CoreMatchers.containsString @@ -38,6 +39,8 @@ import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) @@ -137,6 +140,56 @@ class CoroutineHelpersRobolectricTest : RobolectricTest() { verify(crashReporter, never()).sendExceptionReport(any(), anyOrNull(), anyOrNull(), any()) } + @Test + fun `view task is cancelled when the view is destroyed but the fragment remains attached`() { + val controller = Robolectric.buildActivity(FragmentActivity::class.java).setup() + saveControllerForCleanup(controller) + val activity = controller.get() + val fragment = Fragment(android.R.layout.simple_list_item_1) + activity.supportFragmentManager.commitNow { add(android.R.id.content, fragment) } + + var cancelled = false + val job = + assertNotNull( + fragment.launchCatchingViewTask { + try { + awaitCancellation() + } finally { + cancelled = true + } + }, + ) + assertTrue(job.isActive) + + activity.supportFragmentManager.commitNow { detach(fragment) } + advanceRobolectricLooper() + + assertNull(fragment.view) + assertSame(activity, fragment.activity) + assertTrue(job.isCancelled) + assertTrue(cancelled) + } + + @Test + fun `finishing activity fails view task under Robolectric`() = + assertLifecycleFailure("activity is finishing") { + val controller = Robolectric.buildActivity(FragmentActivity::class.java).setup() + saveControllerForCleanup(controller) + val activity = controller.get() + val fragment = Fragment(android.R.layout.simple_list_item_1) + activity.supportFragmentManager.commitNow { add(android.R.id.content, fragment) } + var ran = false + + activity.lifecycleScope.launch { + fragment.launchCatchingViewTask { ran = true } + activity.finish() + } + advanceRobolectricLooper() + + assertTrue(activity.isFinishing) + assertFalse(ran) + } + /** * A [StorageNotConfiguredException] escaping a coroutine means an activity raced or * outlived its [ensureStorageIsReady][com.ichi2.anki.startup.ensureStorageIsReady] check: diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CrashReportDataTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CrashReportDataTest.kt index c3a9bd958eca..33e37327d4ad 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CrashReportDataTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CrashReportDataTest.kt @@ -2,19 +2,14 @@ package com.ichi2.anki -import androidx.test.ext.junit.runners.AndroidJUnit4 import com.ichi2.anki.CrashReportData.Companion.isDeckNotFoundInLimitsMapException -import com.ichi2.testutils.EmptyApplication +import com.ichi2.testutils.JvmTest import com.ichi2.testutils.ext.triggerDeckNotFoundInLimitsMap import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.annotation.Config import kotlin.test.assertFailsWith import kotlin.test.assertTrue -@RunWith(AndroidJUnit4::class) -@Config(application = EmptyApplication::class) -class CrashReportDataTest : RobolectricTest() { +class CrashReportDataTest : JvmTest() { /** #15195: corrupt deck hierarchy raises 'deck not found in limits map' */ @Test fun `deck not found in limits map regression test`() { diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckAdapterFilterTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckAdapterFilterTest.kt index a96ccd1dc353..0aedf9157a7a 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/DeckAdapterFilterTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckAdapterFilterTest.kt @@ -3,16 +3,14 @@ package com.ichi2.anki -import androidx.test.ext.junit.runners.AndroidJUnit4 import com.ichi2.anki.deckpicker.addVisibleToList import com.ichi2.anki.libanki.filterAndFlatten import com.ichi2.anki.libanki.sched.DeckNode +import com.ichi2.testutils.JvmTest import org.junit.Assert import org.junit.Test -import org.junit.runner.RunWith -@RunWith(AndroidJUnit4::class) -class DeckAdapterFilterTest : RobolectricTest() { +class DeckAdapterFilterTest : JvmTest() { @Test fun verifyFilterResultsReturnsCorrectList() { val pattern = "Math" 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..764cb2c39110 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) @@ -191,6 +195,10 @@ class DeckPickerInsetsTest : RobolectricTest() { // delete every deck: the deck picker enters the initial state col.decks.remove(listOf("Test Deck 0", "Test Deck 1").map { col.decks.id(it) }) deckPicker.updateDeckList() + // Refreshing the decks runs on IO: draining the main looper alone may finish first. + advanceRobolectricLooperUntil(lazyMessage = { "the empty collection placeholder should be shown" }) { + deckPicker.deckPickerBinding.noDecksPlaceholder.isVisible + } deckPicker.layoutForTest() check(deckPicker.fabBottomMargin == 0) { "the FAB should be unraised initially" } @@ -198,6 +206,9 @@ class DeckPickerInsetsTest : RobolectricTest() { // so no layout pass fires on it addDeck("A Deck") deckPicker.updateDeckList() + advanceRobolectricLooperUntil(lazyMessage = { "the deck list should be shown again" }) { + deckPicker.deckPickerBinding.deckPickerContent.isVisible + } deckPicker.layoutForTest() assertThat( diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerSchedulerUpgradeTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerSchedulerUpgradeTest.kt new file mode 100644 index 000000000000..dc1f2d4f6846 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerSchedulerUpgradeTest.kt @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki + +import android.content.Intent +import android.view.KeyEvent +import androidx.activity.OnBackPressedCallback +import androidx.appcompat.app.AlertDialog +import androidx.core.content.edit +import com.ichi2.anki.CollectionManager.TR +import com.ichi2.anki.common.preferences.sharedPrefs +import com.ichi2.anki.common.utils.ext.schedulerVersion +import com.ichi2.anki.dialogs.utils.message +import com.ichi2.anki.dialogs.utils.title +import com.ichi2.testutils.BackupManagerTestUtilities +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.android.controller.ActivityController +import org.robolectric.shadows.ShadowDialog +import org.robolectric.shadows.ShadowToast +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotSame +import kotlin.test.assertSame +import kotlin.test.assertTrue + +@RunWith(ParameterizedRobolectricTestRunner::class) +class DeckPickerSchedulerUpgradeTest : RobolectricTest() { + @ParameterizedRobolectricTestRunner.Parameter + @JvmField + var qualifiers: String = "" + + @Before + fun prepareCollection() { + RuntimeEnvironment.setQualifiers(qualifiers) + setIntroductionSlidesShown(true) + addBasicNote() + BackupManagerTestUtilities.setupSpaceForBackup(targetContext) + InitialActivity.setUpgradedToLatestVersion(targetContext.sharedPrefs()) + getPreferences().edit { putBoolean("backupPromptDisabled", true) } + } + + @After + fun resetBackupSpace() = BackupManagerTestUtilities.reset() + + @Test + fun `required prompt survives repeated recreation and collection refresh`() { + val controller = launchWithPrompt() + val viewModel = controller.get().viewModel + val original = upgradeDialog(controller.get()) + + repeat(2) { + recreate(controller) + assertSame(viewModel, controller.get().viewModel) + refresh(controller.get()) + val restored = upgradeDialog(controller.get()) + assertNotSame(original, restored) + assertEquals(original.title, restored.title) + assertEquals(original.message, restored.message) + assertEquals(1L, col.config.schedulerVersion) + } + } + + @Test + fun `confirming the restored prompt upgrades once`() { + val controller = launchWithPrompt() + recreate(controller) + val dialog = upgradeDialog(controller.get()) + ShadowToast.reset() + + dialog.getButton(AlertDialog.BUTTON_POSITIVE).performClick() + advanceRobolectricLooperUntil { col.config.schedulerVersion == 2L } + advanceRobolectricLooper() + + assertFalse(dialog.isShowing) + assertEquals(1, ShadowToast.shownToastCount()) + assertEquals(TR.schedulingUpdateDone(), ShadowToast.getTextOfLatestToast()) + recreate(controller) + refresh(controller.get()) + assertNoUpgradePrompt() + assertEquals(1, ShadowToast.shownToastCount()) + } + + @Test + fun `Cancel on the restored prompt uses the current activity`() = cancelRestoredPrompt(back = false) + + @Test + fun `Back on the restored prompt uses the current activity`() = cancelRestoredPrompt(back = true) + + private fun cancelRestoredPrompt(back: Boolean) { + val controller = launchWithPrompt() + recreate(controller) + val activity = controller.get() + var cancellations = 0 + activity.onBackPressedDispatcher.addCallback( + activity, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + cancellations++ + } + }, + ) + val dialog = upgradeDialog(activity) + + if (back) { + dialog.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_BACK)) + dialog.dispatchKeyEvent(KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_BACK)) + } else { + dialog.getButton(AlertDialog.BUTTON_NEGATIVE).performClick() + } + advanceRobolectricLooper() + + assertEquals(1, cancellations) + assertEquals(1L, col.config.schedulerVersion) + assertFalse(dialog.isShowing) + recreate(controller) + refresh(controller.get()) + assertNoUpgradePrompt() + } + + private fun launchWithPrompt(): ActivityController { + val controller = startActivityControllerNormallyOpenCollectionWithIntent(DeckPicker::class.java, Intent()) + col.config.set("schedVer", 1L) + col.modSchema(check = false) + refresh(controller.get()) + upgradeDialog(controller.get()) + return controller + } + + private fun refresh(activity: DeckPicker) { + runBlocking { activity.viewModel.reloadDeckCounts().join() } + advanceRobolectricLooper() + } + + private fun recreate(controller: ActivityController) { + val original = controller.get() + controller.recreate() + controller.get().viewModel.awaitStartup() + advanceRobolectricLooper() + assertNotSame(original, controller.get()) + } + + private fun upgradeDialog(activity: DeckPicker): AlertDialog { + val dialogs = ShadowDialog.getShownDialogs().filterIsInstance().filter { it.isShowing } + assertEquals(1, dialogs.size) + return dialogs.single().also { + assertTrue(it.isShowing) + assertSame(activity, it.context.findAnkiActivity()) + assertEquals(activity.getString(CommonString.scheduler_upgrade_required_dialog_title), it.title) + } + } + + private fun assertNoUpgradePrompt() { + assertFalse(ShadowDialog.getShownDialogs().any { it.isShowing }) + } + + companion object { + @ParameterizedRobolectricTestRunner.Parameters(name = "{0}") + @JvmStatic + fun configurations() = listOf("normal", "xlarge") + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTabletScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTabletScreenshotTest.kt index ad4704e00362..32615890e2a4 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTabletScreenshotTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTabletScreenshotTest.kt @@ -34,12 +34,10 @@ class DeckPickerTabletScreenshotTest : ScreenshotTest() { @Test fun deckPickerWith30Decks() = - withDeckPicker(deckCount = 30, withCards = true) { deckPicker -> - deckPicker.simulateEdgeToEdge() - // Allow tryShowStudyOptionsPanel()'s async fragment commit to finalize - // before capturing, otherwise the right pane is empty. - advanceRobolectricLooper() - advanceRobolectricLooper() - captureScreen("30_decks_tablet") + runTest { + withDeckPicker(deckCount = 30, withCards = true) { deckPicker -> + deckPicker.simulateEdgeToEdge() + captureScreen("30_decks_tablet") + } } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt index ee059f4d911e..7b75863d8849 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 @@ -33,6 +34,8 @@ import androidx.core.view.size import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.fragment.app.commitNow +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import androidx.test.core.app.ActivityScenario import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.click @@ -44,6 +47,7 @@ import anki.scheduler.CardAnswer.Rating import app.cash.turbine.test import com.google.android.material.bottomnavigation.BottomNavigationView import com.ichi2.anki.CollectionManager.TR +import com.ichi2.anki.DeckPickerFloatingActionMenu.CloseAnimation import com.ichi2.anki.browser.CardBrowserFragment import com.ichi2.anki.browser.CardBrowserViewModel.RowSelection import com.ichi2.anki.common.preferences.sharedPrefs @@ -689,26 +693,23 @@ class DeckPickerTest : RobolectricTest() { } @Test - fun `ContextMenu testDynRebuildAndEmpty`() = - deckPicker { - val cardIds = - (0..3) - .map { addBasicNote("$it", "").firstCard().id } - assertTrue(allCardsInSameDeck(cardIds, 1)) - val deckId = addDynamicDeck("Deck 1") - getColUnsafe.sched.rebuildFilteredDeck(deckId) - assertTrue(allCardsInSameDeck(cardIds, deckId)) - updateDeckList() - assertEquals(1, visibleDeckCount) - - selectContextMenuOption(ContextMenuOption.CUSTOM_STUDY_EMPTY, deckId) + fun `ContextMenu testDynRebuildAndEmpty`() { + val cardIds = (0..3).map { addBasicNote("$it", "").firstCard().id } + assertTrue(allCardsInSameDeck(cardIds, 1)) + val deckId = addDynamicDeck("Deck 1") + col.sched.rebuildFilteredDeck(deckId) + assertTrue(allCardsInSameDeck(cardIds, deckId)) - assertTrue(allCardsInSameDeck(cardIds, 1)) - - selectContextMenuOption(ContextMenuOption.CUSTOM_STUDY_REBUILD, deckId) + deckPicker(expectedDeckIds = listOf(deckId)) { + selectContextMenuOptionAndAwait(ContextMenuOption.CUSTOM_STUDY_EMPTY, deckId) { + allCardsInSameDeck(cardIds, 1) + } - assertTrue(allCardsInSameDeck(cardIds, deckId)) + selectContextMenuOptionAndAwait(ContextMenuOption.CUSTOM_STUDY_REBUILD, deckId) { + allCardsInSameDeck(cardIds, deckId) + } } + } private suspend fun TestScope.withQueuedCollectionAccess(block: suspend () -> T): T { val previousQueue = CollectionManager.setTestDispatcher(StandardTestDispatcher(testScheduler), useReentrantLock = false) @@ -1369,12 +1370,12 @@ class DeckPickerTest : RobolectricTest() { } @Test - fun `adding a note by single tap closes the FAB menu`() = assertAddingNoteByTouchClosesMenu(doubleTap = false) + fun `adding a note by single tap preserves the visible FAB menu`() = assertAddingNoteByTouchPreservesMenu(doubleTap = false) @Test - fun `adding a note by double tap closes the FAB menu`() = assertAddingNoteByTouchClosesMenu(doubleTap = true) + fun `adding a note by double tap preserves the visible FAB menu`() = assertAddingNoteByTouchPreservesMenu(doubleTap = true) - private fun assertAddingNoteByTouchClosesMenu(doubleTap: Boolean) = + private fun assertAddingNoteByTouchPreservesMenu(doubleTap: Boolean) = deckPicker { val fab = floatingActionButtonBinding.fabMain val startTime = SystemClock.uptimeMillis() @@ -1401,11 +1402,75 @@ class DeckPickerTest : RobolectricTest() { tap(100) val intent = AnkiDroidNavigator.toIntent(awaitItem()) assertEquals("com.ichi2.anki.NoteEditorActivity", intent.component!!.className) - assertFalse(floatingActionMenu.isFABOpen, "Adding a note must close the FAB menu") - assertEquals(View.GONE, floatingActionButtonBinding.fabBGLayout.visibility) + assertFalse(floatingActionMenu.isFABOpen, "Adding a note must save a closed menu state") + assertTrue(floatingActionButtonBinding.fabMain.isExtended) + assertEquals(View.VISIBLE, floatingActionButtonBinding.fabBGLayout.visibility) + } + } + + @Test + fun `selecting Add during FAB reopening keeps menu buttons visible`() = + deckPicker { + floatingActionMenu.showFloatingActionMenu() + shadowOf(mainLooper).idleFor(java.time.Duration.ofMillis(200)) + floatingActionMenu.closeFloatingActionMenu(style = CloseAnimation.SHORT) + shadowOf(mainLooper).idleFor(java.time.Duration.ofMillis(200)) + assertEquals(View.GONE, floatingActionButtonBinding.addDeckButton.visibility) + + floatingActionMenu.showFloatingActionMenu() + // Select Add before the opening animations have finished. + floatingActionButtonBinding.fabMain.performClick() + shadowOf(mainLooper).idleFor(java.time.Duration.ofMillis(200)) + + with(floatingActionButtonBinding) { + assertTrue(fabMain.isExtended) + assertEquals(View.VISIBLE, addDeckButton.visibility) + assertEquals(View.VISIBLE, addFilteredDeckButton.visibility) + assertEquals(View.VISIBLE, addSharedButton.visibility) } } + @Test + fun `Add menu resets on resume without stopping in multi-window`() { + ActivityScenario.launch(DeckPicker::class.java).use { scenario -> + val events = mutableListOf() + scenario.onActivity { deckPicker -> + deckPicker.lifecycle.addObserver(LifecycleEventObserver { _, event -> events.add(event) }) + deckPicker.floatingActionMenu.showFloatingActionMenu() + advanceRobolectricLooper() + with(deckPicker.floatingActionButtonBinding) { + val expandedColor = fabMain.backgroundTintList + fabMain.performClick() + assertTrue(fabMain.isExtended) + assertEquals(expandedColor, fabMain.backgroundTintList) + assertEquals(View.VISIBLE, fabBGLayout.visibility) + } + } + + // A visible activity can be paused without being stopped in multi-window mode. + scenario.moveToState(Lifecycle.State.STARTED) + scenario.onActivity { deckPicker -> + assertTrue(deckPicker.floatingActionButtonBinding.fabMain.isExtended) + } + scenario.moveToState(Lifecycle.State.RESUMED) + + scenario.onActivity { deckPicker -> + assertTrue(Lifecycle.Event.ON_PAUSE in events) + assertFalse(Lifecycle.Event.ON_STOP in events) + with(deckPicker.floatingActionButtonBinding) { + assertFalse(fabMain.isExtended) + assertEquals(View.GONE, fabBGLayout.visibility) + assertEquals(View.GONE, addDeckButton.visibility) + assertEquals(View.GONE, addFilteredDeckButton.visibility) + assertEquals(View.GONE, addSharedButton.visibility) + fabMain.performClick() + assertTrue(deckPicker.floatingActionMenu.isFABOpen) + assertEquals(View.VISIBLE, fabBGLayout.visibility) + } + } + } + } + @Test fun `FAB menu opens on ENTER key`() = deckPicker { @@ -1434,6 +1499,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 +1746,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 +1799,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 { @@ -1927,9 +2028,14 @@ fun RobolectricTest.setIntroductionSlidesShown(shown: Boolean) { } } +/** + * Launches the deck picker, waiting for [expectedDeckIds] in adapter order before running [function] + * when supplied. Prepare collection data before calling this helper. + */ fun RobolectricTest.deckPicker( exposeTestData: Boolean = false, skipIntroduction: Boolean = true, + expectedDeckIds: List? = null, function: suspend DeckPicker.() -> Unit, ) = runTest { setIntroductionSlidesShown(skipIntroduction) @@ -1938,6 +2044,7 @@ fun RobolectricTest.deckPicker( if (exposeTestData) DeckPickerTest.DeckPickerEx::class.java else DeckPicker::class.java, Intent(), ) + expectedDeckIds?.let { deckPicker.awaitVisibleDecks(it) } function(deckPicker) } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTestFixture.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTestFixture.kt index 88f3b2fdc0bf..e93116f563c7 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTestFixture.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTestFixture.kt @@ -4,11 +4,16 @@ package com.ichi2.anki import android.content.Intent import androidx.core.content.edit -import com.ichi2.anki.RobolectricTest.Companion.advanceRobolectricLooper +import androidx.core.view.isVisible import com.ichi2.anki.RobolectricTest.Companion.advanceRobolectricLooperUntil import com.ichi2.anki.common.preferences.sharedPrefs import com.ichi2.anki.deckpicker.DeckPickerViewModel import com.ichi2.anki.deckpicker.DeckPickerViewModel.StartupResponse +import com.ichi2.anki.dialogs.DeckPickerContextMenu.DeckPickerContextMenuOption +import com.ichi2.anki.dialogs.DeckPickerContextMenuResult +import com.ichi2.anki.dialogs.setDeckPickerContextMenuResult +import com.ichi2.anki.libanki.DeckId +import com.ichi2.anki.widgets.DeckAdapter import com.ichi2.testutils.BackupManagerTestUtilities // TODO: move to testFixtures once RobolectricTest is moved @@ -32,7 +37,10 @@ fun withDeckPicker( } val deckPicker = test.startActivityNormallyOpenCollectionWithIntent(DeckPicker::class.java, Intent()).also { - advanceRobolectricLooper() // may be a fix for flaky tests + // Deck refreshes run on IO, so draining the main looper alone may finish first. + advanceRobolectricLooperUntil(lazyMessage = { "the deck list or empty collection placeholder should be shown" }) { + it.deckPickerBinding.deckPickerContent.isVisible || it.deckPickerBinding.noDecksPlaceholder.isVisible + } } block(deckPicker) } @@ -41,3 +49,26 @@ fun DeckPickerViewModel.awaitStartup() = advanceRobolectricLooperUntil { startupJob?.isActive != true && flowOfStartupResponse.value !is StartupResponse.Success } + +/** Waits for the adapter to display the expected decks, including background list diffs. */ +fun DeckPicker.awaitVisibleDecks(expectedDeckIds: List) { + val adapter = deckPickerBinding.decks.adapter as DeckAdapter + advanceRobolectricLooperUntil( + lazyMessage = { "Expected visible decks $expectedDeckIds, but found ${adapter.currentList.map { it.did }}" }, + ) { + adapter.currentList.map { it.did } == expectedDeckIds + } +} + +/** Selects a context-menu action and waits for its observable result before returning. */ +fun DeckPicker.selectContextMenuOptionAndAwait( + option: DeckPickerContextMenuOption, + deckId: DeckId, + condition: () -> Boolean, +) { + supportFragmentManager.setDeckPickerContextMenuResult(DeckPickerContextMenuResult(deckId = deckId, option = option)) + advanceRobolectricLooperUntil( + lazyMessage = { "Context-menu action $option on deck $deckId did not reach its expected state" }, + condition = condition, + ) +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/FlagTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/FlagTest.kt index 2a2067d75dc7..31dafaabe4e8 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/FlagTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/FlagTest.kt @@ -2,23 +2,15 @@ package com.ichi2.anki -import androidx.test.ext.junit.runners.AndroidJUnit4 import com.ichi2.anki.libanki.testutils.AnkiTest import com.ichi2.anki.utils.ext.flag -import com.ichi2.testutils.EmptyApplication import com.ichi2.testutils.JvmTest import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.hasSize import org.junit.Assert.assertEquals import org.junit.Test -import org.junit.experimental.categories.Category -import org.junit.runner.RunWith -import org.robolectric.annotation.Config /** Tests for [Flag] */ -@RunWith(AndroidJUnit4::class) -@Config(application = EmptyApplication::class) -@Category(EmptyApplicationCategory::class) class FlagTest : JvmTest() { @Test fun `search node conversion integration test`() { 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/FragmentLifecycleFixture.kt b/AnkiDroid/src/test/java/com/ichi2/anki/FragmentLifecycleFixture.kt index 6935ec49a536..6ef5cb5da9a5 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/FragmentLifecycleFixture.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/FragmentLifecycleFixture.kt @@ -44,7 +44,7 @@ class FragmentLifecycleFixture( fun fragmentLifecycleFixtures(): List = listOf( - FragmentLifecycleFixture("StudyOptions", knownFailures = setOf(REMOVE)) { StudyOptionsFragment() }, + FragmentLifecycleFixture("StudyOptions") { StudyOptionsFragment() }, FragmentLifecycleFixture("AppearanceSettings", knownFailures = setOf(REMOVE)) { AppearanceSettingsFragment() }, FragmentLifecycleFixture("GeneralSettings", knownFailures = setOf(REMOVE)) { GeneralSettingsFragment() }, FragmentLifecycleFixture("ReviewerOptions", knownFailures = setOf(REMOVE)) { ReviewerOptionsFragment() }, @@ -66,7 +66,7 @@ fun fragmentLifecycleFixtures(): List = FragmentLifecycleFixture("AddReminder", hasFragmentView = false, knownFailures = setOf(REMOVE)) { AddEditReminderDialog.getInstance(AddEditReminderDialog.DialogMode.Add(ReviewReminderScope.Global)) }, - FragmentLifecycleFixture("InsertField", knownFailures = setOf(REMOVE)) { + FragmentLifecycleFixture("InsertField") { InsertFieldDialog.newInstance( fieldItems = listOf("Front", "Back"), metadata = InsertFieldMetadata(SingleCardSide.FRONT, "Card 1", "Basic", null, null, null, "Default"), diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/IntentSyncTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/IntentSyncTest.kt new file mode 100644 index 000000000000..4e0a08988fb9 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/IntentSyncTest.kt @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.settings.Prefs +import com.ichi2.anki.settings.enums.ShouldFetchMedia +import com.ichi2.anki.sync.MeteredSyncPolicy +import com.ichi2.anki.worker.SyncWorker +import com.ichi2.utils.NetworkUtils +import io.mockk.every +import io.mockk.justRun +import io.mockk.mockkObject +import io.mockk.unmockkAll +import io.mockk.verify +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo +import org.hamcrest.Matchers.sameInstance +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.shadows.ShadowDialog + +@RunWith(AndroidJUnit4::class) +class IntentSyncTest : RobolectricTest() { + @Before + fun setUpSync() { + mockkObject(NetworkUtils, MeteredSyncPolicy, SyncWorker.Companion) + every { NetworkUtils.isOnline } returns true + every { MeteredSyncPolicy.shouldBlock() } returns false + justRun { SyncWorker.start(any(), any(), any()) } + } + + @After + fun resetMocks() { + unmockkAll() + } + + @Test + fun `sync intent schedules collection and media independently of the finishing activity`() = + deckPicker { + Prefs.hkey = "test" + Prefs.lastSyncTime = 0 + Prefs.putEnum(R.string.sync_fetch_media_key, ShouldFetchMedia.ALWAYS) + + IntentHandler.Companion.DoSync().handleAsyncMessage(this) + + assertThat(isFinishing, equalTo(true)) + verify(exactly = 1) { SyncWorker.start(this@deckPicker, match { it.hkey == "test" }, true) } + } + + @Test + fun `sync intent respects disabled media sync`() = + deckPicker { + Prefs.hkey = "test" + Prefs.lastSyncTime = 0 + Prefs.putEnum(R.string.sync_fetch_media_key, ShouldFetchMedia.NEVER) + + IntentHandler.Companion.DoSync().handleAsyncMessage(this) + + verify(exactly = 1) { SyncWorker.start(this@deckPicker, any(), false) } + } + + @Test + fun `sync intent respects metered connection policy`() = + deckPicker { + Prefs.hkey = "test" + val previousSyncTime = 1_000L + Prefs.lastSyncTime = previousSyncTime + every { MeteredSyncPolicy.shouldBlock() } returns true + val previousDialog = ShadowDialog.getLatestDialog() + + IntentHandler.Companion.DoSync().handleAsyncMessage(this) + + verify(exactly = 0) { SyncWorker.start(any(), any(), any()) } + assertThat("sync must not open a confirmation dialog", ShadowDialog.getLatestDialog(), sameInstance(previousDialog)) + assertThat(isFinishing, equalTo(true)) + assertThat("blocked sync must not count as a sync attempt", Prefs.lastSyncTime, equalTo(previousSyncTime)) + } + + @Test + fun `sync intent respects rate limit`() = + deckPicker { + Prefs.hkey = "test" + setLastSyncTimeToNow() + + IntentHandler.Companion.DoSync().handleAsyncMessage(this) + + verify(exactly = 0) { SyncWorker.start(any(), any(), any()) } + } + + @Test + fun `sync intent does not sync when offline`() = + deckPicker { + Prefs.hkey = "test" + Prefs.lastSyncTime = 0 + every { NetworkUtils.isOnline } returns false + + IntentHandler.Companion.DoSync().handleAsyncMessage(this) + + verify(exactly = 0) { SyncWorker.start(any(), any(), any()) } + } + + @Test + fun `sync intent does not sync when logged out`() = + deckPicker { + Prefs.hkey = "" + Prefs.lastSyncTime = 0 + + IntentHandler.Companion.DoSync().handleAsyncMessage(this) + + verify(exactly = 0) { SyncWorker.start(any(), any(), any()) } + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/IntentSyncWorkTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/IntentSyncWorkTest.kt new file mode 100644 index 000000000000..1f4d58d949ab --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/IntentSyncWorkTest.kt @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki + +import android.content.Intent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkInfo +import androidx.work.WorkManager +import com.ichi2.anki.CollectionManager.withLeaseForTest +import com.ichi2.anki.worker.SyncWorker +import com.ichi2.anki.worker.UniqueWorkNames +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import kotlinx.coroutines.flow.MutableSharedFlow +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo +import org.hamcrest.Matchers.nullValue +import org.hamcrest.Matchers.sameInstance +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.Shadows.shadowOf +import java.util.UUID +import java.util.concurrent.TimeUnit + +@RunWith(AndroidJUnit4::class) +class IntentSyncWorkTest : RobolectricTest() { + @Test + fun `sync intent leaves pending background sync intact`() { + val workId = enqueueSync() + + val activity = launchIntent("com.ichi2.anki.DO_SYNC") + + assertThat(workManager.getWorkInfoById(workId).get()!!.state, equalTo(WorkInfo.State.ENQUEUED)) + assertThat("a duplicate sync must not open the collection in DeckPicker", shadowOf(activity).nextStartedActivity, nullValue()) + } + + @Test + fun `sync intent leaves foreground collection sync intact`() = + runTest { + for (operation in listOf(CollectionOperation.SYNC, CollectionOperation.FULL_UPLOAD, CollectionOperation.FULL_DOWNLOAD)) { + withLeaseForTest(operation) { + val lease = CollectionManager.collectionLease + + val activity = launchIntent("com.ichi2.anki.DO_SYNC") + + assertThat( + "a duplicate $operation intent must not open DeckPicker", + shadowOf(activity).nextStartedActivity, + nullValue(), + ) + assertThat("the existing $operation must retain its lease", CollectionManager.collectionLease, sameInstance(lease)) + } + } + } + + @Test + fun `sync intent checks foreground sync after the work query completes`() = + runTest { + withPendingSyncQuery { results -> + Robolectric.buildActivity(IntentHandler::class.java, Intent("com.ichi2.anki.DO_SYNC")).use { controller -> + controller.create().start().resume() + val activity = controller.get() + assertThat(results.subscriptionCount.value, equalTo(1)) + + withLeaseForTest(CollectionOperation.SYNC) { + results.emit(emptyList()) + + assertThat(activity.isFinishing, equalTo(true)) + assertThat("a new foreground sync must prevent navigation", shadowOf(activity).nextStartedActivity, nullValue()) + } + } + } + } + + @Test + fun `opening the app still cancels background collection sync`() { + val workId = enqueueSync() + + launchIntent(Intent.ACTION_MAIN) + + assertThat(workManager.getWorkInfoById(workId).get()!!.state, equalTo(WorkInfo.State.CANCELLED)) + } + + @Test + fun `sync intent opens DeckPicker when no sync is pending`() { + val activity = launchIntent("com.ichi2.anki.DO_SYNC") + val intent = shadowOf(activity).nextStartedActivity + + assertThat(intent.component!!.className, equalTo(DeckPicker::class.java.name)) + assertThat(intent.action, equalTo("com.ichi2.anki.DO_SYNC")) + } + + @Test + fun `finishing the handler before the work query completes prevents navigation`() = + runTest { + withPendingSyncQuery { results -> + Robolectric.buildActivity(IntentHandler::class.java, Intent("com.ichi2.anki.DO_SYNC")).use { controller -> + controller.create().start().resume() + val activity = controller.get() + assertThat(results.subscriptionCount.value, equalTo(1)) + + activity.finish() + results.emit(emptyList()) + + assertThat("a finishing handler must not launch DeckPicker", shadowOf(activity).nextStartedActivity, nullValue()) + } + } + } + + @Test + fun `destroying the handler cancels its pending work query`() = + runTest { + withPendingSyncQuery { results -> + Robolectric.buildActivity(IntentHandler::class.java, Intent("com.ichi2.anki.DO_SYNC")).use { controller -> + controller.create().start().resume() + val activity = controller.get() + assertThat(results.subscriptionCount.value, equalTo(1)) + + activity.finish() + controller.pause().stop().destroy() + assertThat(results.subscriptionCount.value, equalTo(0)) + results.emit(emptyList()) + + assertThat("a destroyed handler must not launch DeckPicker", shadowOf(activity).nextStartedActivity, nullValue()) + } + } + } + + private suspend fun withPendingSyncQuery(block: suspend (MutableSharedFlow>) -> Unit) { + val results = MutableSharedFlow>() + val manager = mockk() + mockkObject(WorkManager.Companion) + try { + every { WorkManager.getInstance(any()) } returns manager + every { manager.getWorkInfosForUniqueWorkFlow(UniqueWorkNames.SYNC) } returns results + block(results) + } finally { + unmockkObject(WorkManager.Companion) + } + } + + private val workManager get() = WorkManager.getInstance(targetContext) + + private fun enqueueSync(): UUID { + // Keep the real worker queued without starting a network request. + val work = + OneTimeWorkRequestBuilder() + .setInitialDelay(1, TimeUnit.HOURS) + .build() + workManager.enqueueUniqueWork(UniqueWorkNames.SYNC, ExistingWorkPolicy.KEEP, work).result.get() + assertThat(workManager.getWorkInfoById(work.id).get()!!.state, equalTo(WorkInfo.State.ENQUEUED)) + return work.id + } + + private fun launchIntent(action: String): IntentHandler { + val controller = Robolectric.buildActivity(IntentHandler::class.java, Intent(action)) + saveControllerForCleanup(controller) + val activity = controller.create().get() + advanceRobolectricLooperUntil { activity.isFinishing } + return activity + } +} 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/ManifestThemeTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ManifestThemeTest.kt index 680d088ceff8..894c45fbe79e 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ManifestThemeTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ManifestThemeTest.kt @@ -6,11 +6,16 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.ichi2.anki.compat.CompatHelper.Companion.getPackageInfoCompat import com.ichi2.anki.compat.GET_ACTIVITIES_L import com.ichi2.anki.compat.PackageInfoFlagsCompat +import com.ichi2.testutils.AndroidTest +import com.ichi2.testutils.EmptyApplication +import com.ichi2.testutils.targetContext import com.ichi2.themes.Themes import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.empty import org.junit.Test +import org.junit.experimental.categories.Category import org.junit.runner.RunWith +import org.robolectric.annotation.Config /** * [Themes.setTheme] applies the user's theme in `onCreate`, so activities should not set @@ -21,7 +26,9 @@ import org.junit.runner.RunWith * the stale background is then visible behind transparent content */ @RunWith(AndroidJUnit4::class) -class ManifestThemeTest : RobolectricTest() { +@Config(application = EmptyApplication::class) +@Category(EmptyApplicationCategory::class) +class ManifestThemeTest : AndroidTest { /** * Manifest themes which are intentional. Additions to this list should explain why the * activity cannot use the application theme. 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/ReviewCompletionTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ReviewCompletionTest.kt new file mode 100644 index 000000000000..6507475c38f2 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ReviewCompletionTest.kt @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package com.ichi2.anki + +import android.content.Context +import android.content.Intent +import android.view.KeyEvent +import androidx.core.content.edit +import anki.scheduler.CardAnswer.Rating +import com.ichi2.anki.AbstractFlashcardViewer.Companion.RESULT_NO_MORE_CARDS +import com.ichi2.anki.CollectionManager.TR +import com.ichi2.anki.NavigationDrawerActivity.Companion.EXTRA_STARTED_WITH_SHORTCUT +import com.ichi2.anki.libanki.Consts.DEFAULT_DECK_ID +import com.ichi2.anki.pages.CongratsPage +import com.ichi2.testutils.ext.snackbarText +import org.json.JSONArray +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.shadows.ShadowToast +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@RunWith(ParameterizedRobolectricTestRunner::class) +class ReviewCompletionTest : RobolectricTest() { + @ParameterizedRobolectricTestRunner.Parameter(0) + @JvmField + var newCongratsScreen = false + + @ParameterizedRobolectricTestRunner.Parameter(1) + @JvmField + var fromShortcut = false + + override fun getCollectionStorageMode() = CollectionStorageMode.IN_MEMORY_WITH_MEDIA + + @Before + fun configureCompletionScreen() { + getPreferences().edit { putBoolean("new_congrats_screen", newCongratsScreen) } + ShadowToast.reset() + } + + @Test + fun `finished parent with cards only in its child still shows completion`() { + val child = addDeck("Parent::Child") + col.setDeck(listOf(addBasicNote().firstCard().id), child) + col.decks.select(col.decks.id("Parent")) + + completeReview(expectEmpty = false) { + col.sched.answerCard(assertNotNull(col.sched.card), Rating.EASY) + assertEquals(0, col.sched.totalCount()) + assertEquals(1, col.decks.cardCount(child, includeSubdecks = true)) + } + } + + @Test + fun `filtered deck emptied by the final review still shows completion`() { + addBasicNote() + val filtered = addDynamicDeck("Filtered", "") + + completeReview(expectEmpty = false) { + col.sched.answerCard(assertNotNull(col.sched.card), Rating.EASY) + assertEquals(0, col.decks.cardCount(filtered, includeSubdecks = true)) + } + } + + @Test + fun `learning later still shows completion with the next available time`() { + col.config.set("collapseTime", 0) + val config = col.decks.configDictForDeckId(DEFAULT_DECK_ID) + config.new.delays = JSONArray(listOf(1)) + col.decks.save(config) + addBasicNote() + + completeReview( + expectEmpty = false, + expectedMessage = { + it.getString( + CommonString.studyoptions_congrats_next_due_in, + TR.schedulingNextLearnDue("minutes", 1), + ) + }, + ) { + col.sched.answerCard(assertNotNull(col.sched.card), Rating.AGAIN) + assertNull(col.sched.card) + } + } + + private fun completeReview( + expectEmpty: Boolean, + expectedMessage: (Context) -> String = { + it.getString(if (expectEmpty) CommonString.empty_deck else CommonString.studyoptions_congrats_finished) + }, + finishCards: () -> Unit, + ) { + if (fromShortcut) { + runTest { + val reviewer = + startActivityNormallyOpenCollectionWithIntent( + Reviewer::class.java, + Intent().putExtra(EXTRA_STARTED_WITH_SHORTCUT, true), + ) + advanceRobolectricLooper() + assertNotNull(reviewer.currentCardId) + finishCards() + val selected = col.decks.selected() + val undoStatus = col.undoStatus() + + reviewer.updateCardAndRedraw() + advanceRobolectricLooper() + + assertTrue(reviewer.isFinishing) + assertEquals(RESULT_NO_MORE_CARDS, shadowOf(reviewer).resultCode) + if (newCongratsScreen && !expectEmpty) { + assertCongratsIntent(shadowOf(reviewer).nextStartedActivity) + } else { + assertNull(shadowOf(reviewer).nextStartedActivity) + assertEquals(expectedMessage(reviewer), ShadowToast.getTextOfLatestToast()) + } + assertEquals(selected, col.decks.selected()) + assertEquals(undoStatus, col.undoStatus()) + } + } else { + deckPicker { + advanceRobolectricLooper() + val shadow = shadowOf(this) + shadow.clearNextStartedActivities() + assertTrue(onKeyUp(KeyEvent.KEYCODE_S, KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_S))) + advanceRobolectricLooperUntil { shadow.peekNextStartedActivity() != null } + val review = assertNotNull(shadow.nextStartedActivityForResult) + assertEquals(Reviewer::class.java.name, review.intent.component?.className) + shadow.clearNextStartedActivities() + finishCards() + val selected = col.decks.selected() + val undoStatus = col.undoStatus() + + activityResultRegistry.dispatchResult(review.requestCode, RESULT_NO_MORE_CARDS, null) + + if (newCongratsScreen && !expectEmpty) { + advanceRobolectricLooperUntil { shadow.peekNextStartedActivity() != null } + assertCongratsIntent(shadow.nextStartedActivity) + } else { + advanceRobolectricLooperUntil { snackbarText == expectedMessage(this) } + assertNull(shadow.nextStartedActivity) + } + assertEquals(selected, col.decks.selected()) + assertEquals(undoStatus, col.undoStatus()) + } + } + } + + private fun assertCongratsIntent(intent: Intent?) { + assertEquals( + CongratsPage::class.java.name, + assertNotNull(intent).getStringExtra(SingleFragmentActivity.EXTRA_FRAGMENT_NAME), + ) + } + + companion object { + @ParameterizedRobolectricTestRunner.Parameters(name = "new screen = {0}, shortcut = {1}") + @JvmStatic + fun configurations() = + listOf(false, true).flatMap { newScreen -> + listOf(false, true).map { shortcut -> arrayOf(newScreen, shortcut) } + } + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerKeyboardInputTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerKeyboardInputTest.kt index 8db9d895d5c1..8cb943e15935 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerKeyboardInputTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerKeyboardInputTest.kt @@ -22,7 +22,7 @@ import com.ichi2.anki.AnkiDroidApp.Companion.sharedPrefs import com.ichi2.anki.cardviewer.Gesture import com.ichi2.anki.cardviewer.ViewerCommand import com.ichi2.anki.libanki.Card -import com.ichi2.anki.reviewer.Binding.Companion.keyCode +import com.ichi2.anki.reviewer.Binding.KeyCode import com.ichi2.anki.reviewer.Binding.ModifierKeys import com.ichi2.anki.reviewer.BindingMap import com.ichi2.anki.reviewer.CardSide @@ -143,7 +143,7 @@ class ReviewerKeyboardInputTest : RobolectricTest() { fun pressingZShouldUndoIfAvailable() { ViewerCommand.UNDO.addBinding( sharedPrefs(), - ReviewerBinding(keyCode(KEYCODE_Z, ModifierKeys.none()), CardSide.BOTH), + ReviewerBinding(KeyCode(KEYCODE_Z, ModifierKeys.none()), CardSide.BOTH), ) val underTest = KeyboardInputTestReviewer.displayingAnswer().withUndoAvailable(true) underTest.handleAndroidKeyPress(KEYCODE_Z) @@ -154,7 +154,7 @@ class ReviewerKeyboardInputTest : RobolectricTest() { fun pressingZShouldNotUndoIfNotAvailable() { ViewerCommand.UNDO.addBinding( sharedPrefs(), - ReviewerBinding(keyCode(KEYCODE_Z, ModifierKeys.none()), CardSide.BOTH), + ReviewerBinding(KeyCode(KEYCODE_Z, ModifierKeys.none()), CardSide.BOTH), ) val underTest = KeyboardInputTestReviewer.displayingAnswer().withUndoAvailable(false) underTest.handleUnicodeKeyPress('z') 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..04fca194182c 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt @@ -81,8 +81,12 @@ 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 which do not use the native backend. + */ +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 +172,7 @@ open class RobolectricTest : CollectionManager.closeCollectionBlocking() - maybeSetupBackend() + if (loadBackend) setupBackend() // Reset static variable for custom tabs failure. CustomTabActivityHelper.resetFailed() @@ -465,16 +469,7 @@ open class RobolectricTest : undoableOp { col.updateNote(note) } } - private fun maybeSetupBackend() { - try { - targetContext - } catch (exc: IllegalStateException) { - // We must make sure not to load the backend library into a test running outside - // the Robolectric classloader, or subsequent Robolectric tests that run in this - // process will be unable to make calls into the backend. - println("not annotated with junit, not setting up backend") - return - } + private fun setupBackend() { try { RustBackendLoader.ensureSetup() } catch (e: UnsatisfiedLinkError) { diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt index f0dd6c3072fb..9df05258c31e 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt @@ -2,17 +2,23 @@ package com.ichi2.anki +import android.os.Build import android.view.View import android.view.WindowManager import android.widget.EditText import androidx.core.content.getSystemService import androidx.core.view.allViews import androidx.core.view.drawToBitmap +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.matcher.ViewMatchers.isRoot import com.github.takahirom.roborazzi.ExperimentalRoborazziApi import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers +import com.github.takahirom.roborazzi.RoborazziATFAccessibilityCheckOptions +import com.github.takahirom.roborazzi.RoborazziATFAccessibilityChecker import com.github.takahirom.roborazzi.RoborazziOptions import com.github.takahirom.roborazzi.captureRoboImage import com.github.takahirom.roborazzi.captureScreenRoboImage +import com.github.takahirom.roborazzi.checkRoboAccessibility import com.github.takahirom.roborazzi.provideRoborazziContext import com.google.testing.junit.testparameterinjector.TestParameter import com.google.testing.junit.testparameterinjector.TestParameterValuesProvider @@ -31,8 +37,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 */ @@ -147,13 +151,39 @@ abstract class ScreenshotTest : RobolectricTest() { * Captures a screenshot to `build/outputs/roborazzi//.png`. * * Writes to /diffs/ if there is an issue. + * Add `-PaccessibilityScan` to scan the live UI and log accessibility findings. */ @OptIn(ExperimentalRoborazziApi::class) protected fun captureScreen(name: String) = capture(name) { filePath, options -> captureScreenRoboImage(filePath = filePath, roborazziOptions = options) + scanAccessibilityIfEnabled(filePath, options) } + /** + * Scan the live download UI across all themes, logging findings without failing on them: + * ```sh + * ./gradlew :AnkiDroid:testPlayDebugUnitTest -Pscreenshot -PaccessibilityScan -Ptheme=all \ + * --tests '*SharedDecksScreenshotTest.downloadFragment*' + * ``` + */ + @OptIn(ExperimentalRoborazziApi::class) + private fun scanAccessibilityIfEnabled( + filePath: String, + options: RoborazziOptions, + ) { + if (!java.lang.Boolean.getBoolean("screenshot.accessibilityScan")) return + check(Build.VERSION.SDK_INT >= 34) { "Accessibility scans require API 34 or newer" } + println("Accessibility scan: $filePath") + onView(isRoot()).checkRoboAccessibility( + roborazziATFAccessibilityCheckOptions = + RoborazziATFAccessibilityCheckOptions( + failureLevel = RoborazziATFAccessibilityChecker.CheckLevel.LogOnly, + ), + roborazziOptions = options, + ) + } + /** Captures a laid-out view, including content extending beyond the screen. */ protected fun captureView( name: String, diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/StudyOptionsFragmentTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/StudyOptionsFragmentTest.kt index 30a3e8b20f77..632ad4197a02 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/StudyOptionsFragmentTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/StudyOptionsFragmentTest.kt @@ -8,6 +8,7 @@ import android.widget.ScrollView import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.appcompat.view.menu.MenuBuilder +import androidx.fragment.app.FragmentActivity import androidx.fragment.app.commitNow import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope @@ -85,8 +86,8 @@ class StudyOptionsFragmentTest : RobolectricTest() { /** Issue 22348: replacing the tablet panel can detach it before its initial refresh starts. */ @Test @Config(qualifiers = "xlarge") - fun `replacing study options before its refresh starts exposes lifecycle misuse`() = - assertLifecycleFailure("not attached to an activity") { + fun `replacing study options before its refresh starts skips the stale refresh`() = + runTest { setIntroductionSlidesShown(true) addBasicNote() val controller = Robolectric.buildActivity(DeckPicker::class.java).setup() @@ -152,6 +153,28 @@ class StudyOptionsFragmentTest : RobolectricTest() { assertSame(initialState, fragment.viewModel.state) } + /** Issue 22065: the host can replace the fragment before its queued refresh starts. */ + @Test + fun `refresh queued by view creation is skipped after synchronous replacement`() { + withNullCollection { + val controller = Robolectric.buildActivity(FragmentActivity::class.java).setup() + saveControllerForCleanup(controller) + val activity = controller.get() + val first = StudyOptionsFragment() + val second = StudyOptionsFragment() + + activity.lifecycleScope.launch { + activity.supportFragmentManager.commitNow { replace(android.R.id.content, first) } + activity.supportFragmentManager.commitNow { replace(android.R.id.content, second) } + } + advanceRobolectricLooper() + + assertNull(first.activity) + assertSame(second, activity.supportFragmentManager.findFragmentById(android.R.id.content)) + assertTrue(second.isAdded) + } + } + /** Issue 21981: a refresh must offer recovery when the deck hierarchy is corrupt. */ @Test fun `corrupt deck hierarchy offers Check database instead of crashing`() = 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/ThrowableFilterServiceTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ThrowableFilterServiceTest.kt index 1659bb050980..b7c485eabe20 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ThrowableFilterServiceTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ThrowableFilterServiceTest.kt @@ -3,12 +3,11 @@ package com.ichi2.anki -import androidx.test.ext.junit.runners.AndroidJUnit4 import anki.backend.backendError import com.ichi2.anki.exception.StorageAccessException import com.ichi2.anki.servicelayer.ThrowableFilterService import com.ichi2.anki.servicelayer.ThrowableFilterService.safeFromPII -import com.ichi2.testutils.JvmTest +import com.ichi2.utils.ExceptionUtil.causeChain import net.ankiweb.rsdroid.BackendException import net.ankiweb.rsdroid.BackendException.BackendSchedulerUpgradeRequiredException import net.ankiweb.rsdroid.exceptions.BackendDeckIsFilteredException @@ -16,12 +15,15 @@ import net.ankiweb.rsdroid.exceptions.BackendNetworkException import net.ankiweb.rsdroid.exceptions.BackendSyncException import net.ankiweb.rsdroid.exceptions.BackendSyncException.BackendSyncServerMessageException import org.junit.Test -import org.junit.runner.RunWith +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNotSame +import kotlin.test.assertSame import kotlin.test.assertTrue -@RunWith(AndroidJUnit4::class) -class ThrowableFilterServiceTest : JvmTest() { +class ThrowableFilterServiceTest { @Test fun `scheduler upgrade required exceptions are discarded`() { val exception = BackendSchedulerUpgradeRequiredException(backendError {}) @@ -61,4 +63,223 @@ class ThrowableFilterServiceTest : JvmTest() { val exception3 = StorageAccessException("test exception") assertTrue(ThrowableFilterService.shouldDiscardThrowable(exception3)) } + + @Test + fun `circular causes are discarded before reporting`() { + val exception = Exception() + val cause = Exception() + exception.initCause(cause) + cause.initCause(exception) + + assertTrue(ThrowableFilterService.shouldDiscardThrowable(exception)) + } + + @Test + fun `PII in suppressed exceptions is filtered`() { + val exception = Exception() + val suppressed = Exception() + suppressed.addSuppressed(BackendSyncServerMessageException(backendError {})) + exception.addSuppressed(suppressed) + + assertFalse(exception.safeFromPII()) + assertTrue(ThrowableFilterService.shouldDiscardThrowable(exception)) + + suppressed.addSuppressed(exception) + assertFalse(exception.safeFromPII()) + } + + @Test + fun `ordinary uncaught exceptions reach the next handler unchanged`() { + val cause = IllegalArgumentException("wanted cause") + val exception = IllegalStateException("wanted", cause) + + assertSame(exception, forwardUnhandled(exception)) + exception.addSuppressed(cause) + assertFalse(ThrowableFilterService.shouldDiscardThrowable(exception)) + assertSame(exception, forwardUnhandled(exception)) + } + + @Test + fun `unwanted uncaught exceptions reach crash reporting without messages`() { + val exceptions = + listOf( + BackendNetworkException(backendError { message = "private network details" }), + BackendSyncException(backendError { message = "private sync details" }), + BackendSchedulerUpgradeRequiredException(backendError { message = "private scheduler details" }), + StorageAccessException("private storage path"), + BackendSyncServerMessageException(backendError { message = "private@example.com" }), + ) + for (exception in exceptions) { + val forwarded = forwardUnhandled(exception) + + assertNotSame(exception, forwarded) + assertEquals(exception.javaClass.name, forwarded.toString()) + assertContentEquals(exception.stackTrace, forwarded.stackTrace) + assertFalse(forwarded.stackTraceToString().contains("private")) + // Handled reports of the same types must continue to be filtered. + assertTrue(ThrowableFilterService.shouldDiscardThrowable(exception)) + } + } + + @Test + fun `uncaught PII exceptions have their entire cause chain sanitized`() { + val serverError = BackendSyncServerMessageException(backendError { message = "private@example.com" }) + val cause = + object : IllegalArgumentException("private cause", serverError) { + override fun toString(): String = error("Must not read a custom description") + + override fun getLocalizedMessage(): String = error("Must not read a localized message") + } + val exception = IllegalStateException("private outer message", cause) + // Sharing a cause as a suppressed exception must not truncate the main cause chain. + exception.addSuppressed(serverError) + + val forwarded = forwardUnhandled(exception) + + assertEquals(exception.javaClass.name, forwarded.toString()) + assertContentEquals(exception.stackTrace, forwarded.stackTrace) + val forwardedCause = assertNotNull(forwarded.cause) + assertEquals(cause.javaClass.name, forwardedCause.toString()) + assertContentEquals(cause.stackTrace, forwardedCause.stackTrace) + val forwardedServerError = assertNotNull(forwardedCause.cause) + assertEquals(serverError.javaClass.name, forwardedServerError.toString()) + assertContentEquals(serverError.stackTrace, forwardedServerError.stackTrace) + assertFalse(forwarded.stackTraceToString().contains("private")) + assertEquals("private outer message", exception.message) + assertSame(cause, exception.cause) + assertSame(serverError, cause.cause) + } + + @Test + fun `suppressed PII exceptions are detected and sanitized`() { + val exception = IllegalStateException("private outer message") + val suppressed = IllegalArgumentException("private suppressed message") + suppressed.addSuppressed(BackendSyncServerMessageException(backendError { message = "private@example.com" })) + exception.addSuppressed(suppressed) + + assertFalse(exception.safeFromPII()) + assertTrue(ThrowableFilterService.shouldDiscardThrowable(exception)) + val forwarded = forwardUnhandled(exception) + + val forwardedSuppressed = forwarded.suppressed.single() + assertEquals(suppressed.javaClass.name, forwardedSuppressed.toString()) + assertContentEquals(suppressed.stackTrace, forwardedSuppressed.stackTrace) + val suppressedServerError = suppressed.suppressed.single() + assertEquals(suppressedServerError.javaClass.name, forwardedSuppressed.suppressed.single().toString()) + assertFalse(forwarded.stackTraceToString().contains("private")) + assertSame(suppressed, exception.suppressed.single()) + } + + @Test + fun `circular exception graphs are sanitized with and without PII types`() { + val exception = Exception("private outer message") + val cause = Exception("private cause") + exception.initCause(cause) + cause.initCause(exception) + cause.addSuppressed(exception) + assertTrue(exception.safeFromPII()) + + repeat(2) { + val forwarded = forwardUnhandled(exception) + val forwardedCause = assertNotNull(forwarded.cause) + assertNotSame(exception, forwarded) + assertEquals(cause.javaClass.name, forwardedCause.toString()) + assertEquals(null, forwardedCause.cause) + assertTrue(forwardedCause.suppressed.isEmpty()) + assertTrue(ThrowableFilterService.shouldDiscardThrowable(exception)) + assertFalse(forwarded.stackTraceToString().contains("private")) + + exception.addSuppressed(BackendSyncServerMessageException(backendError { message = "private@example.com" })) + assertFalse(exception.safeFromPII()) + } + } + + @Test + fun `deep exception chains do not overflow filtering or sanitization`() { + var cause: Throwable = BackendSyncServerMessageException(backendError { message = "private@example.com" }) + repeat(2000) { cause = Exception("private cause", cause) } + + assertFalse(cause.safeFromPII()) + val forwarded = forwardUnhandled(cause) + + val forwardedCauses = forwarded.causeChain.toList() + assertEquals(2001, forwardedCauses.size) + assertTrue(forwardedCauses.none { it.message?.contains("private") == true }) + } + + @Test + fun `reinitialization keeps filtered fatal errors reportable`() { + val exception = StorageAccessException("private path") + val reports = mutableListOf() + withReportingHandler( + Thread.UncaughtExceptionHandler { _, throwable -> reports.add(throwable) }, + ) { + ThrowableFilterService.reInitialize() + val handler = assertNotNull(Thread.getDefaultUncaughtExceptionHandler()) + + handler.uncaughtException(Thread.currentThread(), exception) + + val report = reports.single() + assertEquals(exception.javaClass.name, report.toString()) + assertFalse(report.stackTraceToString().contains("private")) + } + } + + @Test + fun `installed filter forwards an uncaught worker thread exception`() { + val exception = StorageAccessException("private storage path") + val worker = Thread { throw exception } + var forwardedThread: Thread? = null + var forwarded: Throwable? = null + var reports = 0 + withReportingHandler( + Thread.UncaughtExceptionHandler { thread, throwable -> + reports++ + forwardedThread = thread + forwarded = throwable + }, + ) { + worker.start() + worker.join(5000) + + assertFalse(worker.isAlive) + assertEquals(1, reports) + assertSame(worker, forwardedThread) + assertEquals(exception.javaClass.name, assertNotNull(forwarded).toString()) + assertFalse(assertNotNull(forwarded).stackTraceToString().contains("private")) + } + } + + private fun withReportingHandler( + reportingHandler: Thread.UncaughtExceptionHandler, + block: () -> Unit, + ) { + val originalDefault = Thread.getDefaultUncaughtExceptionHandler() + val originalHandler = ThrowableFilterService.originalUncaughtExceptionHandler + try { + Thread.setDefaultUncaughtExceptionHandler(reportingHandler) + ThrowableFilterService.initialize() + block() + } finally { + Thread.setDefaultUncaughtExceptionHandler(originalDefault) + ThrowableFilterService.originalUncaughtExceptionHandler = originalHandler + } + } + + private fun forwardUnhandled(exception: Throwable): Throwable { + val thread = Thread.currentThread() + var forwarded: Throwable? = null + var calls = 0 + withReportingHandler( + Thread.UncaughtExceptionHandler { actualThread, throwable -> + assertSame(thread, actualThread) + forwarded = throwable + calls++ + }, + ) { + ThrowableFilterService.uncaughtExceptionHandler.uncaughtException(thread, exception) + } + assertEquals(1, calls, "The uncaught exception must reach the next handler exactly once") + return assertNotNull(forwarded) + } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/TranslationTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/TranslationTest.kt index 6dc47400f2a8..1e6180698f95 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/TranslationTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/TranslationTest.kt @@ -2,18 +2,17 @@ package com.ichi2.anki -import androidx.test.ext.junit.runners.AndroidJUnit4 import anki.i18n.GeneratedTranslations import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.TranslationTest.Companion.BASELINE_CASE_INSENSITIVE_DUPLICATES import com.ichi2.anki.TranslationTest.Companion.BASELINE_DUPLICATES import com.ichi2.testutils.BackendTranslation +import com.ichi2.testutils.JvmTest import com.ichi2.testutils.XmlStringResource import com.ichi2.testutils.getAndroidManifestStringResourceNames import com.ichi2.testutils.getBackendNonArgStrings import com.ichi2.testutils.getTranslatableXmlStrings import org.junit.Test -import org.junit.runner.RunWith import java.io.File import kotlin.test.fail @@ -22,8 +21,7 @@ import kotlin.test.fail * (01-core, 02-strings, etc.) do not duplicate strings already available * from the backend via [GeneratedTranslations]/[TR]. */ -@RunWith(AndroidJUnit4::class) // TODO: no Android dependencies; could be JvmTest -class TranslationTest : RobolectricTest() { +class TranslationTest : JvmTest() { @Test fun `CrowdIn-managed files do not reappear in this module`() { val resDir = File("src/main/res") diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/TtsVoicesCacheTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/TtsVoicesCacheTest.kt index 70bff1e26881..21e3101a6152 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/TtsVoicesCacheTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/TtsVoicesCacheTest.kt @@ -3,9 +3,7 @@ package com.ichi2.anki import android.speech.tts.TextToSpeech -import androidx.test.ext.junit.runners.AndroidJUnit4 import com.ichi2.anki.i18n.normalize -import com.ichi2.testutils.EmptyApplication import com.ichi2.testutils.TtsVoicesCacheOverride import io.mockk.Runs import io.mockk.coEvery @@ -14,15 +12,12 @@ import io.mockk.every import io.mockk.just import io.mockk.mockk import io.mockk.mockkObject +import kotlinx.coroutines.test.runTest import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.annotation.Config import java.util.Locale import kotlin.test.assertEquals -@RunWith(AndroidJUnit4::class) -@Config(application = EmptyApplication::class) -class TtsVoicesCacheTest : RobolectricTest() { +class TtsVoicesCacheTest { @Test fun `a new reviewer engine gets its own cached languages`() = runTest { 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/account/LoginFragmentTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/account/LoginFragmentTest.kt index c527d3310765..3a7cf6c80649 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/account/LoginFragmentTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/account/LoginFragmentTest.kt @@ -2,6 +2,8 @@ package com.ichi2.anki.account +import android.os.Looper +import android.view.WindowManager import android.widget.Button import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.android.material.textfield.TextInputEditText @@ -16,6 +18,9 @@ import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeSource @RunWith(AndroidJUnit4::class) class LoginFragmentTest : RobolectricTest() { @@ -43,8 +48,17 @@ class LoginFragmentTest : RobolectricTest() { activity.findViewById(R.id.password).setText("hunter2") activity.findViewById