diff --git a/.github/check_alpha_release.js b/.github/check_alpha_release.js
new file mode 100644
index 000000000000..332305a69137
--- /dev/null
+++ b/.github/check_alpha_release.js
@@ -0,0 +1,54 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+const fs = require("node:fs");
+
+module.exports = async function checkAlphaRelease({ github, context, core }) {
+ 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;
+ }
+
+ const releases = await github.paginate(github.rest.repos.listReleases, {
+ ...context.repo,
+ per_page: 100,
+ });
+ // Use calendar days to check, so last Monday's release doesn't block this release.
+ const day = 24 * 60 * 60 * 1000;
+ const today = Math.floor(Date.now() / day);
+ const recentRelease = releases.find(
+ 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 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;
+ }
+
+ const { data: comparison } = await github.rest.repos.compareCommitsWithBasehead({
+ ...context.repo,
+ 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;
+ }
+
+ core.info(
+ `Publishing an alpha: ${comparison.ahead_by} new commits since ${latestAlpha.tag_name}.`,
+ );
+ return true;
+};
diff --git a/.github/run_emulator_tests.py b/.github/run_emulator_tests.py
new file mode 100644
index 000000000000..f384f060acc5
--- /dev/null
+++ b/.github/run_emulator_tests.py
@@ -0,0 +1,57 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+"""Run emulator tests and generate the coverage report."""
+
+import hashlib
+from pathlib import Path
+import subprocess
+import sys
+import time
+
+
+def read_coverage():
+ shell = ["adb", "shell", "-T", "run-as", "com.ichi2.anki"]
+ for attempt in range(1, 6):
+ try:
+ checksum = subprocess.check_output([*shell, "sha256sum", "coverage.ec"], timeout=30).split()
+ data = subprocess.check_output([*shell, "cat", "coverage.ec"], timeout=30)
+ # ADB can exit successfully after a truncated transfer.
+ if not data or not checksum or hashlib.sha256(data).hexdigest().encode() != checksum[0]:
+ raise ValueError("Coverage copy is empty or incomplete")
+ return data
+ except (subprocess.SubprocessError, ValueError) as error:
+ # TODO (Issue 22147 - Dec 2026): check CI logs for "Coverage copy attempt" failures.
+ print(f"Coverage copy attempt {attempt}/5 failed: {error}", file=sys.stderr)
+ if attempt == 5:
+ raise
+ time.sleep(1)
+
+
+def main():
+ connected_test = ":AnkiDroid:connectedPlayReleaseAndroidTest"
+ subprocess.run(
+ ["./gradlew", "uninstallAll", connected_test,
+ "-Pandroid.injected.androidTest.leaveApksInstalledAfterRun=true", "--daemon"],
+ check=True,
+ )
+ data = read_coverage()
+ output_dir = Path("AnkiDroid/build/outputs/code_coverage/playReleaseAndroidTest/connected")
+ output_dir.mkdir(parents=True, exist_ok=True)
+ # JaCoCo reads every .ec file, including AGP's potentially truncated copy.
+ for previous in output_dir.rglob("*.ec"):
+ previous.unlink()
+ (output_dir / "coverage.ec").write_bytes(data)
+ subprocess.run(
+ ["./gradlew", ":AnkiDroid:jacocoAndroidTestReport", "-x", connected_test, "--daemon"],
+ check=True,
+ )
+
+
+if __name__ == "__main__":
+ try:
+ main()
+ except subprocess.CalledProcessError as error:
+ sys.exit(error.returncode)
+ except (OSError, ValueError, subprocess.TimeoutExpired) as error:
+ sys.exit(str(error))
diff --git a/.github/workflows/README.md b/.github/workflows/README.md
index a6051a7cbbdf..3b016a6088a4 100644
--- a/.github/workflows/README.md
+++ b/.github/workflows/README.md
@@ -22,4 +22,21 @@ Alternately, you may run the actions on your fork of `Anki-Android`.
## Other Workflows
-These are typically run by maintainers. See the [Maintenance guide](https://github.com/ankidroid/Anki-Android/wiki/Maintenance-guide)
\ No newline at end of file
+These are typically run by maintainers. See the [Maintenance guide](https://github.com/ankidroid/Anki-Android/wiki/Maintenance-guide)
+
+### Merge queue cancellation
+
+GitHub can leave jobs running after their merge group has been removed.
+
+`cancel_merge_queue_runs.yml` is triggered when a temporary merge queue branch is deleted.
+The workflow cancels unfinished runs for unmerged groups.
+
+### Automatic publish
+
+An automatic alpha publish is performed every Monday at 04:30 UTC. This publish is skipped if:
+
+* A publish occurred fewer than 7 UTC calendar days ago.
+* The app is not in alpha.
+* There were no commits since the last release.
+
+An alpha published on Monday does not block the following Monday's release, regardless of the time it finished.
diff --git a/.github/workflows/cancel_merge_queue_runs.yml b/.github/workflows/cancel_merge_queue_runs.yml
new file mode 100644
index 000000000000..1b460586dced
--- /dev/null
+++ b/.github/workflows/cancel_merge_queue_runs.yml
@@ -0,0 +1,135 @@
+# SPDX-License-Identifier: GPL-3.0-or-later
+name: Cancel Obsolete Merge Queue Runs
+
+# `merge_group: destroyed` identifies the merge group and reason for removal,
+# but is not documented as a supported Actions trigger. Use `delete` instead.
+on:
+ # GitHub deletes temporary branches after discarded groups and successful merges.
+ # Preserve runs for merged commits: their checks also appear on the target branch.
+ delete:
+
+permissions: {}
+
+jobs:
+ cancel:
+ if: >-
+ github.event.ref_type == 'branch' &&
+ startsWith(github.event.ref, 'gh-readonly-queue/')
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ permissions:
+ actions: write
+ contents: read
+ steps:
+ # SECURITY: never check out PR code here, use an inline script.
+ - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ with:
+ script: |
+ const deletedBranch = context.payload.ref;
+ const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
+
+ // Target branch names may contain slashes. Refuse unfamiliar queue ref formats.
+ const targetBranch = /^gh-readonly-queue\/(.+)\/pr-\d+-[0-9a-f]{40}$/.exec(deletedBranch)?.[1];
+ if (!targetBranch) {
+ core.setFailed(`Cannot determine the target branch for ${deletedBranch}; skipping cleanup.`);
+ return;
+ }
+
+ // Collect unfinished merge-group workflow runs for this deleted branch.
+ const candidateRuns = await findUnfinishedMergeGroupRuns(deletedBranch);
+ for (const run of candidateRuns) {
+ try {
+ const outcome = await cancelObsoleteRun(run);
+
+ // The branch may have been requeued and its runs may be needed again.
+ if (outcome === 'branch-recreated') return;
+ } catch (error) {
+ core.setFailed(`Could not clean up run ${run.id}: ${error.message}`);
+ }
+ }
+
+ async function findUnfinishedMergeGroupRuns(branch) {
+ const runs = new Map();
+ let discoveryError;
+
+ // Poll three times to reduce the impact of stale API results.
+ // Known defect: all three scans can omit unfinished runs, potentially missing
+ // the entire group. Cleanup can then report success without cancelling them.
+ // There is no automatic follow-up cleanup, so missed runs can continue
+ // consuming runner capacity.
+ // TODO(2026-10-01): Remove when resolved: https://github.com/orgs/community/discussions/206725
+ for (let scan = 0; scan < 3; scan++) {
+ if (scan > 0) await delay(10000);
+ try {
+ const discovered = await github.paginate(github.rest.actions.listWorkflowRunsForRepo, {
+ ...context.repo, branch, event: 'merge_group', per_page: 100,
+ });
+ for (const run of discovered) {
+ if (run.event === 'merge_group' && run.head_branch === branch && run.status !== 'completed') {
+ runs.set(run.id, run);
+ }
+ }
+ discoveryError = undefined;
+ } catch (error) {
+ discoveryError = error;
+ core.warning(`Discovery failed: ${error.message}`);
+ }
+ }
+ if (discoveryError) core.setFailed(`Could not finish discovering runs: ${discoveryError.message}`);
+ return runs.values();
+ }
+
+ async function getCancellationStatus(runId) {
+ const { data: run } = await github.rest.actions.getWorkflowRun({ ...context.repo, run_id: runId });
+ if (run.status === 'completed' || run.run_attempt !== 1) return 'ineligible';
+
+ try {
+ await github.rest.git.getRef({ ...context.repo, ref: `heads/${deletedBranch}` });
+ core.info('Skipping cleanup: the queue branch exists again.');
+ return 'branch-recreated';
+ } catch (error) {
+ if (error.status !== 404) throw error;
+ }
+
+ // The target may have advanced since merging, so check ancestry, not just its tip.
+ // Comparison errors must prevent cancellation; a missing queue ref alone is insufficient.
+ const { data: comparison } = await github.rest.repos.compareCommitsWithBasehead({
+ ...context.repo, basehead: `${run.head_sha}...refs/heads/${targetBranch}`, per_page: 1,
+ });
+ if (comparison.status === 'ahead' || comparison.status === 'identical') {
+ core.info(`Skipping ${run.id}: its commit is already on ${targetBranch}.`);
+ return 'merged';
+ }
+ if (comparison.status !== 'behind' && comparison.status !== 'diverged') {
+ throw new Error(`Unexpected comparison status: ${comparison.status}`);
+ }
+ return 'eligible';
+ }
+
+ async function cancelObsoleteRun(run) {
+ let lastError;
+ for (let attempt = 1; attempt <= 3; attempt++) {
+ try {
+ // Recheck completion, reruns, branch recreation, and merging before each cancellation.
+ const status = await getCancellationStatus(run.id);
+ if (status !== 'eligible') return status;
+
+ core.info(`Cancelling ${run.id}: ${run.name}`);
+ await github.rest.actions.cancelWorkflowRun({ ...context.repo, run_id: run.id });
+ return;
+ } catch (error) {
+ // 409 Conflict => the run could be retried, in which case we skip this run.
+ const retryable = error.status == null || error.status === 409 || (error.status >= 500 && error.status < 600);
+ if (!retryable) throw error;
+ lastError = error;
+ core.warning(`Run ${run.id}: ${error.message}; rechecking in 5 seconds.`);
+ }
+ await delay(5000);
+ }
+
+ // The run may have finished, been retried, merged, or its branch been recreated.
+ // Recheck once more; if still eligible, report the error without a fourth request.
+ const status = await getCancellationStatus(run.id);
+ if (status === 'eligible') throw lastError;
+ return status;
+ }
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index e9b4b38cd16e..a4361bf82d18 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -72,7 +72,7 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
- uses: github/codeql-action/init@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
+ uses: github/codeql-action/init@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
with:
languages: ${{ matrix.language }}
build-mode: ${{ matrix.build-mode }}
@@ -86,7 +86,7 @@ jobs:
# AnkiDroid requires JDK21+ to build, current runners default to 17
- name: Configure JDK
if: matrix.language == 'java-kotlin'
- uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
+ uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with:
distribution: "jetbrains"
java-version: "21" # also change jitpack.yml
@@ -108,6 +108,6 @@ jobs:
./gradlew --stop
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@cdf488f595d80d6e07e03d4674febd5ab45fa938 # v4.37.9
+ uses: github/codeql-action/analyze@2892aa5e19bbd11bc0cff5427e3b750a04d9e3c2 # v4.38.2
with:
category: "/language:${{matrix.language}}"
diff --git a/.github/workflows/compare_apk_size.yml b/.github/workflows/compare_apk_size.yml
index 7d48c8061ed7..29127679994e 100644
--- a/.github/workflows/compare_apk_size.yml
+++ b/.github/workflows/compare_apk_size.yml
@@ -30,7 +30,7 @@ jobs:
KEYALIAS: nrkeystorealias
steps:
- name: Configure JDK
- uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
+ uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with:
distribution: "jetbrains"
java-version: "21"
@@ -51,7 +51,7 @@ jobs:
ref: 'refs/pull/${{ github.event.inputs.prNumber }}/head'
- name: Setup Gradle
- uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
+ uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
timeout-minutes: 5
with:
# Use open source provider: https://github.com/gradle/actions/blob/main/docs/setup-gradle.md#basic-caching
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index b95542719c11..3a1a2cd53e7d 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -29,7 +29,7 @@ jobs:
fetch-depth: 2
- name: Configure JDK
- uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
+ uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with:
distribution: "jetbrains"
java-version: "21"
@@ -37,7 +37,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Gradle
- uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
+ uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
timeout-minutes: 5
with:
# Use open source provider: https://github.com/gradle/actions/blob/main/docs/setup-gradle.md#basic-caching
@@ -88,7 +88,7 @@ jobs:
- name: Prettify AnkiDroid javascript code
uses: creyD/prettier_action@8c18391fdc98ed0d884c6345f03975edac71b8f0 # v4.6
with:
- prettier_options: --check AnkiDroid/**/*.js
+ prettier_options: --check AnkiDroid/**/*.js .github/check_alpha_release.js
dry: True
- name: Install Localization
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 2adaf6bd1313..c81eed38e5e8 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -1,6 +1,9 @@
name: 🚀 Publish
on:
+ schedule:
+ # Check every Monday (04:30); skip if any release is fewer than seven UTC calendar days old.
+ - cron: "30 4 * * 1"
workflow_dispatch:
inputs:
public:
@@ -22,8 +25,50 @@ concurrency:
cancel-in-progress: false
jobs:
+ check_release:
+ name: 'Check whether a release is due'
+ if: github.event_name == 'schedule' && github.repository == 'ankidroid/Anki-Android' && github.ref == 'refs/heads/main'
+ timeout-minutes: 20
+ runs-on: ubuntu-latest
+ outputs:
+ publish: ${{ steps.check.outputs.result }}
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - name: Configure JDK
+ uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
+ with:
+ distribution: "jetbrains"
+ java-version: "21"
+
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
+ with:
+ cache-provider: basic
+ cache-read-only: true
+
+ - name: Write release metadata
+ run: ./gradlew :AnkiDroid:writeReleaseMetadata --console=plain
+
+ - id: check
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ with:
+ script: |
+ const checkAlphaRelease = require('./.github/check_alpha_release.js');
+ return await checkAlphaRelease({ github, context, core });
+
release_app:
name: 'Release App'
+ # wait for 'check_release' (success/skipped)
+ needs: check_release
+ # Manual releases proceed when the scheduled check is skipped.
+ if: >
+ github.repository == 'ankidroid/Anki-Android' &&
+ !cancelled() &&
+ (
+ github.event_name == 'workflow_dispatch' ||
+ (success() && needs.check_release.outputs.publish == 'true')
+ )
timeout-minutes: 120
runs-on: ubuntu-latest
steps:
@@ -38,7 +83,7 @@ jobs:
key: gradle-${{ hashFiles('**/*.gradle*') }}-v1
- name: Configure JDK
- uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
+ uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with:
distribution: "jetbrains"
java-version: "21"
diff --git a/.github/workflows/screenshot_comment.yml b/.github/workflows/screenshot_comment.yml
index a5808c07b452..1e994a187b84 100644
--- a/.github/workflows/screenshot_comment.yml
+++ b/.github/workflows/screenshot_comment.yml
@@ -44,7 +44,7 @@ jobs:
steps:
- name: Download PR number artifact
- uses: dawidd6/action-download-artifact@d63b86af1b34672e53c440b1b83979861906bad7 # v24
+ uses: dawidd6/action-download-artifact@eab87c9830c39eff17e5a6eadb20bfb4bc880477 # v27
with:
name: pr
run_id: ${{ github.event.workflow_run.id }}
@@ -54,7 +54,7 @@ jobs:
run: |
echo "pull_request_number=$(cat NR)" >> "$GITHUB_OUTPUT"
- name: Download screenshot diff artifact
- uses: dawidd6/action-download-artifact@d63b86af1b34672e53c440b1b83979861906bad7 # v24
+ uses: dawidd6/action-download-artifact@eab87c9830c39eff17e5a6eadb20bfb4bc880477 # v27
with:
run_id: ${{ github.event.workflow_run.id }}
name: screenshot-diff
diff --git a/.github/workflows/screenshot_compare.yml b/.github/workflows/screenshot_compare.yml
index 800b17fa2416..514dc959432f 100644
--- a/.github/workflows/screenshot_compare.yml
+++ b/.github/workflows/screenshot_compare.yml
@@ -43,7 +43,7 @@ jobs:
- name: Download base branch screenshots
id: baseline
- uses: dawidd6/action-download-artifact@d63b86af1b34672e53c440b1b83979861906bad7 # v24
+ uses: dawidd6/action-download-artifact@eab87c9830c39eff17e5a6eadb20bfb4bc880477 # v27
with:
name: screenshot
workflow: screenshot_store.yml
@@ -60,7 +60,7 @@ jobs:
echo 'Screenshot comparison did not run because the base branch baseline could not be downloaded. Run **Screenshots: Store Baseline** on the PR base branch, then rerun this check.' >> "$GITHUB_STEP_SUMMARY"
- name: Configure JDK
- uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
+ uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with:
distribution: "jetbrains"
java-version: "21"
@@ -68,7 +68,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Gradle
- uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
+ uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with:
# Use open source provider: https://github.com/gradle/actions/blob/main/docs/setup-gradle.md#basic-caching
cache-provider: basic
diff --git a/.github/workflows/screenshot_store.yml b/.github/workflows/screenshot_store.yml
index 4b1df8549681..c8ec4e93eb8b 100644
--- a/.github/workflows/screenshot_store.yml
+++ b/.github/workflows/screenshot_store.yml
@@ -55,7 +55,7 @@ jobs:
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Configure JDK
- uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
+ uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with:
distribution: "jetbrains"
java-version: "21"
@@ -64,7 +64,7 @@ jobs:
# Better than caching and/or extensions of actions/setup-java
- name: Setup Gradle
- uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
+ uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
with:
# Use open source provider: https://github.com/gradle/actions/blob/main/docs/setup-gradle.md#basic-caching
cache-provider: basic
diff --git a/.github/workflows/tests_emulator.yml b/.github/workflows/tests_emulator.yml
index 714274db142c..b6da152e6d51 100644
--- a/.github/workflows/tests_emulator.yml
+++ b/.github/workflows/tests_emulator.yml
@@ -120,7 +120,7 @@ jobs:
sudo udevadm trigger --name-match=kvm
- name: Configure JDK
- uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
+ uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with:
distribution: "jetbrains"
java-version: "21" # also change jitpack.yml
@@ -128,7 +128,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Gradle
- uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
+ uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
timeout-minutes: 5
with:
# Use open source provider: https://github.com/gradle/actions/blob/main/docs/setup-gradle.md#basic-caching
@@ -164,16 +164,31 @@ jobs:
- name: Install Android Emulator
# Retry to work around https://github.com/ReactiveCircus/android-emulator-runner/issues/496
uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
+ env:
+ SDK_API_LEVEL: ${{ matrix.api-level }}
+ SDK_TARGET: ${{ matrix.target }}
+ SDK_ARCH: ${{ matrix.arch }}
+ # Keep in sync with sdk-installer.ts in the pinned android-emulator-runner action.
+ SDK_BUILD_TOOLS_VERSION: '37.0.0'
with:
timeout_minutes: 5
retry_wait_seconds: 60
max_attempts: 3
- command: '"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --verbose --install emulator --channel=0'
+ command: >-
+ "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --verbose --install
+ "build-tools;$SDK_BUILD_TOOLS_VERSION"
+ platform-tools "platforms;android-$SDK_API_LEVEL"
+ emulator "system-images;android-$SDK_API_LEVEL;$SDK_TARGET;$SDK_ARCH"
+ --channel=0
# sdkmanager preserves failed downloads and installation state for resuming.
on_retry_command: >-
rm -rf "${ANDROID_HOME:?}/.temp"
"${ANDROID_HOME:?}/.downloadIntermediates"
"${ANDROID_HOME:?}/emulator/.installer"
+ "${ANDROID_HOME:?}/build-tools/${SDK_BUILD_TOOLS_VERSION:?}/.installer"
+ "${ANDROID_HOME:?}/platform-tools/.installer"
+ "${ANDROID_HOME:?}/platforms/android-${SDK_API_LEVEL:?}/.installer"
+ "${ANDROID_HOME:?}/system-images/android-${SDK_API_LEVEL:?}/${SDK_TARGET:?}/${SDK_ARCH:?}/.installer"
- name: AVD Boot and Snapshot Creation
# Only generate a snapshot for saving if we are on main branch with a cache miss
@@ -241,7 +256,7 @@ jobs:
bash .github/adb_retry.sh shell settings put global hide_error_dialogs 1
bash .github/adb_retry.sh emu screenrecord start --time-limit 1800 video.webm
sleep 5
- bash .github/with_emulator_log.sh adb-log.txt ./gradlew uninstallAll jacocoAndroidTestReport --daemon
+ bash .github/with_emulator_log.sh adb-log.txt python3 .github/run_emulator_tests.py
- name: Report Emulator Test Results
if: always() && hashFiles('AnkiDroid/build/outputs/androidTest-results/connected/**/TEST-*.xml') != ''
@@ -316,7 +331,7 @@ jobs:
name: ${{ matrix.api-level }}-${{ matrix.arch }}-${{matrix.target}}-${{matrix.first-boot-delay}}-${{matrix.iteration}}-adb_video
path: video.webm
- - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
+ - uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
with:
verbose: true
fail_ci_if_error: ${{ github.repository == 'ankidroid/Anki-Android' }} # optional (default = false)
diff --git a/.github/workflows/tests_unit.yml b/.github/workflows/tests_unit.yml
index 60d628eb8479..1066288b4ccf 100644
--- a/.github/workflows/tests_unit.yml
+++ b/.github/workflows/tests_unit.yml
@@ -137,7 +137,7 @@ jobs:
fetch-depth: 50
- name: Configure JDK
- uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
+ uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1
with:
distribution: "jetbrains"
java-version: "21" # also change jitpack.yml
@@ -145,7 +145,7 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Setup Gradle
- uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
+ uses: gradle/actions/setup-gradle@3f5f9adaf7d9fecd50b5935e54106014257a94e6 # v6.4.0
timeout-minutes: 10
with:
# Use open source provider: https://github.com/gradle/actions/blob/main/docs/setup-gradle.md#basic-caching
@@ -199,7 +199,7 @@ jobs:
if: contains(matrix.os, 'windows')
run: ./gradlew --stop
- - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
+ - uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
with:
verbose: true
fail_ci_if_error: ${{ github.repository == 'ankidroid/Anki-Android' }} # optional (default = false)
diff --git a/.idea/dictionaries/davidallison.xml b/.idea/dictionaries/davidallison.xml
index 833a85d06aa6..fce38a732117 100644
--- a/.idea/dictionaries/davidallison.xml
+++ b/.idea/dictionaries/davidallison.xml
@@ -95,6 +95,7 @@
rerenderresyncretryable
+ scrollshotsearchpreferenceservicelayersinh
diff --git a/AnkiDroid/build.gradle b/AnkiDroid/build.gradle
index 767bc2fa7773..1614e965532d 100644
--- a/AnkiDroid/build.gradle
+++ b/AnkiDroid/build.gradle
@@ -1,11 +1,11 @@
import com.ichi2.anki.gradle.VersionCodeTasks
+import com.ichi2.anki.gradle.WriteReleaseMetadata
plugins {
// Gradle plugin portal
alias(libs.plugins.tripletPlay)
- // TODO: migrate to .kts & replace the next 2 id lines with id("ankidroid.android.app")
- id 'com.android.application'
- id 'org.jetbrains.kotlin.plugin.parcelize'
+ id 'ankidroid.android.app'
+ id 'ankidroid.plugins.jacoco'
alias(libs.plugins.androidx.baselineprofile)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.kotlin.compose)
@@ -66,9 +66,6 @@ def buildTimeMillis = providers.gradleProperty("buildTime")
android {
namespace = "com.ichi2.anki"
- compileSdk = libs.versions.compileSdk.get().toInteger()
- compileSdkMinor = libs.versions.compileSdkMinor.get().toInteger()
-
buildFeatures {
buildConfig = true
aidl = true
@@ -118,13 +115,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=22600101
+ versionCode=22600102
// 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.0alpha1"
- minSdk = libs.versions.minSdk.get().toInteger()
-
- // After #13695: change .tests_emulator.yml
- targetSdk = libs.versions.targetSdk.get().toInteger()
+ versionName="2.26.0alpha2"
testApplicationId = "com.ichi2.anki.tests"
vectorDrawables.useSupportLibrary = true
testInstrumentationRunner = 'com.ichi2.testutils.NewCollectionPathTestRunner'
@@ -335,11 +328,6 @@ android {
testOptions {
animationsDisabled = true
- kotlin {
- compilerOptions {
- freeCompilerArgs.add("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi")
- }
- }
unitTests {
all { test ->
test.useJUnitPlatform {
@@ -386,8 +374,6 @@ android {
}
compileOptions {
- sourceCompatibility JavaVersion.VERSION_17
- targetCompatibility JavaVersion.VERSION_17
coreLibraryDesugaringEnabled = true
}
@@ -398,8 +384,17 @@ android {
}
}
-// register `validateVersionCode` with our (potentially new versionCode).
+// register release tasks using the finalized version configuration.
androidComponents.finalizeDsl { androidDsl ->
+ // output versionName etc... to `build/outputs/release-metadata.json` for use in CI
+ tasks.register('writeReleaseMetadata', WriteReleaseMetadata) {
+ group = 'release'
+ description = 'Writes the configured release version and type as JSON.'
+ versionName.set(androidDsl.defaultConfig.versionName)
+ versionCode.set(androidDsl.defaultConfig.versionCode)
+ }
+
+ // register `validateVersionCode` with the finalized version code.
def validation = VersionCodeTasks.registerValidationTask(project, androidDsl.defaultConfig.versionCode)
// handle all 'publish' calls, even if run directly
tasks.matching { it.name.startsWith("publish") }.configureEach {
@@ -685,8 +680,6 @@ afterEvaluate {
}
apply from: "./robolectricDownloader.gradle"
-apply from: "./jacoco.gradle.kts"
-apply from: "../lint.gradle"
android {
lint {
diff --git a/AnkiDroid/robolectricDownloader.gradle b/AnkiDroid/robolectricDownloader.gradle
index f70b83d96177..11bd88d9406e 100644
--- a/AnkiDroid/robolectricDownloader.gradle
+++ b/AnkiDroid/robolectricDownloader.gradle
@@ -18,7 +18,7 @@
// Run `./gradlew jacocoUnitTestReport` to check that all SDKs used by the tests are included.
def robolectricAndroidSdkVersions = [
// [androidVersion: "6.0.1_r3", frameworkSdkBuildVersion: "r1"],
-// [androidVersion: "7.0.0_r1", frameworkSdkBuildVersion: "r1"],
+ [androidVersion: "7.0.0_r1", frameworkSdkBuildVersion: "r1"],
[androidVersion: "7.1.0_r7", frameworkSdkBuildVersion: "r1"],
// [androidVersion: "8.0.0_r4", frameworkSdkBuildVersion: "r1"],
[androidVersion: "8.1.0", frameworkSdkBuildVersion: "4611349"],
@@ -32,6 +32,7 @@ def robolectricAndroidSdkVersions = [
[androidVersion: "15", frameworkSdkBuildVersion: "13954326"],
// TODO: check TarArchiveInputStream.forEachEntry when this is updated
[androidVersion: "16", frameworkSdkBuildVersion: "13921718"], // current targetSdk
+ [androidVersion: "17", frameworkSdkBuildVersion: "15733970"],
]
def robolectricSdkDownload = tasks.register('robolectricSdkDownload', WriteProperties) {
@@ -56,4 +57,3 @@ robolectricAndroidSdkVersions.forEach { robolectricSdkVersion ->
sdkConfig.elements.map { it.iterator().next().asFile.absolutePath })
}
}
-
diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/CardViewerMathJaxTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/CardViewerMathJaxTest.kt
new file mode 100644
index 000000000000..994c5c811495
--- /dev/null
+++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/CardViewerMathJaxTest.kt
@@ -0,0 +1,59 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki
+
+import androidx.test.core.app.ActivityScenario
+import com.ichi2.anki.browser.IdsFile
+import com.ichi2.anki.previewer.CardViewerActivity
+import com.ichi2.anki.previewer.PreviewerFragment
+import com.ichi2.anki.tests.InstrumentedTest
+import com.ichi2.anki.testutil.GrantStoragePermission.storagePermission
+import com.ichi2.anki.testutil.ensureWebViewIsSupported
+import com.ichi2.anki.testutil.grantPermissions
+import com.ichi2.anki.testutil.notificationPermission
+import com.ichi2.anki.testutil.waitUntil
+import org.junit.Rule
+import org.junit.Test
+import java.util.concurrent.atomic.AtomicBoolean
+import kotlin.time.Duration.Companion.seconds
+
+/** Covers the MathJax loading path shared by the previewer and new study screen. */
+class CardViewerMathJaxTest : InstrumentedTest() {
+ @get:Rule
+ val runtimePermissionRule = grantPermissions(storagePermission, notificationPermission)
+
+ @Test
+ fun mathJaxRendersInThePreviewer() {
+ withCardViewer("""\(x^2\)""") { scenario ->
+ val rendered = AtomicBoolean(false)
+ waitUntil(timeout = 30.seconds, message = { "MathJax equation did not render" }) {
+ scenario.onActivity { activity ->
+ val previewer = activity.fragment as PreviewerFragment
+ previewer.binding.webViewLayout.evaluateJavascript(
+ """
+ document.querySelector('#qa mjx-container mjx-math')?.getBoundingClientRect().height > 0
+ && getComputedStyle(document.getElementById('qa')).opacity === '1'
+ """.trimIndent(),
+ ) { rendered.set(it == "true") }
+ }
+ rendered.get()
+ }
+ }
+ }
+
+ private fun withCardViewer(
+ front: String,
+ block: (ActivityScenario) -> Unit,
+ ) {
+ ensureWebViewIsSupported()
+ val note = addNoteUsingBasicNoteType(front)
+ val idsFile = IdsFile(testContext.cacheDir, listOf(note.firstCard(col).id))
+ val intent = PreviewerFragment.getIntent(testContext, idsFile, currentIndex = 0)
+ try {
+ ActivityScenario.launch(intent).use(block)
+ } finally {
+ idsFile.delete()
+ col.backend.removeNotes(noteIds = listOf(note.id), cardIds = emptyList())
+ }
+ }
+}
diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/DeckPickerTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/DeckPickerTest.kt
index 9374f58c9d43..e682ab10f290 100644
--- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/DeckPickerTest.kt
+++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/DeckPickerTest.kt
@@ -4,6 +4,8 @@
package com.ichi2.anki
import android.annotation.SuppressLint
+import android.view.View
+import androidx.core.content.edit
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions
import androidx.test.espresso.assertion.ViewAssertions.matches
@@ -11,6 +13,8 @@ import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.ext.junit.rules.ActivityScenarioRule
import com.ichi2.anki.TestUtils.isTablet
+import com.ichi2.anki.common.preferences.sharedPrefs
+import com.ichi2.anki.settings.Prefs
import com.ichi2.anki.tests.InstrumentedTest
import com.ichi2.anki.testutil.GrantStoragePermission.storagePermission
import com.ichi2.anki.testutil.disableIntroductionSlide
@@ -23,6 +27,7 @@ import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
+import kotlin.test.assertEquals
@SuppressLint("DirectSystemCurrentTimeMillisUsage")
class DeckPickerTest : InstrumentedTest() {
@@ -56,6 +61,30 @@ class DeckPickerTest : InstrumentedTest() {
}
}
+ @Test
+ fun bottomNavigationFollowsDeckPickerLayoutWhenPreferenceIsSaved() {
+ val key = testContext.getString(R.string.dev_bottom_nav_key)
+ val preferences = testContext.sharedPrefs()
+ val wasSaved = preferences.contains(key)
+ val oldValue = preferences.getBoolean(key, false)
+ try {
+ preferences.edit(commit = true) { putBoolean(key, true) }
+ activityRule.scenario.recreate()
+ activityRule.scenario.onActivity { deckPicker ->
+ val hasBottomNavLayout = deckPicker.resources.getBoolean(R.bool.bottom_navigation_available)
+ assertEquals(!deckPicker.fragmented, hasBottomNavLayout)
+ assertEquals(true, Prefs.devBottomNavEnabled)
+ assertEquals(hasBottomNavLayout, deckPicker.bottomNavigationEnabled)
+ assertEquals(hasBottomNavLayout, deckPicker.findViewById(R.id.bottom_navigation) != null)
+ assertEquals(if (hasBottomNavLayout) 1 else 0, deckPicker.deckPickerBinding.decks.itemDecorationCount)
+ }
+ } finally {
+ preferences.edit(commit = true) {
+ if (wasSaved) putBoolean(key, oldValue) else remove(key)
+ }
+ }
+ }
+
@Test
fun checkIfStudyOptionsIsDisplayedOnTablet() {
// Run the test only on emulator.
diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/PagesTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/PagesTest.kt
index 7d44808a7b77..0764d87d68c7 100644
--- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/PagesTest.kt
+++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/PagesTest.kt
@@ -57,7 +57,6 @@ class PagesTest : InstrumentedTest() {
val intents =
listOf Intent, String>>(
Pair(PagesTest::getStatistics, "Statistics"),
- Pair(PagesTest::getCardInfo, "CardInfo"),
Pair(PagesTest::getCongratsPage, "CongratsPage"),
Pair(PagesTest::getDeckOptions, "DeckOptions"),
// the following need a file path
@@ -73,12 +72,6 @@ class PagesTest : InstrumentedTest() {
fun PagesTest.getStatistics(context: Context): Intent = StatisticsDestination.toIntent(context)
-fun PagesTest.getCardInfo(context: Context): Intent =
- addNoteUsingBasicNoteType().firstCard(col).let { card ->
- this.card = card
- CardInfoDestination(card.id, EntryPoint.CURRENT_CARD_STUDY).toIntent(context)
- }
-
fun PagesTest.getCongratsPage(context: Context): Intent =
addNoteUsingBasicNoteType().firstCard(col).let { card ->
this.card = card
diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerFragmentTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerFragmentTest.kt
index 05185163aa9a..5efba55edd10 100644
--- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerFragmentTest.kt
+++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerFragmentTest.kt
@@ -16,6 +16,7 @@ import com.ichi2.anki.libanki.DeckId
import com.ichi2.anki.previewer.CardViewerActivity
import com.ichi2.anki.tests.InstrumentedTest
import com.ichi2.anki.tests.checkWithTimeout
+import com.ichi2.anki.testutil.AvoidDayRolloverRule
import com.ichi2.anki.testutil.GrantStoragePermission.storagePermission
import com.ichi2.anki.testutil.ensureWebViewIsSupported
import com.ichi2.anki.testutil.grantPermissions
@@ -36,6 +37,9 @@ import java.util.concurrent.TimeUnit
@RunWith(AndroidJUnit4::class)
class ReviewerFragmentTest : InstrumentedTest() {
+ @get:Rule(order = 0)
+ val avoidDayRollover = AvoidDayRolloverRule()
+
@get:Rule
val runtimePermissionRule = grantPermissions(storagePermission, notificationPermission)
diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerMediaErrorTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerMediaErrorTest.kt
new file mode 100644
index 000000000000..05245d3c664d
--- /dev/null
+++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerMediaErrorTest.kt
@@ -0,0 +1,195 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki
+
+import android.content.Intent
+import android.webkit.WebResourceError
+import android.webkit.WebResourceRequest
+import android.webkit.WebResourceResponse
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import androidx.test.core.app.ActivityScenario
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.assertion.ViewAssertions.doesNotExist
+import androidx.test.espresso.assertion.ViewAssertions.matches
+import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
+import androidx.test.espresso.matcher.ViewMatchers.withId
+import androidx.test.espresso.matcher.ViewMatchers.withText
+import com.ichi2.anki.pages.AnkiServer.Companion.LOCALHOST
+import com.ichi2.anki.tests.InstrumentedTest
+import com.ichi2.anki.tests.checkWithTimeout
+import com.ichi2.anki.testutil.AvoidDayRolloverRule
+import com.ichi2.anki.testutil.GrantStoragePermission.storagePermission
+import com.ichi2.anki.testutil.ResetMediaErrorHandlerRule
+import com.ichi2.anki.testutil.ensureWebViewIsSupported
+import com.ichi2.anki.testutil.grantPermissions
+import com.ichi2.anki.testutil.notificationPermission
+import com.ichi2.anki.testutil.waitUntil
+import fi.iki.elonen.NanoHTTPD
+import org.hamcrest.Matchers.allOf
+import org.junit.Rule
+import org.junit.Test
+import java.net.InetSocketAddress
+import java.net.Socket
+import java.util.UUID
+import java.util.concurrent.LinkedBlockingQueue
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicBoolean
+import kotlin.test.assertEquals
+import com.google.android.material.R as MaterialR
+
+class ReviewerMediaErrorTest : InstrumentedTest() {
+ @get:Rule(order = 0)
+ val avoidDayRollover = AvoidDayRolloverRule()
+
+ @get:Rule(order = 1)
+ val resetMediaErrorHandler = ResetMediaErrorHandlerRule()
+
+ @get:Rule
+ val runtimePermissionRule = grantPermissions(storagePermission, notificationPermission)
+
+ @Test
+ fun apiHttpFailureDoesNotHideMissingMediaSnackbar() {
+ assertApiFailureDoesNotHideMissingMediaSnackbar()
+ }
+
+ @Test
+ fun apiTransportFailureDoesNotHideMissingMediaSnackbar() {
+ // Reserve a port without listening, so connections are refused and no server can claim it.
+ Socket().use { socket ->
+ socket.bind(InetSocketAddress(LOCALHOST, 0))
+ assertApiFailureDoesNotHideMissingMediaSnackbar(
+ apiBaseUrl = "http://$LOCALHOST:${socket.localPort}",
+ expectedApiErrorCode = WebViewClient.ERROR_CONNECT,
+ )
+ }
+ }
+
+ private fun assertApiFailureDoesNotHideMissingMediaSnackbar(
+ apiBaseUrl: String? = null,
+ expectedApiErrorCode: Int = 500,
+ ) {
+ withErrorServer { mediaBaseUrl ->
+ withReviewer { scenario ->
+ val errors = scenario.recordWebViewErrors()
+ scenario.evaluateJavascript(
+ "fetch('${apiBaseUrl ?: mediaBaseUrl}/jsapi/cardDue', {method: 'POST'}).catch(() => {});",
+ )
+ assertEquals("/jsapi/cardDue" to expectedApiErrorCode, errors.poll(10, TimeUnit.SECONDS))
+ onView(withId(MaterialR.id.snackbar_text)).check(doesNotExist())
+
+ scenario.assertMissingImageIsReported(mediaBaseUrl, errors)
+ }
+ }
+ }
+
+ private fun withReviewer(block: (ActivityScenario) -> Unit) {
+ ensureWebViewIsSupported()
+ val originalDeckId = col.decks.selected()
+ val deckId = col.decks.addNormalDeckWithName("ReviewerMediaErrorTest-${UUID.randomUUID()}").id
+ try {
+ col.decks.select(deckId)
+ addNoteUsingBasicNoteType("Media error integration test").firstCard(col).update { did = deckId }
+ // Launch the legacy reviewer explicitly: the new study screen does not implement this API.
+ ActivityScenario.launch(Intent(testContext, Reviewer::class.java)).use { scenario ->
+ scenario.awaitCardLoaded()
+ block(scenario)
+ }
+ } finally {
+ col.decks.remove(listOf(deckId))
+ col.decks.select(originalDeckId)
+ }
+ }
+
+ private fun withErrorServer(block: (baseUrl: String) -> Unit) {
+ val server =
+ object : NanoHTTPD(LOCALHOST, 0) {
+ override fun serve(session: IHTTPSession): Response {
+ val status =
+ when {
+ session.method == Method.OPTIONS -> Response.Status.OK
+ session.uri == "/jsapi/cardDue" -> Response.Status.INTERNAL_ERROR
+ else -> Response.Status.NOT_FOUND
+ }
+ return newFixedLengthResponse(status, "text/plain", "Deliberate test failure").apply {
+ addHeader("Access-Control-Allow-Origin", "*")
+ }
+ }
+ }
+ try {
+ server.start()
+ block("http://$LOCALHOST:${server.listeningPort}")
+ } finally {
+ server.stop()
+ }
+ }
+
+ private fun ActivityScenario.awaitCardLoaded() {
+ val ready = AtomicBoolean(false)
+ waitUntil(message = { "Reviewer card did not finish loading" }) {
+ onActivity { reviewer ->
+ reviewer.webView?.evaluateJavascript(
+ "document.readyState === 'complete' && document.body.innerText.includes('Media error integration test')",
+ ) { ready.set(it == "true") }
+ }
+ ready.get()
+ }
+ }
+
+ private fun ActivityScenario.recordWebViewErrors(): LinkedBlockingQueue> {
+ val errors = LinkedBlockingQueue>()
+ onActivity { reviewer ->
+ val client = checkNotNull(reviewer.webViewClient)
+ checkNotNull(reviewer.webView).webViewClient =
+ object : WebViewClient() {
+ override fun shouldInterceptRequest(
+ view: WebView,
+ request: WebResourceRequest,
+ ): WebResourceResponse? = client.shouldInterceptRequest(view, request)
+
+ override fun onReceivedError(
+ view: WebView,
+ request: WebResourceRequest,
+ error: WebResourceError,
+ ) {
+ client.onReceivedError(view, request, error)
+ errors.add(request.url.path to error.errorCode)
+ }
+
+ override fun onReceivedHttpError(
+ view: WebView,
+ request: WebResourceRequest,
+ errorResponse: WebResourceResponse,
+ ) {
+ // Exercise the production callback before signaling the instrumentation thread.
+ client.onReceivedHttpError(view, request, errorResponse)
+ errors.add(request.url.path to errorResponse.statusCode)
+ }
+ }
+ }
+ return errors
+ }
+
+ private fun ActivityScenario.evaluateJavascript(script: String) {
+ onActivity { reviewer ->
+ checkNotNull(reviewer.webView).evaluateJavascript(script, null)
+ }
+ }
+
+ private fun ActivityScenario.assertMissingImageIsReported(
+ baseUrl: String,
+ errors: LinkedBlockingQueue>,
+ ) {
+ val missingImage = "missing-${UUID.randomUUID()}.png"
+ evaluateJavascript("const image = new Image(); image.src = '$baseUrl/$missingImage'; document.body.appendChild(image);")
+ assertEquals("/$missingImage" to 404, errors.poll(10, TimeUnit.SECONDS))
+ onView(withId(MaterialR.id.snackbar_text)).checkWithTimeout(
+ matches(
+ allOf(
+ isDisplayed(),
+ withText(testContext.getString(CommonString.card_viewer_could_not_find_image, missingImage)),
+ ),
+ ),
+ )
+ }
+}
diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerTest.kt
index cc45317df731..63546d125caa 100755
--- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerTest.kt
+++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerTest.kt
@@ -6,14 +6,12 @@ package com.ichi2.anki
import androidx.core.content.edit
import androidx.recyclerview.widget.RecyclerView
import androidx.test.espresso.Espresso.onView
-import androidx.test.espresso.NoMatchingViewException
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.test.espresso.matcher.ViewMatchers.hasDescendant
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
-import androidx.test.espresso.matcher.ViewMatchers.withResourceName
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -22,11 +20,13 @@ import com.ichi2.anki.libanki.Consts
import com.ichi2.anki.libanki.DeckId
import com.ichi2.anki.tests.InstrumentedTest
import com.ichi2.anki.tests.checkWithTimeout
+import com.ichi2.anki.testutil.AvoidDayRolloverRule
import com.ichi2.anki.testutil.GrantStoragePermission.storagePermission
import com.ichi2.anki.testutil.closeBackupCollectionDialogIfExists
import com.ichi2.anki.testutil.closeGetStartedScreenIfExists
import com.ichi2.anki.testutil.grantPermissions
import com.ichi2.anki.testutil.notificationPermission
+import com.ichi2.anki.testutil.useResumedActivity
import com.ichi2.anki.testutil.waitUntil
import com.ichi2.anki.utils.ext.cardStateCustomizer
import org.hamcrest.MatcherAssert.assertThat
@@ -36,11 +36,13 @@ import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import timber.log.Timber
-import java.lang.AssertionError
import java.util.UUID
@RunWith(AndroidJUnit4::class)
class ReviewerTest : InstrumentedTest() {
+ @get:Rule(order = 0)
+ val avoidDayRollover = AvoidDayRolloverRule()
+
// Launch IntroductionActivity instead of DeckPicker activity because in CI
// builds, it seems to create IntroductionActivity after the DeckPicker,
// causing the DeckPicker activity to be destroyed. As a consequence, this
@@ -48,7 +50,7 @@ class ReviewerTest : InstrumentedTest() {
// with an already destroyed activity. By launching IntroductionActivity, we
// ensure that IntroductionActivity is launched first and navigate to the
// DeckPicker -> Reviewer activities
- @get:Rule
+ @get:Rule(order = 1)
val activityScenarioRule = ActivityScenarioRule(IntroductionActivity::class.java)
@get:Rule
@@ -106,23 +108,23 @@ class ReviewerTest : InstrumentedTest() {
closeGetStartedScreenIfExists()
closeBackupCollectionDialogIfExists()
- reviewDeckWithName(testDeckName)
-
- var cardFromDb = col.getCard(card.id).toBackendCard()
- assertThat(cardFromDb.easeFactor, equalTo(card.factor))
- assertThat(cardFromDb.interval, equalTo(card.ivl))
- assertThat(cardFromDb.customData, equalTo("""{"c":1}"""))
-
- clickShowAnswerAndAnswerGood()
- // Answering runs on the IO dispatcher, which Espresso does not wait for.
- waitUntil(message = { "The review of card ${card.id} was not saved" }) {
- col.getCard(card.id).reps == card.reps + 1
+ reviewDeckWithName(testDeckName) {
+ var cardFromDb = col.getCard(card.id).toBackendCard()
+ assertThat(cardFromDb.easeFactor, equalTo(card.factor))
+ assertThat(cardFromDb.interval, equalTo(card.ivl))
+ assertThat(cardFromDb.customData, equalTo("""{"c":1}"""))
+
+ clickShowAnswerAndAnswerGood()
+ // Answering runs on the IO dispatcher, which Espresso does not wait for.
+ waitUntil(message = { "The review of card ${card.id} was not saved" }) {
+ col.getCard(card.id).reps == card.reps + 1
+ }
+
+ cardFromDb = col.getCard(card.id).toBackendCard()
+ assertThat(cardFromDb.easeFactor, equalTo(3000))
+ assertThat(cardFromDb.interval, equalTo(123))
+ assertThat(cardFromDb.customData, equalTo("""{"c":2}"""))
}
-
- cardFromDb = col.getCard(card.id).toBackendCard()
- assertThat(cardFromDb.easeFactor, equalTo(3000))
- assertThat(cardFromDb.interval, equalTo(123))
- assertThat(cardFromDb.customData, equalTo("""{"c":2}"""))
}
@Test
@@ -137,11 +139,11 @@ class ReviewerTest : InstrumentedTest() {
closeGetStartedScreenIfExists()
closeBackupCollectionDialogIfExists()
- reviewDeckWithName(testDeckName)
-
- clickShowAnswer()
+ reviewDeckWithName(testDeckName) {
+ clickShowAnswer()
- ensureAnswerButtonsAreDisplayed()
+ ensureAnswerButtonsAreDisplayed()
+ }
}
private fun clickOnDeckWithName(deckName: String) {
@@ -160,37 +162,33 @@ class ReviewerTest : InstrumentedTest() {
.perform(click())
}
- private fun reviewDeckWithName(deckName: String) {
- clickOnDeckWithName(deckName)
- // Adding cards directly to the database while in the Deck Picker screen
- // will not update the page with correct card counts. Hence, clicking
- // on the deck will bring us to the study options page where we need to
- // click on the Study button. If we have added cards to the database
- // before the Deck Picker screen has fully loaded, then we skip clicking
- // the Study button
- clickOnStudyButtonIfExists()
+ private fun reviewDeckWithName(
+ deckName: String,
+ block: () -> Unit,
+ ) {
+ // The activity rule only owns IntroductionActivity. Close both activities before restoring
+ // rollover, so neither can refresh against a collection that is being torn down.
+ useResumedActivity {
+ clickOnDeckWithName(deckName)
+ // Adding cards directly to the database while in the Deck Picker screen
+ // will not update the page with correct card counts. Hence, clicking
+ // on the deck will bring us to the study options page where we need to
+ // click on the Study button. If we have added cards to the database
+ // before the Deck Picker screen has fully loaded, then we skip clicking
+ // the Study button
+ clickOnStudyButtonIfExists()
+ useResumedActivity { block() }
+ }
}
private fun clickShowAnswerAndAnswerGood() {
clickShowAnswer()
ensureAnswerButtonsAreDisplayed()
- try {
- // ...on the command line it has resource name "good_button"...
- onView(withResourceName("good_button")).perform(click())
- } catch (e: NoMatchingViewException) {
- // ...but in Android Studio it has resource name "flashcard_layout_ease3" !?
- onView(withResourceName("flashcard_layout_ease3")).perform(click())
- }
+ onView(withId(R.id.flashcard_layout_ease3)).perform(click())
}
private fun clickShowAnswer() {
- try {
- // ... on the command line, it has resource name "show_answer"...
- onView(withResourceName("show_answer")).perform(click())
- } catch (e: NoMatchingViewException) {
- // ... but in Android Studio it has resource name "flashcard_layout_flip" !?
- onView(withResourceName("flashcard_layout_flip")).perform(click())
- }
+ onView(withId(R.id.flashcard_layout_flip)).perform(click())
}
private fun ensureAnswerButtonsAreDisplayed() {
@@ -198,19 +196,10 @@ class ReviewerTest : InstrumentedTest() {
// the messages to be passed in and out of the WebView when evaluating
// the custom JS scheduler code. The ease buttons are hidden until the
// custom scheduler has finished running
- try {
- // ...on the command line it has resource name "good_button"...
- onView(withResourceName("good_button")).checkWithTimeout(
- matches(isDisplayed()),
- 100,
- )
- } catch (e: AssertionError) {
- // ...but in Android Studio it has resource name "flashcard_layout_ease3" !?
- onView(withResourceName("flashcard_layout_ease3")).checkWithTimeout(
- matches(isDisplayed()),
- 100,
- )
- }
+ onView(withId(R.id.flashcard_layout_ease3)).checkWithTimeout(
+ matches(isDisplayed()),
+ 100,
+ )
}
private fun disableNewReviewer() {
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 fc1b5a23b0a9..2c2cb19c783b 100644
--- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/dialogs/NoteTypeFieldEditorContextMenuTest.kt
+++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/dialogs/NoteTypeFieldEditorContextMenuTest.kt
@@ -1,5 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
-// SPDX-FileCopyrightText: Copyright (c) 2022 lukstbit
package com.ichi2.anki.dialogs
diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/pages/CardInfoTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/pages/CardInfoTest.kt
new file mode 100644
index 000000000000..662268738884
--- /dev/null
+++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/pages/CardInfoTest.kt
@@ -0,0 +1,49 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.pages
+
+import androidx.test.core.app.ActivityScenario
+import anki.scheduler.CardAnswer.Rating
+import com.ichi2.anki.CollectionManager.TR
+import com.ichi2.anki.SingleFragmentActivity
+import com.ichi2.anki.common.destinations.CardInfoDestination
+import com.ichi2.anki.common.destinations.CardInfoDestination.EntryPoint
+import com.ichi2.anki.tests.InstrumentedTest
+import com.ichi2.anki.testutil.waitForPageCondition
+import org.json.JSONObject
+import org.junit.Test
+
+class CardInfoTest : InstrumentedTest() {
+ @Test
+ fun rendersReviewedFsrsCard() {
+ val fsrsWasEnabled = col.config.get("fsrs") ?: false
+ val note = addNoteUsingBasicNoteType()
+ try {
+ col.config.set("fsrs", true)
+ val card = note.firstCard(col)
+ card.startTimer()
+ col.sched.answerCard(card, Rating.GOOD)
+
+ val intent = CardInfoDestination(card.id, EntryPoint.CURRENT_CARD_STUDY).toIntent(testContext)
+ ActivityScenario.launch(intent).use { scenario ->
+ lateinit var page: CardInfoFragment
+ scenario.onActivity { page = it.fragment as CardInfoFragment }
+ page.waitForPageCondition(
+ """
+ (() => {
+ const stats = document.querySelector('.stats-table');
+ return stats?.innerText.includes('${card.id}')
+ && stats.innerText.includes(${JSONObject.quote(TR.cardStatsFsrsStability())})
+ && stats.innerText.includes(${JSONObject.quote(TR.cardStatsFsrsDifficulty())})
+ && document.querySelector('path.forgetting-curve-line')?.getAttribute('d')?.length > 0;
+ })();
+ """.trimIndent(),
+ "Card Info did not render the card's FSRS statistics and forgetting curve",
+ )
+ }
+ } finally {
+ col.backend.removeNotes(noteIds = listOf(note.id), cardIds = emptyList())
+ col.config.set("fsrs", fsrsWasEnabled)
+ }
+ }
+}
diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/pages/CsvImporterTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/pages/CsvImporterTest.kt
new file mode 100644
index 000000000000..5fe5caa7241c
--- /dev/null
+++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/pages/CsvImporterTest.kt
@@ -0,0 +1,43 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.pages
+
+import androidx.lifecycle.Lifecycle
+import androidx.test.core.app.ActivityScenario
+import androidx.test.platform.app.InstrumentationRegistry
+import com.ichi2.anki.SingleFragmentActivity
+import com.ichi2.anki.tests.InstrumentedTest
+import com.ichi2.anki.testutil.waitUntil
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withTimeout
+import org.junit.Test
+import kotlin.time.Duration.Companion.seconds
+
+class CsvImporterTest : InstrumentedTest() {
+ @Test
+ fun dismissingAMissingFileErrorClosesTheImporter() {
+ ActivityScenario.launch(CsvImporter.getIntent(testContext, "/missing-21926.csv")).use { scenario ->
+ lateinit var page: CsvImporter
+ scenario.onActivity { page = it.fragment as CsvImporter }
+ waitUntil(timeout = 30.seconds, message = { "CSV error page was not displayed" }) {
+ page.evaluateJavascript("document.querySelector('.error-box button') !== null") == "true"
+ }
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ page.webViewLayout.evaluateJavascript("document.querySelector('.error-box button').click()")
+ }
+ waitUntil(timeout = 10.seconds, message = { "CSV error acknowledgement did not close the importer" }) {
+ scenario.state == Lifecycle.State.DESTROYED
+ }
+ }
+ }
+
+ /** Evaluates [script] in the WebView, returning the JSON-encoded result. */
+ private fun CsvImporter.evaluateJavascript(script: String): String {
+ val result = CompletableDeferred()
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ webViewLayout.evaluateJavascript(script) { result.complete(it) }
+ }
+ return runBlocking { withTimeout(10.seconds) { result.await() } }
+ }
+}
diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/pages/DeckOptionsTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/pages/DeckOptionsTest.kt
index 5ebff4bc3edd..18a627e123b5 100644
--- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/pages/DeckOptionsTest.kt
+++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/pages/DeckOptionsTest.kt
@@ -13,6 +13,7 @@ import com.ichi2.anki.SingleFragmentActivity
import com.ichi2.anki.common.preferences.sharedPrefs
import com.ichi2.anki.libanki.Consts
import com.ichi2.anki.tests.InstrumentedTest
+import com.ichi2.anki.testutil.waitForPageCondition
import com.ichi2.anki.testutil.waitUntil
import com.ichi2.testutils.ext.defaultDeckNewCardsPerDay
import kotlinx.coroutines.CompletableDeferred
@@ -24,7 +25,6 @@ import org.json.JSONObject
import org.junit.After
import org.junit.Before
import org.junit.Test
-import java.util.concurrent.atomic.AtomicBoolean
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
@@ -70,6 +70,51 @@ class DeckOptionsTest : InstrumentedTest() {
}
}
+ /** Issue 21923: empty/default FSRS parameters caused a panic and poisoned later backend calls. */
+ @Test
+ fun helpMeDecideSimulatesWithDefaultParameters() {
+ val note = addNoteUsingBasicNoteType()
+ try {
+ val config = col.decks.configDictForDeckId(Consts.DEFAULT_DECK_ID).jsonObject
+ for (key in listOf("fsrsWeights", "fsrsParams5", "fsrsParams6")) {
+ assertEquals(0, config.getJSONArray(key).length(), "$key must use the defaults")
+ }
+ withDeckOptions {
+ val helpMeDecide = JSONObject.quote(TR.deckConfigFsrsDesiredRetentionHelpMeDecideExperimental())
+ evaluateJavascript(
+ """
+ Array.from(document.querySelectorAll('button'))
+ .find(button => button.textContent.trim() === $helpMeDecide).click();
+ """.trimIndent(),
+ )
+ waitForPageCondition("document.querySelector('.modal.show') !== null", "simulator did not open")
+ evaluateJavascript(
+ """
+ (() => {
+ const days = document.querySelector('.modal.show input[type="number"]');
+ days.value = '30';
+ days.dispatchEvent(new Event('input', { bubbles: true }));
+ days.dispatchEvent(new Event('change', { bubbles: true }));
+ })();
+ """.trimIndent(),
+ )
+ evaluateJavascript(
+ """
+ Array.from(document.querySelectorAll('.modal.show button'))
+ .find(button => button.textContent.trim() === ${JSONObject.quote(TR.deckConfigSimulate())}).click();
+ """.trimIndent(),
+ )
+ waitForPageCondition(
+ "document.querySelector('.modal.show svg .lines path')?.getAttribute('d')?.length > 0",
+ "simulation did not produce a graph",
+ )
+ }
+ assertTrue(col.sched.counts().count() > 0, "backend remains usable after simulation")
+ } finally {
+ col.backend.removeNotes(noteIds = listOf(note.id), cardIds = emptyList())
+ }
+ }
+
@Test
fun optimizingAllPresetsSavesAndReloadsOptions() {
withDeckOptions {
@@ -153,23 +198,6 @@ class DeckOptionsTest : InstrumentedTest() {
assertFalse(requireActivity().isFinishing)
}
- /** Poll across page reloads, which can discard an individual JavaScript evaluation callback. */
- private fun DeckOptions.waitForPageCondition(
- condition: String,
- message: String,
- ) {
- val satisfied = AtomicBoolean(false)
- waitUntil(timeout = 30.seconds, message = { message }) {
- // Reloading can discard an evaluation callback. Retry without waiting for each one.
- InstrumentationRegistry.getInstrumentation().runOnMainSync {
- webViewLayout.evaluateJavascript(condition) {
- if (it == "true") satisfied.set(true)
- }
- }
- satisfied.get()
- }
- }
-
private fun DeckOptions.saveAndOptimize(newPerDay: Int) {
val script =
"""
diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/startup/BackendSetupIntegrationTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/startup/BackendSetupIntegrationTest.kt
new file mode 100644
index 000000000000..0bf6e08b7ec4
--- /dev/null
+++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/startup/BackendSetupIntegrationTest.kt
@@ -0,0 +1,88 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.startup
+
+import android.system.Os
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import com.ichi2.anki.AnkiDroidApp
+import com.ichi2.testutils.common.assertThrows
+import net.ankiweb.rsdroid.Backend
+import net.ankiweb.rsdroid.exceptions.BackendIoException
+import org.hamcrest.MatcherAssert.assertThat
+import org.hamcrest.Matchers.containsString
+import org.hamcrest.Matchers.equalTo
+import org.hamcrest.Matchers.notNullValue
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.io.DataInputStream
+import java.io.File
+import java.nio.file.Files
+import java.util.zip.ZipFile
+
+@RunWith(AndroidJUnit4::class)
+class BackendSetupIntegrationTest {
+ private val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext
+
+ @Test
+ fun collectionExportRecoversFromUnwritableTmpDir() {
+ // TemporaryFolder uses reflection that is incompatible with Android's desugared NIO APIs.
+ val directory = Files.createTempDirectory(context.cacheDir.toPath(), "backend-setup-").toFile()
+ try {
+ withTmpDir("/data/local/tmp") {
+ // Issue 19545: the export destination is writable, but Rust creates its
+ // dummy collection in TMPDIR, which the app cannot write to.
+ val failure = assertThrows { exportCollection(directory) }
+ assertThat(failure.message, containsString("Failed to create file in '/data/local/tmp'"))
+ assertThat(failure.message, containsString("Permission denied (os error 13)"))
+
+ AnkiDroidApp.makeBackendUsable(context)
+
+ exportCollection(directory).assertValidCollectionPackage()
+ assertThat(Os.getenv("TMPDIR"), equalTo(context.cacheDir.path))
+ }
+ } finally {
+ directory.deleteRecursively()
+ }
+ }
+
+ private fun exportCollection(directory: File): File =
+ Backend().use { backend ->
+ val collection = File(directory, "collection.anki2")
+ val output = File(directory, "collection.colpkg")
+ backend.openCollection(collection.path)
+ backend.exportCollectionPackage(output.path, includeMedia = false, legacy = false)
+ output
+ }
+
+ private fun File.assertValidCollectionPackage() {
+ ZipFile(this).use { archive ->
+ assertThat(archive.getEntry("collection.anki21b"), notNullValue())
+ // This dummy SQLite collection is the temporary file that previously failed.
+ val dummyCollection = archive.getEntry("collection.anki2")
+ assertThat(dummyCollection, notNullValue())
+ DataInputStream(archive.getInputStream(dummyCollection)).use { input ->
+ val header = ByteArray(16)
+ input.readFully(header)
+ assertThat(header.toString(Charsets.US_ASCII), equalTo("SQLite format 3\u0000"))
+ }
+ }
+ }
+
+ private fun withTmpDir(
+ @Suppress("SameParameterValue") path: String,
+ block: () -> Unit,
+ ) {
+ val originalTmpDir = Os.getenv("TMPDIR")
+ try {
+ Os.setenv("TMPDIR", path, true)
+ block()
+ } finally {
+ if (originalTmpDir == null) {
+ Os.unsetenv("TMPDIR")
+ } else {
+ Os.setenv("TMPDIR", originalTmpDir, true)
+ }
+ }
+ }
+}
diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/ContentProviderTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/ContentProviderTest.kt
index ec4627c4b966..9705445506b0 100644
--- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/ContentProviderTest.kt
+++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/ContentProviderTest.kt
@@ -853,7 +853,7 @@ class ContentProviderTest : InstrumentedTest() {
true,
)
- assertSingleCardProjection(card, null, FlashCardsContract.Card.DEFAULT_PROJECTION.toList()) {
+ assertSingleCardProjection(card, null, FlashCardsContract.Card.DEFAULT_COLUMNS.toList()) {
assertEquals(-1, it.getColumnIndex(FlashCardsContract.Card.ORIGINAL_POSITION))
assertEquals(-1, it.getColumnIndex(FlashCardsContract.Card.RAW_CUSTOM_DATA))
assertEquals(-1, it.getColumnIndex(FlashCardsContract.Card.FSRS_STABILITY))
@@ -1038,7 +1038,7 @@ class ContentProviderTest : InstrumentedTest() {
val cursor = contentResolver.cursorFor(cardUri)
cursor.use {
- assertEquals(FlashCardsContract.Card.DEFAULT_PROJECTION.toList(), it.columnNames.toList())
+ assertEquals(FlashCardsContract.Card.DEFAULT_COLUMNS.toList(), it.columnNames.toList())
assertTrue("default projection cursor should contain a row", it.moveToFirst())
assertEquals(-1, it.getColumnIndex(FlashCardsContract.Card.RAW_QUEUE))
@@ -1265,9 +1265,9 @@ class ContentProviderTest : InstrumentedTest() {
)
while (it.moveToNext()) {
// Check that it's possible to leave out columns from the projection
- for (i in FlashCardsContract.Note.DEFAULT_PROJECTION.indices) {
+ for (i in FlashCardsContract.Note.DEFAULT_COLUMNS.indices) {
val projection =
- removeFromProjection(FlashCardsContract.Note.DEFAULT_PROJECTION, i)
+ removeFromProjection(FlashCardsContract.Note.DEFAULT_COLUMNS, i)
val noteId =
it.getString(it.getColumnIndex(FlashCardsContract.Note._ID))
val noteUri = Uri.withAppendedPath(FlashCardsContract.Note.CONTENT_URI, noteId)
@@ -1311,8 +1311,8 @@ class ContentProviderTest : InstrumentedTest() {
fun testQueryNotesProjection() {
val cr = contentResolver
// Query all available notes
- for (i in FlashCardsContract.Note.DEFAULT_PROJECTION.indices) {
- val projection = removeFromProjection(FlashCardsContract.Note.DEFAULT_PROJECTION, i)
+ for (i in FlashCardsContract.Note.DEFAULT_COLUMNS.indices) {
+ val projection = removeFromProjection(FlashCardsContract.Note.DEFAULT_COLUMNS, i)
cr
.query(
FlashCardsContract.Note.CONTENT_URI,
@@ -1375,7 +1375,7 @@ class ContentProviderTest : InstrumentedTest() {
cv.put(FlashCardsContract.Note.FLDS, Utils.joinFields(dummyFields2))
cr.update(uri, cv, null, null)
cr
- .query(uri, FlashCardsContract.Note.DEFAULT_PROJECTION, null, null, null)
+ .query(uri, FlashCardsContract.Note.DEFAULT_COLUMNS, null, null, null)
.use { noteCursor ->
assertNotNull(
"Check that there is a valid cursor for detail data after update",
@@ -1755,7 +1755,7 @@ class ContentProviderTest : InstrumentedTest() {
contentResolver
.query(
FlashCardsContract.Deck.CONTENT_ALL_URI,
- FlashCardsContract.Deck.DEFAULT_PROJECTION,
+ FlashCardsContract.Deck.DEFAULT_COLUMNS,
null,
null,
null,
diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/AvoidDayRolloverRule.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/AvoidDayRolloverRule.kt
new file mode 100644
index 000000000000..4c769ec44836
--- /dev/null
+++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/AvoidDayRolloverRule.kt
@@ -0,0 +1,60 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.testutil
+
+import anki.config.copy
+import com.ichi2.anki.CollectionManager.withCol
+import com.ichi2.anki.DayRolloverHandler
+import com.ichi2.anki.common.android.appContext
+import com.ichi2.anki.common.time.TimeManager
+import com.ichi2.widget.DayRolloverAlarm
+import kotlinx.coroutines.runBlocking
+import org.junit.rules.TestRule
+import org.junit.runner.Description
+import org.junit.runners.model.Statement
+import java.util.Calendar
+
+/**
+ * Prevents day rollover from resetting the reviewer.
+ *
+ * Fixed by temporarily setting the rollover time to currentTime + 12 hours.
+ *
+ * ```kotlin
+ * // This must have a lower `order` than `ActivityScenarioRule`
+ * @get:Rule(order = 0)
+ * val avoidDayRollover = AvoidDayRolloverRule()
+ *
+ * @get:Rule(order = 1)
+ * val activityScenarioRule = ActivityScenarioRule(IntroductionActivity::class.java)
+ * ```
+ */
+class AvoidDayRolloverRule : TestRule {
+ override fun apply(
+ base: Statement,
+ description: Description,
+ ): Statement =
+ object : Statement() {
+ override fun evaluate() {
+ val originalRollover = runBlocking { withCol { getPreferences().scheduling.rollover } }
+ try {
+ val currentHour = TimeManager.time.calendar()[Calendar.HOUR_OF_DAY]
+ setRollover((currentHour + 12) % 24)
+ base.evaluate()
+ } finally {
+ // The collection is shared with other tests, including after a failure.
+ setRollover(originalRollover)
+ }
+ }
+ }
+
+ private fun setRollover(hour: Int) =
+ runBlocking {
+ withCol {
+ setPreferences(getPreferences().copy { scheduling = scheduling.copy { rollover = hour } })
+ }
+ // Consume the cutoff change before an activity can react to the next time event.
+ DayRolloverHandler.handleTimeChange()
+ // Finish replacing the alarm before entering the test or restoring normal operation.
+ DayRolloverAlarm.scheduleNextInternal(appContext)
+ }
+}
diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/PageFragment.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/PageFragment.kt
new file mode 100644
index 000000000000..1b0cd0e845ac
--- /dev/null
+++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/PageFragment.kt
@@ -0,0 +1,25 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.testutil
+
+import androidx.test.platform.app.InstrumentationRegistry
+import com.ichi2.anki.pages.PageFragment
+import java.util.concurrent.atomic.AtomicBoolean
+import kotlin.time.Duration.Companion.seconds
+
+/** Poll across page reloads, which can discard an individual JavaScript evaluation callback. */
+fun PageFragment.waitForPageCondition(
+ condition: String,
+ message: String,
+) {
+ val satisfied = AtomicBoolean(false)
+ waitUntil(timeout = 30.seconds, message = { message }) {
+ // Reloading can discard an evaluation callback. Retry without waiting for each one.
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ webViewLayout.evaluateJavascript(condition) {
+ if (it == "true") satisfied.set(true)
+ }
+ }
+ satisfied.get()
+ }
+}
diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/ResetMediaErrorHandlerRule.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/ResetMediaErrorHandlerRule.kt
new file mode 100644
index 000000000000..9d36ee205f3c
--- /dev/null
+++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/ResetMediaErrorHandlerRule.kt
@@ -0,0 +1,41 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.testutil
+
+import androidx.test.platform.app.InstrumentationRegistry
+import com.ichi2.anki.AbstractFlashcardViewer
+import com.ichi2.anki.cardviewer.MediaErrorHandler
+import org.junit.rules.ExternalResource
+
+/**
+ * Resets the state of the [MediaErrorHandler] singleton.
+ *
+ * ```kotlin
+ * // Close the activity before restoring the handler.
+ * @get:Rule(order = 0)
+ * val resetMediaErrorHandler = ResetMediaErrorHandlerRule()
+ *
+ * @get:Rule(order = 1)
+ * val activityScenarioRule = ActivityScenarioRule(Reviewer::class.java)
+ * ```
+ *
+ * Manually launched activities must be closed within the test.
+ *
+ * @see AbstractFlashcardViewer.mediaErrorHandler
+ */
+class ResetMediaErrorHandlerRule : ExternalResource() {
+ private lateinit var originalHandler: MediaErrorHandler
+
+ override fun before() {
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ originalHandler = AbstractFlashcardViewer.mediaErrorHandler
+ AbstractFlashcardViewer.mediaErrorHandler = MediaErrorHandler()
+ }
+ }
+
+ override fun after() {
+ InstrumentationRegistry.getInstrumentation().runOnMainSync {
+ AbstractFlashcardViewer.mediaErrorHandler = originalHandler
+ }
+ }
+}
diff --git a/AnkiDroid/src/main/assets/scripts/js-api.js b/AnkiDroid/src/main/assets/scripts/js-api.js
index 91a8e6d0f875..226dc5aa8237 100644
--- a/AnkiDroid/src/main/assets/scripts/js-api.js
+++ b/AnkiDroid/src/main/assets/scripts/js-api.js
@@ -35,6 +35,9 @@ const jsApiList = {
ankiGetCardLapses: "cardLapses",
ankiGetCardDue: "cardDue",
ankiIsInFullscreen: "isInFullscreen",
+ ankiSetFullscreenButtonsAndMenu: "setFullscreenButtonsAndMenu",
+ ankiSetFullscreenButtonsOnly: "setFullscreenButtonsOnly",
+ ankiSetFullscreenAllGone: "setFullscreenAllGone",
ankiIsTopbarShown: "isTopbarShown",
ankiIsInNightMode: "isInNightMode",
ankiIsDisplayingAnswer: "isDisplayingAnswer",
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt
index 2673355c8004..fe5297aa892d 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt
@@ -23,7 +23,6 @@ import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
-import android.text.InputType
import android.view.GestureDetector
import android.view.GestureDetector.SimpleOnGestureListener
import android.view.KeyEvent
@@ -242,9 +241,6 @@ abstract class AbstractFlashcardViewer :
private var touchLayer: FrameLayout? = null
protected var answerField: FixedEditText? = null
- /** Layout-provided default `inputType` for [answerField], captured once and used to restore
- * state when moving off a card that used `{{nosuggest:type:}}`. See issue #10352. */
- private var defaultAnswerFieldInputType: Int? = null
protected var flipCardLayout: FrameLayout? = null
private var easeButtonsLayout: LinearLayout? = null
@@ -742,14 +738,13 @@ abstract class AbstractFlashcardViewer :
/**
* Apply or restore the `{{nosuggest:type:}}` flag set on [answerField].
+ *
+ * @see FixedEditText.noSuggest
*/
- private fun applyTypeAnswerSuggestionFlags(noSuggest: Boolean) {
+ private fun updateTypeAnswerNoSuggest(noSuggest: Boolean) {
val field = answerField ?: return
- // see ReviewerFragment for why `TYPE_NULL` was selected
- val targetInputType =
- if (noSuggest) InputType.TYPE_NULL else (defaultAnswerFieldInputType ?: field.inputType)
- if (field.inputType != targetInputType) {
- field.inputType = targetInputType
+ if (field.noSuggest != noSuggest) {
+ field.noSuggest = noSuggest
getSystemService()?.restartInput(field)
}
}
@@ -996,10 +991,7 @@ abstract class AbstractFlashcardViewer :
val params = flipCardLayout!!.layoutParams
params.height = initialFlipCardHeight * 2
}
- answerField =
- findViewById(R.id.answer_field).also { answerField ->
- defaultAnswerFieldInputType = answerField.inputType
- }
+ answerField = findViewById(R.id.answer_field)
initControls()
// Position answer buttons
@@ -1363,7 +1355,7 @@ abstract class AbstractFlashcardViewer :
// Show text entry based on if the user wants to write the answer
answerField?.visibility = View.VISIBLE
answerField?.applyLanguageHint(typeAnswer?.languageHint)
- applyTypeAnswerSuggestionFlags(typeAnswer?.noSuggest == true)
+ updateTypeAnswerNoSuggest(typeAnswer?.noSuggest == true)
} else {
answerField?.visibility = View.GONE
}
@@ -2003,7 +1995,7 @@ abstract class AbstractFlashcardViewer :
// Show text entry based on if the user wants to write the answer
answerField?.visibility = View.VISIBLE
answerField?.applyLanguageHint(typeAnswer?.languageHint)
- applyTypeAnswerSuggestionFlags(typeAnswer?.noSuggest == true)
+ updateTypeAnswerNoSuggest(typeAnswer?.noSuggest == true)
} else {
answerField?.visibility = View.GONE
}
@@ -2441,6 +2433,8 @@ abstract class AbstractFlashcardViewer :
error: WebResourceError,
) {
super.onReceivedError(view, request, error)
+ Timber.w("WebView error received")
+ Timber.d("WebView error %d (%s): %s %s", error.errorCode, error.description, request.method, request.url)
mediaErrorHandler.processFailure(request) { filename: String ->
displayCouldNotFindMediaSnackbar(
filename,
@@ -2454,6 +2448,14 @@ abstract class AbstractFlashcardViewer :
errorResponse: WebResourceResponse,
) {
super.onReceivedHttpError(view, request, errorResponse)
+ Timber.w("WebView HTTP error received")
+ Timber.d(
+ "WebView HTTP error %d (%s): %s %s",
+ errorResponse.statusCode,
+ errorResponse.reasonPhrase,
+ request.method,
+ request.url,
+ )
mediaErrorHandler.processFailure(request) { filename: String ->
displayCouldNotFindMediaSnackbar(
filename,
@@ -2793,7 +2795,8 @@ abstract class AbstractFlashcardViewer :
const val DEFAULT_DOUBLE_TAP_TIME_INTERVAL = 200
/** Handle providing help for "Image Not Found" */
- internal val mediaErrorHandler = MediaErrorHandler()
+ @set:VisibleForTesting(otherwise = VisibleForTesting.NONE)
+ internal var mediaErrorHandler = MediaErrorHandler()
// Android design spec for the size of the status bar.
private const val NO_GESTURE_BORDER_DIP = 24
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AcraCrashReporter.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AcraCrashReporter.kt
index f09e090f34b6..69d91f379104 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/AcraCrashReporter.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/AcraCrashReporter.kt
@@ -1,5 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
-// SPDX-FileCopyrightText: Copyright (c) 2022 lukstbit
package com.ichi2.anki
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ActionProviderCompat.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ActionProviderCompat.kt
index 21119d597141..534afce8cb5d 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/ActionProviderCompat.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/ActionProviderCompat.kt
@@ -1,18 +1,5 @@
-/*
- * Copyright (c) 2022 lukstbit <52494258+lukstbit@users.noreply.github.com>
- *
- * This program is free software; you can redistribute it and/or modify it under
- * the terms of the GNU General Public License as published by the Free Software
- * Foundation; either version 3 of the License, or (at your option) any later
- * version.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT ANY
- * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
- * PARTICULAR PURPOSE. See the GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program. If not, see .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
+
package com.ichi2.anki
import android.content.Context
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt
index be4d699e2429..5a6022841bc6 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt
@@ -63,6 +63,7 @@ import com.ichi2.anki.servicelayer.ThrowableFilterService
import com.ichi2.anki.services.NotificationService
import com.ichi2.anki.settings.Prefs
import com.ichi2.anki.settings.PrefsRepository
+import com.ichi2.anki.startup.configureBackendTemporaryDirectory
import com.ichi2.anki.startup.ensureCollectionPathSet
import com.ichi2.anki.startup.getDefaultAnkiDroidDirectory
import com.ichi2.anki.ui.dialogs.ActivityAgnosticDialogs
@@ -669,7 +670,7 @@ open class AnkiDroidApp :
if (Build.FINGERPRINT == "robolectric") return
// Prevent sqlite throwing error 6410 due to the lack of /tmp on Android
- Os.setenv("TMPDIR", context.cacheDir.path, false)
+ configureBackendTemporaryDirectory(context)
// Load backend library
System.loadLibrary("rsdroid")
}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidJsAPI.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidJsAPI.kt
index fd5f9224c038..01daaee0b89d 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidJsAPI.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidJsAPI.kt
@@ -31,6 +31,7 @@ import com.ichi2.anki.libanki.Decks
import com.ichi2.anki.libanki.Note
import com.ichi2.anki.libanki.SortOrder
import com.ichi2.anki.model.CardsOrNotes
+import com.ichi2.anki.reviewer.FullScreenMode
import com.ichi2.anki.security.AppPermissions
import com.ichi2.anki.security.DangerousJsApiPermission
import com.ichi2.anki.security.DangerousJsPermissionDeniedException
@@ -379,6 +380,9 @@ open class AnkiDroidJsAPI(
}
"isInFullscreen" -> convertToByteArray(apiContract, activity.isFullscreen)
+ "setFullscreenButtonsAndMenu" -> setFullscreenMode(FullScreenMode.BUTTONS_AND_MENU, apiContract)
+ "setFullscreenButtonsOnly" -> setFullscreenMode(FullScreenMode.BUTTONS_ONLY, apiContract)
+ "setFullscreenAllGone" -> setFullscreenMode(FullScreenMode.FULLSCREEN_ALL_GONE, apiContract)
"isTopbarShown" -> convertToByteArray(apiContract, activity.prefShowTopbar)
"isInNightMode" -> convertToByteArray(apiContract, activity.isInNightMode)
"enableHorizontalScrollbar" -> {
@@ -544,6 +548,14 @@ open class AnkiDroidJsAPI(
}
}
+ private fun setFullscreenMode(
+ mode: FullScreenMode,
+ apiContract: ApiContract,
+ ): ByteArray {
+ val result = if (activity is Reviewer) activity.setFullscreenMode(mode) else false
+ return convertToByteArray(apiContract, result)
+ }
+
private fun processAction(
action: () -> Boolean,
apiContract: ApiContract,
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/BackendBackups.kt b/AnkiDroid/src/main/java/com/ichi2/anki/BackendBackups.kt
index 21f50993cfd0..800b64a6351c 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/BackendBackups.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/BackendBackups.kt
@@ -16,16 +16,29 @@
package com.ichi2.anki
+import androidx.annotation.CheckResult
import com.ichi2.anki.CollectionManager.withCol
+import com.ichi2.anki.libanki.Collection
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
+import net.ankiweb.rsdroid.BackendException
-suspend fun performBackupInBackground(force: Boolean = false) {
+/**
+ * Attempts a backup and awaits its completion.
+ *
+ * @param force Bypass the minimum backup interval. Unchanged collections are still skipped.
+ * @return `true` if a backup completed successfully; `false` if the collection has not changed
+ * since the last backup, or if [force] is `false` and the minimum backup interval has not elapsed.
+ * @throws BackendException if backup creation or completion fails. See [Collection.createBackup]
+ * for full exception details.
+ */
+@CheckResult
+suspend fun performBackupInBackground(force: Boolean = false): Boolean {
// Wait a second to allow the deck list to finish loading first, or it
// will hang until the first stage of the backup completes.
delay(1000)
- createBackup(force = force)
+ return createBackup(force = force)
}
fun Activity.importColpkg(colpkgPath: String) where Activity : AnkiActivity, Activity : ImportColpkgListener {
@@ -44,17 +57,19 @@ fun Activity.importColpkg(colpkgPath: String) where Activity : AnkiAc
}
}
-private suspend fun createBackup(force: Boolean) {
- withCol {
- // this two-step approach releases the backend lock after the initial copy
- createBackup(
- BackupManager.getBackupDirectoryFromCollection(colDb),
- force,
- waitForCompletion = false,
- )
- }
+private suspend fun createBackup(force: Boolean): Boolean {
+ val created =
+ withCol {
+ // this two-step approach releases the backend lock after the initial copy
+ createBackup(
+ BackupManager.getBackupDirectoryFromCollection(colDb),
+ force,
+ waitForCompletion = false,
+ )
+ }
// move this outside 'withCol' to avoid blocking
withContext(Dispatchers.IO) {
CollectionManager.getBackend().awaitBackupCompletion()
}
+ return created
}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/BackendExporting.kt b/AnkiDroid/src/main/java/com/ichi2/anki/BackendExporting.kt
index b2ab08dd29ac..a3b88968daed 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/BackendExporting.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/BackendExporting.kt
@@ -1,5 +1,5 @@
-// SPDX-FileCopyrightText: 2023 lukstbit <52494258+lukstbit@users.noreply.github.com>
// SPDX-License-Identifier: GPL-3.0-or-later
+
package com.ichi2.anki
import anki.import_export.ExportLimit
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt
index bffb0b738e02..5b216f5b8424 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt
@@ -57,6 +57,7 @@ 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.annotation.KotlinCleanup
+import com.ichi2.anki.common.utils.ext.onPermanentDismissal
import com.ichi2.anki.databinding.ActivityCardBrowserBinding
import com.ichi2.anki.dialogs.DiscardChangesDialog
import com.ichi2.anki.dialogs.SavedBrowserSearchesDialogFragment
@@ -127,8 +128,8 @@ open class CardBrowser :
val menuHost: MenuHost?
get() =
- if (useSearchView) {
- if (this::cardBrowserFragment.isInitialized) cardBrowserFragment else null
+ if (this::cardBrowserFragment.isInitialized && useSearchView) {
+ cardBrowserFragment
} else {
null
}
@@ -231,6 +232,7 @@ open class CardBrowser :
// must be called once we have an accessible collection
viewModel = createViewModel(launchOptions, fragmented)
+ onPermanentDismissal { viewModel.deleteSavedSelectionFile() }
cardBrowserFragment = supportFragmentManager.findFragmentById(R.id.card_browser_frame) as? CardBrowserFragment
?: CardBrowserFragment().also { fragment ->
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt
index 080cc3be479c..68f0db4a3121 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt
@@ -188,6 +188,7 @@ import com.ichi2.anki.snackbar.SnackbarBuilder
import com.ichi2.anki.snackbar.showSnackbar
import com.ichi2.anki.sync.MeteredSyncPolicy
import com.ichi2.anki.sync.launchCatchingRequiringOneWaySyncDiscardUndo
+import com.ichi2.anki.sync.syncStatus
import com.ichi2.anki.ui.BottomFadeFrameLayout
import com.ichi2.anki.ui.ResizablePaneManager
import com.ichi2.anki.ui.animations.fadeIn
@@ -300,6 +301,9 @@ open class DeckPicker :
override val analyticsScreenName: String
get() = selectedBottomNavItem()?.analyticsScreenName ?: super.analyticsScreenName
+ internal val bottomNavigationEnabled: Boolean
+ get() = Prefs.devBottomNavEnabled && !fragmented
+
// Short animation duration from system
private var shortAnimDuration = 0
@@ -558,7 +562,7 @@ open class DeckPicker :
// create inherited navigation drawer layout here so that it can be used by parent class
initNavigationDrawer()
- if (Prefs.devBottomNavEnabled && !AnkiquestNavigation.enabled() && !fragmented) {
+ if (bottomNavigationEnabled && !AnkiquestNavigation.enabled()) {
disableDrawerSwipe()
disableDrawerIndicator()
}
@@ -604,7 +608,7 @@ open class DeckPicker :
onRetry = { heatmapViewModel.refresh() },
)
deckPickerBinding.decks.adapter = ConcatAdapter(deckListAdapter, heatmapAdapter)
- if (Prefs.devBottomNavEnabled) {
+ if (bottomNavigationEnabled) {
deckPickerBinding.decks.addItemDecoration(
DeckHierarchyLinesDecoration(this, deckListAdapter),
)
@@ -615,12 +619,16 @@ open class DeckPicker :
setupPullToSync()
// Setup the FloatingActionButtons
floatingActionMenu =
- DeckPickerFloatingActionMenu(this, binding, this).apply {
+ DeckPickerFloatingActionMenu(
+ context = this,
+ homescreenBinding = binding,
+ deckPicker = this,
toggleListener =
FloatingActionBarToggleListener { isOpening ->
closeFloatingActionBarBackPressCallback.isEnabled = isOpening
- }
- }
+ },
+ initiallyOpen = savedInstanceState?.getBoolean("mIsFABOpen") == true,
+ )
shortAnimDuration = resources.getInteger(android.R.integer.config_shortAnimTime)
@@ -1232,7 +1240,9 @@ open class DeckPicker :
}
Timber.d("onCreateOptionsMenu()")
- floatingActionMenu.closeFloatingActionMenu(applyRiseAndShrinkAnimation = false)
+ if (isDrawerOpen) {
+ floatingActionMenu.closeFloatingActionMenu(applyRiseAndShrinkAnimation = false)
+ }
// Fragments own their menus: each fragment registers a MenuProvider against this
// activity (see StudyOptionsFragment), and the menu host dispatches creation,
// preparation and selection to them. This activity never drives a fragment's menu.
@@ -1515,14 +1525,16 @@ open class DeckPicker :
}
}
- private fun createBackup() {
+ @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
+ internal fun createBackup(): Job =
launchCatchingTask {
- withProgress(message = TR.sentenceCase.creatingBackup) {
- performBackupInBackground(true)
- }
- showThemedToast(this@DeckPicker, TR.profilesBackupCreated(), false)
+ val created =
+ withProgress(message = TR.sentenceCase.creatingBackup) {
+ performBackupInBackground(true)
+ }
+ val message = if (created) TR.profilesBackupCreated() else TR.profilesBackupUnchanged()
+ showThemedToast(this@DeckPicker, message, false)
}
- }
private fun showMediaCheckDialog() {
Timber.i("showing media check dialog")
@@ -1634,6 +1646,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)
Timber.i("Performing Sync on Resume")
Permissions.requestNotificationPermissionsForSyncing(this)
sync()
@@ -1651,7 +1664,7 @@ open class DeckPicker :
outState.putBoolean("mIsFABOpen", floatingActionMenu.isFABOpen)
importColpkgListener?.let {
if (it is DatabaseRestorationListener) {
- outState.getString("dbRestorationPath", it.newAnkiDroidDirectory.absolutePath)
+ outState.putString("dbRestorationPath", it.newAnkiDroidDirectory.absolutePath)
}
}
outState.putSerializable("mediaUsnOnConflict", mediaUsnOnConflict)
@@ -1660,7 +1673,6 @@ open class DeckPicker :
public override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
- floatingActionMenu.isFABOpen = savedInstanceState.getBoolean("mIsFABOpen")
savedInstanceState.getString("dbRestorationPath")?.let { path ->
val path = File(path)
CollectionHelper.ankiDroidDirectoryOverride = path
@@ -1740,7 +1752,8 @@ open class DeckPicker :
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
- if ((!Prefs.devBottomNavEnabled && !AnkiquestNavigation.enabled()) || fragmented || event.action != KeyEvent.ACTION_DOWN ||
+ if ((!bottomNavigationEnabled && (!AnkiquestNavigation.enabled() || fragmented)) ||
+ event.action != KeyEvent.ACTION_DOWN ||
!event.isAltPressed
) {
return super.dispatchKeyEvent(event)
@@ -2534,7 +2547,7 @@ open class DeckPicker :
fun bottomNavShortcut(
keys: String,
destination: NavigationItem,
- ) = if ((Prefs.devBottomNavEnabled || AnkiquestNavigation.enabled()) && !fragmented) {
+ ) = if (bottomNavigationEnabled || (AnkiquestNavigation.enabled() && !fragmented)) {
val label =
if (AnkiquestNavigation.enabled()) {
when (destination) {
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt
index 745577e851c9..0bfe3d6b7a54 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt
@@ -24,6 +24,8 @@ class DeckPickerFloatingActionMenu(
private val context: Context,
homescreenBinding: ActivityHomescreenBinding,
private val deckPicker: DeckPicker,
+ private val toggleListener: FloatingActionBarToggleListener,
+ initiallyOpen: Boolean,
) {
// TODO: refactor this to decouple with Homescreen & DeckPicker
private val binding: IncludeFloatingAddButtonBinding = homescreenBinding.deckPickerPane.floatingActionButton
@@ -43,8 +45,7 @@ class DeckPickerFloatingActionMenu(
private val addWhiteIcon: Int = R.drawable.ic_add
var isFABOpen = false
-
- var toggleListener: FloatingActionBarToggleListener? = null
+ private set
@Suppress("unused")
val isFragmented: Boolean
@@ -52,7 +53,7 @@ class DeckPickerFloatingActionMenu(
@VisibleForTesting
fun showFloatingActionMenu() {
- toggleListener?.onBeginToggle(isOpening = true)
+ toggleListener.onBeginToggle(isOpening = true)
deckPicker.activeSnackBar?.dismiss()
linearLayout.alpha = 0.5f
studyOptionsFrame?.let { it.alpha = 0.5f }
@@ -111,7 +112,7 @@ class DeckPickerFloatingActionMenu(
* want to show any type of rise and shrink animation for the FAB so we put the value `false` for the parameter.
*/
fun closeFloatingActionMenu(applyRiseAndShrinkAnimation: Boolean) {
- toggleListener?.onBeginToggle(isOpening = false)
+ toggleListener.onBeginToggle(isOpening = false)
if (applyRiseAndShrinkAnimation) {
linearLayout.alpha = 1f
studyOptionsFrame?.let { it.alpha = 1f }
@@ -401,6 +402,10 @@ class DeckPickerFloatingActionMenu(
}
}
binding.fabMain.setOnClickListener(fabMainClickListener)
+
+ if (initiallyOpen) {
+ showFloatingActionMenu()
+ }
}
/**
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/FieldEditText.kt b/AnkiDroid/src/main/java/com/ichi2/anki/FieldEditText.kt
index 725a779b58e4..c658b29e4066 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/FieldEditText.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/FieldEditText.kt
@@ -126,14 +126,7 @@ class FieldEditText :
content: String?,
replaceNewLine: Boolean,
) {
- val text =
- if (content == null) {
- ""
- } else if (replaceNewLine) {
- content.replace(" ".toRegex(), NEW_LINE)
- } else {
- content
- }
+ val text = if (content == null) "" else convertBrToNewline(content, replaceNewLine)
setText(text)
}
@@ -229,5 +222,11 @@ class FieldEditText :
companion object {
val NEW_LINE: String = System.getProperty("line.separator")!!
+
+ /** Converts stored ` ` HTML line breaks to [NEW_LINE], matching [setContent]'s handling. */
+ fun convertBrToNewline(
+ content: String,
+ replaceNewLine: Boolean,
+ ): String = if (replaceNewLine) content.replace(" ".toRegex(), NEW_LINE) else content
}
}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/HomeScreenNavigation.kt b/AnkiDroid/src/main/java/com/ichi2/anki/HomeScreenNavigation.kt
index 8bca97a116ac..7796816aada9 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/HomeScreenNavigation.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/HomeScreenNavigation.kt
@@ -24,7 +24,6 @@ import com.ichi2.anki.browser.CardBrowserViewModel
import com.ichi2.anki.common.analytics.Analytics
import com.ichi2.anki.common.annotations.NeedsTest
import com.ichi2.anki.pages.Statistics
-import com.ichi2.anki.settings.Prefs
/**
* Manages the bottom navigation bar for the home screen.
@@ -39,7 +38,7 @@ fun setupBottomNavigation() {
if (deckPicker.supportFragmentManager.findFragmentByTag(NavigationItem.BROWSER.tag) != null) {
ensureBrowserViewModel()
}
- if ((!Prefs.devBottomNavEnabled && !AnkiquestNavigation.enabled()) || deckPicker.fragmented) return
+ if (!deckPicker.bottomNavigationEnabled && (!AnkiquestNavigation.enabled() || deckPicker.fragmented)) return
val bottomNav = deckPicker.findViewById(R.id.bottom_navigation)
val fragmentContainer = deckPicker.findViewById(R.id.bottom_nav_fragment_container)
@@ -105,14 +104,14 @@ fun showRestoredBottomNavTab() {
deckPicker.binding.bottomNavigation?.selectedItemId = R.id.ankiquest_nav_decks
return
}
- if (!Prefs.devBottomNavEnabled || deckPicker.fragmented) return
+ if (!deckPicker.bottomNavigationEnabled) return
val bottomNav = deckPicker.binding.bottomNavigation ?: return
bottomNav.selectedItemId = bottomNav.selectedItemId
}
context(deckPicker: DeckPicker)
fun selectedBottomNavItem(): NavigationItem? {
- if (!Prefs.devBottomNavEnabled || deckPicker.fragmented) return null
+ if (!deckPicker.bottomNavigationEnabled) return null
val bottomNav = deckPicker.binding.bottomNavigation ?: return null
return NavigationItem.fromId(bottomNav.selectedItemId)
}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/LeakCanaryConfiguration.kt b/AnkiDroid/src/main/java/com/ichi2/anki/LeakCanaryConfiguration.kt
index 8392672ddfb3..2373cfd2999c 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/LeakCanaryConfiguration.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/LeakCanaryConfiguration.kt
@@ -1,18 +1,5 @@
-/*
- * Copyright (c) 2022 lukstbit
- *
- * This program is free software; you can redistribute it and/or modify it under
- * the terms of the GNU General Public License as published by the Free Software
- * Foundation; either version 3 of the License, or (at your option) any later
- * version.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT ANY
- * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
- * PARTICULAR PURPOSE. See the GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program. If not, see .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
+
package com.ichi2.anki
import android.app.Application
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NavigationDrawerActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NavigationDrawerActivity.kt
index 2d4ec5e17096..339f8b69ccc4 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/NavigationDrawerActivity.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/NavigationDrawerActivity.kt
@@ -155,8 +155,6 @@ abstract class NavigationDrawerActivity(
protected fun initNavigationDrawer(mainView: View = findViewById(android.R.id.content)) {
// Create inherited navigation drawer layout here so that it can be used by parent class
drawerLayout = mainView.findViewById(R.id.drawer_layout)
- // set a custom shadow that overlays the main content when the drawer opens
- drawerLayout.setDrawerShadow(R.drawable.drawer_shadow, GravityCompat.START)
// Force transparent status bar with primary dark color underlaid so that the drawer displays under status bar
window.statusBarColor = getColor(R.color.transparent)
drawerLayout.setStatusBarBackgroundColor(
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt
index ca00f223fd6a..727b59e8085c 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt
@@ -2230,11 +2230,27 @@ class NoteEditorFragment :
}
} else {
populateEditFields(changeType)
- if (changeType.type != Type.CHANGE_FIELD_COUNT) {
+ if (changeType.type == Type.CHANGE_FIELD_COUNT) {
+ // A note type switch may carry the user's just-typed text into the new fields,
+ // so don't re-capture the baseline from current content. Instead, remap the
+ // existing baseline the same way the field content itself was remapped: keep
+ // baseline values for fields that still exist (put through the same -to-newline
+ // conversion `populateEditFields` just applied to the actual field content, so a
+ // sticky field containing literal ` ` text doesn't look edited), and treat any
+ // field the new note type added as baseline-empty, since it didn't exist for the
+ // user to edit before.
+ if (addNote) {
+ addNoteFieldBaseline =
+ List(editFields!!.size) { i ->
+ val old = addNoteFieldBaseline.getOrElse(i) { "" }
+ FieldEditText.convertBrToNewline(old, changeType.replaceNewlines)
+ }
+ }
+ } else {
updateFieldsFromStickyText()
- }
- if (addNote) {
- addNoteFieldBaseline = editFields!!.map { it.text?.toString() ?: "" }
+ if (addNote) {
+ addNoteFieldBaseline = editFields!!.map { it.text?.toString() ?: "" }
+ }
}
}
}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ReadText.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ReadText.kt
index adb77a528c74..caf2b949bdf1 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/ReadText.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/ReadText.kt
@@ -26,7 +26,6 @@ import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AlertDialog
import com.ichi2.anki.CollectionManager.TR
import com.ichi2.anki.cardviewer.SingleCardSide
-import com.ichi2.anki.common.utils.android.HandlerUtils.postDelayedOnNewHandler
import com.ichi2.anki.common.utils.android.showThemedToast
import com.ichi2.anki.i18n.getIso3LanguageOrNull
import com.ichi2.anki.libanki.Card
@@ -40,8 +39,12 @@ import com.ichi2.anki.utils.openUrl
import com.ichi2.utils.message
import com.ichi2.utils.positiveButton
import com.ichi2.utils.title
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.withContext
import timber.log.Timber
import java.lang.ref.WeakReference
+import java.util.Locale
object ReadText {
@get:VisibleForTesting(otherwise = VisibleForTesting.NONE)
@@ -59,6 +62,10 @@ object ReadText {
private const val NO_TTS = "0"
private val ttsParams = Bundle()
private var completionListener: ReadTextListener? = null
+ private var initializationJob: Job? = null
+ private var initialization: Any? = null
+ private var ttsEngine: String? = null
+ private var languageSelectionJob: Job? = null
private fun speak(
text: String?,
@@ -98,12 +105,31 @@ object ReadText {
* @param ord The card template ordinal
* @param qa The card question or card answer
*/
- @SuppressLint("CheckResult")
fun selectTts(
text: String?,
did: DeckId,
ord: Int,
qa: CardSide?,
+ ) {
+ val context = flashCardViewer.get() as? AbstractFlashcardViewer ?: return
+ val initialization = this.initialization ?: return
+ val engine = ttsEngine
+ languageSelectionJob?.cancel()
+ languageSelectionJob =
+ context.launchCatchingTask {
+ val locales = TtsVoices.localesForEngine(engine)
+ if (this@ReadText.initialization !== initialization) return@launchCatchingTask
+ showTtsDialog(text, did, ord, qa, locales)
+ }
+ }
+
+ @SuppressLint("CheckResult")
+ private suspend fun showTtsDialog(
+ text: String?,
+ did: DeckId,
+ ord: Int,
+ qa: CardSide?,
+ locales: List,
) {
// TODO: Consolidate with ReadText.readCardSide
textToSpeak = text
@@ -112,7 +138,7 @@ object ReadText {
ReadText.ord = ord
val res = flashCardViewer.get()!!.resources
val dialog = AlertDialog.Builder(flashCardViewer.get()!!)
- if (availableLocales().isEmpty()) {
+ if (locales.isEmpty()) {
Timber.w("ReadText.textToSpeech() no TTS languages available")
dialog
.message(CommonString.no_tts_available_message)
@@ -123,7 +149,7 @@ object ReadText {
mutableListOf>().apply {
add(Pair(NO_TTS, res.getString(CommonString.tts_no_tts))) // add option: "no tts"
val (validLocales, invalidLocales) =
- availableLocales()
+ locales
.sortedWith(compareBy { it.displayName })
.map { Pair(it.getIso3LanguageOrNull(), it.displayName) }
// getIso3LanguageOrNull returns null if invalid
@@ -155,21 +181,13 @@ object ReadText {
}
}
}
- // Show the dialog after short delay so that user gets a chance to preview the card
- showDialogAfterDelay(dialog, 500)
- }
-
- private fun showDialogAfterDelay(
- dialog: AlertDialog.Builder,
- delayMillis: Int,
- ) {
- postDelayedOnNewHandler({
- try {
- dialog.show()
- } catch (e: BadTokenException) {
- Timber.w(e, "Activity invalidated before TTS language dialog could display")
- }
- }, delayMillis.toLong())
+ // Give the user a chance to preview the card. The delay is cancelled with the reviewer.
+ delay(500)
+ try {
+ dialog.show()
+ } catch (e: BadTokenException) {
+ Timber.w(e, "Activity invalidated before TTS language dialog could display")
+ }
}
/**
@@ -288,6 +306,9 @@ object ReadText {
context: Context,
listener: ReadTextListener,
) {
+ cancelInitialization()
+ val initialization = Any()
+ this.initialization = initialization
// Store weak reference to Activity to prevent memory leak
flashCardViewer = WeakReference(context)
completionListener = listener
@@ -295,52 +316,26 @@ object ReadText {
// Create new TTS object and setup its onInit Listener
textToSpeech =
TextToSpeech(context) { status: Int ->
+ if (this.initialization !== initialization) return@TextToSpeech
if (status == TextToSpeech.SUCCESS) {
- if (availableLocales().isNotEmpty()) {
- // notify the reviewer that TTS has been initialized
- Timber.d("TTS initialized and available languages found")
- (context as AbstractFlashcardViewer).ttsInitialized()
- } else {
- ankiActivityContext?.showSnackbar(CommonString.no_tts_available_message)
- Timber.w("TTS initialized but no available languages found")
- }
- textToSpeech!!.setOnUtteranceProgressListener(
- object : UtteranceProgressListener() {
- override fun onDone(arg0: String) {
- listener.onDone(questionAnswer)
- }
-
- override fun onError(
- utteranceId: String?,
- errorCode: Int,
- ) {
- Timber.v(
- "Android TTS failed: %s (%d). Check logcat for error. " +
- "Indicates a problem with Android TTS engine.",
- errorToDeveloperString(errorCode),
- errorCode,
- )
- @StringRes val helpUrl = R.string.link_faq_tts
- val ankiActivity = context as AnkiActivity
- ankiActivity.mayOpenUrl(helpUrl)
- // TODO: We can do better in this UI now we have a reason for failure
- ankiActivity.showSnackbar(CommonString.no_tts_available_message) {
- setAction(CommonString.help) { openTtsHelpUrl(helpUrl) }
- }
+ val initializedTts = textToSpeech ?: return@TextToSpeech
+ initializationJob =
+ (context as AbstractFlashcardViewer).launchCatchingTask {
+ // Discovery may need more callbacks on the main thread, so suspend this
+ // reviewer instead of blocking the Android initialization callback.
+ val engine = withContext(ioDispatcher) { initializedTts.defaultEngine }
+ val locales = TtsVoices.localesForEngine(engine)
+ if (this@ReadText.initialization !== initialization) return@launchCatchingTask
+ ttsEngine = engine
+ initializedTts.setOnUtteranceProgressListener(utteranceProgressListener(context, listener))
+ if (locales.isNotEmpty()) {
+ Timber.d("TTS initialized and available languages found")
+ context.ttsInitialized()
+ } else {
+ ankiActivityContext?.showSnackbar(CommonString.no_tts_available_message)
+ Timber.w("TTS initialized but no available languages found")
}
-
- @Suppress("DeprecatedCallableAddReplaceWith")
- @Deprecated("")
- override fun onError(utteranceId: String) {
- // required for UtteranceProgressListener, but also deprecated
- Timber.e("onError(string) should not have been called")
- }
-
- override fun onStart(arg0: String) {
- // no nothing
- }
- },
- )
+ }
} else {
showThemedToast(context, context.getString(CommonString.no_tts_available_message), false)
Timber.w("TTS not successfully initialized")
@@ -348,6 +343,46 @@ object ReadText {
}
}
+ private fun utteranceProgressListener(
+ context: Context,
+ listener: ReadTextListener,
+ ): UtteranceProgressListener =
+ object : UtteranceProgressListener() {
+ override fun onDone(arg0: String) {
+ listener.onDone(questionAnswer)
+ }
+
+ override fun onError(
+ utteranceId: String?,
+ errorCode: Int,
+ ) {
+ Timber.v(
+ "Android TTS failed: %s (%d). Check logcat for error. " +
+ "Indicates a problem with Android TTS engine.",
+ errorToDeveloperString(errorCode),
+ errorCode,
+ )
+ @StringRes val helpUrl = R.string.link_faq_tts
+ val ankiActivity = context as AnkiActivity
+ ankiActivity.mayOpenUrl(helpUrl)
+ // TODO: We can do better in this UI now we have a reason for failure
+ ankiActivity.showSnackbar(CommonString.no_tts_available_message) {
+ setAction(CommonString.help) { openTtsHelpUrl(helpUrl) }
+ }
+ }
+
+ @Suppress("DeprecatedCallableAddReplaceWith")
+ @Deprecated("")
+ override fun onError(utteranceId: String) {
+ // required for UtteranceProgressListener, but also deprecated
+ Timber.e("onError(string) should not have been called")
+ }
+
+ override fun onStart(arg0: String) {
+ // no nothing
+ }
+ }
+
fun errorToDeveloperString(errorCode: Int): String =
when (errorCode) {
TextToSpeech.ERROR -> "Generic failure"
@@ -375,6 +410,7 @@ object ReadText {
*/
fun releaseTts(context: Context) {
if (textToSpeech != null && flashCardViewer.get() === context) {
+ cancelInitialization()
textToSpeech!!.stop()
textToSpeech!!.shutdown()
}
@@ -387,6 +423,7 @@ object ReadText {
}
fun closeForTests() {
+ cancelInitialization()
if (textToSpeech != null) {
textToSpeech!!.shutdown()
}
@@ -395,8 +432,14 @@ object ReadText {
System.gc()
}
- @Suppress("DEPRECATION") // we'll be removing this functionality, little point in fixing
- private fun availableLocales() = TtsVoices.availableLocalesBlocking()
+ private fun cancelInitialization() {
+ initialization = null
+ initializationJob?.cancel()
+ initializationJob = null
+ languageSelectionJob?.cancel()
+ languageSelectionJob = null
+ ttsEngine = null
+ }
interface ReadTextListener {
fun onDone(playedSide: CardSide?)
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/Reviewer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/Reviewer.kt
index 886924b0fed4..0835c9141915 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/Reviewer.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/Reviewer.kt
@@ -247,7 +247,7 @@ open class Reviewer :
if (!ensureStorageIsReady()) {
return
}
- if (Prefs.devBottomNavEnabled) {
+ if (Prefs.devBottomNavEnabled && resources.getBoolean(R.bool.bottom_navigation_available)) {
showBackIcon()
}
colorPalette = findViewById(R.id.whiteboard_editor)
@@ -1127,11 +1127,10 @@ open class Reviewer :
}
}
- private fun showDueDateDialog() =
- launchCatchingTask {
- Timber.i("showing due date dialog")
- SetDueDateDialog.show(this@Reviewer, listOf(currentCardId!!))
- }
+ private fun showDueDateDialog() {
+ Timber.i("showing due date dialog")
+ SetDueDateDialog.show(this, listOf(currentCardId!!))
+ }
private fun showResetCardDialog() {
Timber.i("showResetCardDialog() Reset progress button pressed")
@@ -1936,6 +1935,24 @@ open class Reviewer :
)
}
+ @Suppress("deprecation") // #9332: UI Visibility -> Insets
+ private fun isImmersiveSystemUiVisible(
+ activity: AnkiActivity,
+ ): Boolean = activity.window.decorView.systemUiVisibility and View.SYSTEM_UI_FLAG_HIDE_NAVIGATION == 0
+
+ fun setFullscreenMode(mode: FullScreenMode): Boolean =
+ try {
+ val prefs = sharedPrefs()
+ if (FullScreenMode.fromPreference(prefs) != mode) {
+ FullScreenMode.setPreference(prefs, mode)
+ ActivityCompat.recreate(this)
+ }
+ true
+ } catch (e: Exception) {
+ Timber.w(e, "Error setting fullscreen mode")
+ false
+ }
+
override suspend fun handlePostRequest(
uri: PostRequestUri,
bytes: ByteArray,
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt b/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt
index 54ea704fb7d4..30bfe1e35664 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt
@@ -6,7 +6,6 @@ package com.ichi2.anki
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
import anki.collection.Progress
-import anki.sync.SyncAuth
import anki.sync.SyncCollectionResponse
import anki.sync.syncAuth
import com.google.android.material.snackbar.Snackbar
@@ -19,6 +18,9 @@ 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.SyncAuth
+import com.ichi2.anki.sync.fullUploadOrDownload
+import com.ichi2.anki.sync.syncCollection
import com.ichi2.anki.ui.internationalization.sentenceCase
import com.ichi2.anki.worker.SyncMediaWorker
import com.ichi2.preferences.VersatileTextWithASwitchPreference
@@ -60,7 +62,7 @@ fun syncAuth(): SyncAuth? {
this.endpoint = resolvedEndpoint
}
this.ioTimeoutSecs = Prefs.networkTimeoutSecs
- }
+ }.let(::SyncAuth)
}
}
@@ -169,11 +171,7 @@ private suspend fun handleNormalSync(
if (output.hasNewEndpoint() && output.newEndpoint.isNotEmpty()) {
Timber.i("sync endpoint updated")
Prefs.currentSyncUri = output.newEndpoint
- auth2 =
- syncAuth {
- this.hkey = auth.hkey
- endpoint = output.newEndpoint
- }
+ auth2 = auth.withEndpoint(output.newEndpoint)
}
val mediaUsn =
if (syncMedia) {
@@ -245,11 +243,15 @@ private suspend fun handleDownload(
onCancel = ::cancelSync,
) {
try {
- createBackup(
- BackupManager.getBackupDirectoryFromCollection(colDb),
- force = true,
- waitForCompletion = true,
- )
+ val created =
+ createBackup(
+ BackupManager.getBackupDirectoryFromCollection(colDb),
+ force = true,
+ waitForCompletion = true,
+ )
+ if (!created) {
+ Timber.i("Sync: Backup skipped before full download: collection unchanged")
+ }
close(downgrade = false, forFullSync = true)
fullUploadOrDownload(auth, upload = false, serverUsn = mediaUsn)
} finally {
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/TtsVoices.kt b/AnkiDroid/src/main/java/com/ichi2/anki/TtsVoices.kt
index 69804ec00daa..118f939228f7 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/TtsVoices.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/TtsVoices.kt
@@ -22,21 +22,21 @@ package com.ichi2.anki
import android.speech.tts.TextToSpeech
import android.speech.tts.Voice
-import com.ichi2.anki.TtsVoices.availableLocaleData
-import com.ichi2.anki.TtsVoices.availableLocales
import com.ichi2.anki.common.android.appContext
import com.ichi2.anki.common.coroutines.applicationScope
import com.ichi2.anki.i18n.normalize
import com.ichi2.anki.i18n.toAnkiTwoLetterCode
import com.ichi2.anki.libanki.TemplateManager
import com.ichi2.anki.libanki.TtsVoice
+import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
-import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
import timber.log.Timber
import java.util.Locale
+import java.util.concurrent.atomic.AtomicReference
import kotlin.coroutines.resume
/**
@@ -51,13 +51,13 @@ object TtsVoices {
// A new instance of this list is not required if the app language changes: .displayName returns
// the new values
- /** An immutable list of locales available for TTS */
- private lateinit var availableLocaleData: List
+ /** 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
- /** A job which populates [availableLocaleData] */
+ /** A job which populates both the default-engine languages and all available voices. */
private var buildLocalesJob: Job? = null
/**
@@ -71,58 +71,43 @@ object TtsVoices {
var ttsEngine: String? = null
private set
- /**
- * Returns the list of available locales for use in TTS
- *
- * This is a blocking function in the worst case scenario, but under all normal circumstances
- * this should return instantly
- *
- * @return The list of available languages, or an empty list if an error occurred
- */
- @Deprecated("blocking function", replaceWith = ReplaceWith("availableLocales"))
- fun availableLocalesBlocking(): List {
- if (this::availableLocaleData.isInitialized) {
- return this.availableLocaleData
- }
- Timber.w("availableLocales was unexpectedly a blocking function")
-
- launchBuildLocalesJob()
- runBlocking {
- buildLocalesJob?.join()
- }
-
- return this.availableLocaleData
- }
-
suspend fun refresh() {
launchBuildLocalesJob()
buildLocalesJob?.join()
- loadTtsVoicesData()
+ val defaultLocales = CompletableDeferred()
+ availableVoices = loadVoices(defaultLocales, ::createTts)
+ engineLocaleData.set(defaultLocales)
}
/**
- * Returns the list of available locales for use in TTS
- *
- * Under all normal circumstances this should return instantly
- *
- * @return The list of available languages, or an empty list if an error occurred
+ * Returns cached languages for the reviewer's engine, suspending until ready.
+ * If the default engine has changed since startup, query that engine on a separate connection.
*/
- private suspend fun availableLocales(): List {
- if (this::availableLocaleData.isInitialized) {
- return this.availableLocaleData
- }
-
+ internal suspend fun localesForEngine(engine: String?): List {
launchBuildLocalesJob()
- buildLocalesJob?.join()
+ val cached = engineLocaleData.get()
+ val result = cached.await()
+ if (result.engine == engine) return result.locales
- return this.availableLocaleData
+ val locales =
+ withContext(ioDispatcher) {
+ val tts = createTts(engine) ?: return@withContext emptyList()
+ try {
+ readLocales(tts)
+ } finally {
+ tts.shutdown()
+ }
+ }
+ // A refresh may have published newer data while this query was in flight.
+ engineLocaleData.compareAndSet(cached, CompletableDeferred(EngineLocales(engine, locales)))
+ return locales
}
/**
* Returns the list of available voices for use in TTS
*/
suspend fun allTtsVoices(): Set {
- if (this::availableLocaleData.isInitialized) {
+ if (this::availableVoices.isInitialized) {
return this.availableVoices
}
@@ -132,14 +117,14 @@ object TtsVoices {
}
/**
- * Launches a [Job] to populate the list of available locales for use in TTS
+ * Launches a [Job] to populate the default-engine languages and all available voices
*
* This is run in the background, without blocking the main thread
*
- * [availableLocales] awaits the result of this function
+ * Legacy TTS does not wait for the full voice scan.
*/
fun launchBuildLocalesJob() {
- if (this::availableLocaleData.isInitialized || buildLocalesJob != null) {
+ if (this::availableVoices.isInitialized || buildLocalesJob != null) {
Timber.d("job already started")
return
}
@@ -147,83 +132,108 @@ object TtsVoices {
Timber.d("launching job")
// This is intended to be a global singleton outside the lifecycle of a specific activity
// Most of the time of execution is waiting for the TTS Engine to initialize
+ val defaultLocales = engineLocaleData.get()
buildLocalesJob =
applicationScope.launch(Dispatchers.IO) {
Timber.d("executing job")
- loadTtsVoicesData()
+ availableVoices = loadVoices(defaultLocales, ::createTts)
buildLocalesJob = null
- Timber.d("%d TTS Voices available", availableLocaleData.size)
+ Timber.d("%d TTS Voices available", availableVoices.size)
}
}
/**
- * Populates [availableVoices] and [availableLocaleData] with the voices and locales available
- * across every installed TTS engine (#18737), not just the user's default engine.
+ * Publishes default-engine languages before collecting voices from every installed engine.
+ * Both queries run in the startup job's IO context.
*/
- private suspend fun loadTtsVoicesData() {
+ internal suspend fun loadVoices(
+ defaultLocales: CompletableDeferred,
+ createTts: suspend (String?) -> TextToSpeech?,
+ ): Set {
// A default-engine instance is needed first to enumerate the installed engines
- val probeTts = createTts()
+ val probeTts =
+ try {
+ createTts(null)
+ } catch (e: Exception) {
+ // Do not leave a reviewer waiting forever if discovery fails before publishing languages.
+ defaultLocales.completeExceptionally(e)
+ throw e
+ }
if (probeTts == null) {
Timber.e("Unable to build list of TTS Voices")
- availableVoices = emptySet()
- availableLocaleData = emptyList()
- return
+ defaultLocales.complete(EngineLocales(null, emptyList()))
+ return emptySet()
}
val enginePackages =
try {
+ val engine = probeTts.defaultEngine
+ defaultLocales.complete(EngineLocales(engine, readLocales(probeTts)))
// `engines` lists every installed engine; include the default defensively
- (probeTts.engines.map { it.name } + listOfNotNull(probeTts.defaultEngine)).distinct()
+ try {
+ (probeTts.engines.map { it.name } + listOfNotNull(engine)).distinct()
+ } catch (e: Exception) {
+ Timber.w(e, "unable to list TTS engines")
+ listOfNotNull(engine)
+ }
} catch (e: Exception) {
- Timber.w(e, "unable to list TTS engines")
- listOfNotNull(probeTts.defaultEngine)
+ defaultLocales.completeExceptionally(e)
+ throw e
} finally {
probeTts.shutdown()
}
- val (voices, locales) = loadVoicesFromEngines(enginePackages) { engine -> createTts(engine) }
- availableVoices = voices
- availableLocaleData = locales
+ return loadVoicesFromEngines(enginePackages) { engine -> createTts(engine) }
}
+ internal data class EngineLocales(
+ val engine: String?,
+ val locales: List,
+ )
+
+ private fun readLocales(tts: TextToSpeech): List =
+ try {
+ // Normalize engine-provided locales before displaying them, then remove aliases.
+ tts.availableLanguages
+ .orEmpty()
+ .map { it.normalize() }
+ .distinct()
+ } catch (e: Exception) {
+ Timber.w(e, "unable to read TTS languages")
+ emptyList()
+ }
+
/**
- * Loads the voices and locales available across the provided [enginePackages].
+ * Loads the voices available across the provided [enginePackages].
*
* Each engine is initialised independently so a single misbehaving engine cannot prevent the
* others from being listed.
*
* @param createTts builds a [TextToSpeech] bound to the provided engine package, or `null` on failure
- * @return the union of all voices, and the union of all normalized locales, across [enginePackages]
+ * @return the union of all voices across [enginePackages]
*/
internal suspend fun loadVoicesFromEngines(
enginePackages: List,
createTts: suspend (engine: String) -> TextToSpeech?,
- ): Pair, List> {
+ ): Set {
val voices = mutableSetOf()
- val locales = mutableSetOf()
for (engine in enginePackages) {
val tts = createTts(engine)
if (tts == null) {
Timber.w("Unable to initialize TTS engine: %s", engine)
continue
}
- // Samsung TextToSpeech engine returns locales with a displayName of "GBR,DEFAULT"/"GBR,f00"
- // so normalize them before displaying them to users
- // sample of problematic data: language = "eng", region = "GBR", variant = "f00"
try {
tts.voices?.let { engineVoices ->
voices += engineVoices.map { it.toTtsVoice(engine) }
}
- tts.availableLanguages?.let { engineLocales ->
- locales += engineLocales.map { it.normalize() }
- }
} catch (e: Exception) {
Timber.w(e, "error reading voices from TTS engine: %s", engine)
} finally {
tts.shutdown()
}
}
- return voices to locales.toList()
+ return voices
}
/**
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/account/LoggedInViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/account/LoggedInViewModel.kt
index 7e2ceff41a78..75810cfe7002 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/account/LoggedInViewModel.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/account/LoggedInViewModel.kt
@@ -1,19 +1,5 @@
-/*
- * Copyright (c) 2025 Ashish Yadav
- *
- * This program is free software; you can redistribute it and/or modify it under
- * the terms of the GNU General Public License as published by the Free Software
- * Foundation; either version 3 of the License, or (at your option) any later
- * version.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT ANY
- * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
- * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
- * details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program. If not, see .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright (c) 2025 Ashish Yadav
package com.ichi2.anki.account
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/account/LoginViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/account/LoginViewModel.kt
index 7db3b11b4ded..3bb7e847f02a 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/account/LoginViewModel.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/account/LoginViewModel.kt
@@ -1,19 +1,5 @@
-/*
- * Copyright (c) 2025 Ashish Yadav
- *
- * This program is free software; you can redistribute it and/or modify it under
- * the terms of the GNU General Public License as published by the Free Software
- * Foundation; either version 3 of the License, or (at your option) any later
- * version.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT ANY
- * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
- * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
- * details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program. If not, see .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright (c) 2025 Ashish Yadav
package com.ichi2.anki.account
@@ -21,11 +7,11 @@ import android.content.Context
import androidx.annotation.StringRes
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
-import anki.sync.SyncAuth
import com.ichi2.anki.CollectionManager.withCol
import com.ichi2.anki.CommonString
import com.ichi2.anki.common.annotations.NeedsTest
import com.ichi2.anki.settings.Prefs
+import com.ichi2.anki.sync.SyncAuth
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
@@ -109,7 +95,7 @@ class LoginViewModel : ViewModel() {
endpoint: String?,
): SyncAuth =
withCol {
- syncLogin(username, password, endpoint)
+ SyncAuth(syncLogin(username, password, endpoint))
}
private fun updateLogin(
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/android/AnkiShakeDetector.kt b/AnkiDroid/src/main/java/com/ichi2/anki/android/AnkiShakeDetector.kt
index d78d46060035..80003081b005 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/android/AnkiShakeDetector.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/android/AnkiShakeDetector.kt
@@ -1,18 +1,5 @@
-/*
- * Copyright (c) 2026 Jatin Kumar
- *
- * This program is free software; you can redistribute it and/or modify it under
- * the terms of the GNU General Public License as published by the Free Software
- * Foundation; either version 3 of the License, or (at your option) any later
- * version.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT ANY
- * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
- * PARTICULAR PURPOSE. See the GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program. If not, see .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright (c) 2026 Jatin Kumar
package com.ichi2.anki.android
import android.content.Context
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/android/input/Shortcut.kt b/AnkiDroid/src/main/java/com/ichi2/anki/android/input/Shortcut.kt
index e74b01cabff9..720e0afae631 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/android/input/Shortcut.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/android/input/Shortcut.kt
@@ -1,18 +1,5 @@
-/*
- * Copyright (c) 2024 Sanjay Sargam
- *
- * This program is free software; you can redistribute it and/or modify it under
- * the terms of the GNU General Public License as published by the Free Software
- * Foundation; either version 3 of the License, or (at your option) any later
- * version.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT ANY
- * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
- * PARTICULAR PURPOSE. See the GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program. If not, see .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright (c) 2024 Sanjay Sargam
package com.ichi2.anki.android.input
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/android/input/ShortcutGroup.kt b/AnkiDroid/src/main/java/com/ichi2/anki/android/input/ShortcutGroup.kt
index 8b064cf66aea..62a74e5e8b3c 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/android/input/ShortcutGroup.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/android/input/ShortcutGroup.kt
@@ -1,18 +1,5 @@
-/*
- * Copyright (c) 2024 Sanjay Sargam
- *
- * This program is free software; you can redistribute it and/or modify it under
- * the terms of the GNU General Public License as published by the Free Software
- * Foundation; either version 3 of the License, or (at your option) any later
- * version.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT ANY
- * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
- * PARTICULAR PURPOSE. See the GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program. If not, see .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright (c) 2024 Sanjay Sargam
package com.ichi2.anki.android.input
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/android/input/ShortcutGroupProvider.kt b/AnkiDroid/src/main/java/com/ichi2/anki/android/input/ShortcutGroupProvider.kt
index 87305b3b5ad4..8231cb44d030 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/android/input/ShortcutGroupProvider.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/android/input/ShortcutGroupProvider.kt
@@ -1,18 +1,5 @@
-/*
- * Copyright (c) 2024 Sanjay Sargam
- *
- * This program is free software; you can redistribute it and/or modify it under
- * the terms of the GNU General Public License as published by the Free Software
- * Foundation; either version 3 of the License, or (at your option) any later
- * version.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT ANY
- * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
- * PARTICULAR PURPOSE. See the GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program. If not, see .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright (c) 2024 Sanjay Sargam
package com.ichi2.anki.android.input
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/backend/HtmlUtils.kt b/AnkiDroid/src/main/java/com/ichi2/anki/backend/HtmlUtils.kt
index 4087e220f04c..e3d007af4799 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/backend/HtmlUtils.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/backend/HtmlUtils.kt
@@ -1,21 +1,8 @@
-/*
- * Copyright (c) 2009 Daniel Svärd
- * Copyright (c) 2009 Edu Zamora
- * Copyright (c) 2011 Norbert Nagold
- * Copyright (c) 2012 Kostas Spyropoulos
- *
- * This program is free software; you can redistribute it and/or modify it under
- * the terms of the GNU General Public License as published by the Free Software
- * Foundation; either version 3 of the License, or (at your option) any later
- * version.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT ANY
- * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
- * PARTICULAR PURPOSE. See the GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program. If not, see .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright (c) 2009 Daniel Svärd
+// SPDX-FileCopyrightText: Copyright (c) 2009 Edu Zamora
+// SPDX-FileCopyrightText: Copyright (c) 2011 Norbert Nagold
+// SPDX-FileCopyrightText: Copyright (c) 2012 Kostas Spyropoulos
package com.ichi2.anki.backend
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserMultiColumnAdapter.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserMultiColumnAdapter.kt
index 3bd29591e7b6..cb8a409c4220 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserMultiColumnAdapter.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserMultiColumnAdapter.kt
@@ -15,6 +15,7 @@ import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.TextView
import androidx.annotation.ColorInt
+import androidx.annotation.MainThread
import androidx.annotation.VisibleForTesting
import androidx.appcompat.widget.ThemeUtils
import androidx.core.graphics.drawable.toDrawable
@@ -56,8 +57,14 @@ class BrowserMultiColumnAdapter(
val fontSizeScalePercent =
sharedPrefs().getInt("relativeCardBrowserFontSize", DEFAULT_FONT_SIZE_RATIO)
- private val rowCollection: BrowserRowCollection
- get() = viewModel.cards
+ // Search updates must not change the adapter's rows before RecyclerView is notified.
+ private var rows: List = viewModel.cards.toList()
+
+ @MainThread
+ fun refreshRows() {
+ rows = viewModel.cards.toList()
+ notifyDataSetChanged()
+ }
private var originalTextSize = -1.0f
@@ -222,19 +229,13 @@ class BrowserMultiColumnAdapter(
return MultiColumnViewHolder(binding)
}
- override fun getItemCount(): Int = rowCollection.size
+ override fun getItemCount(): Int = rows.size
override fun onBindViewHolder(
holder: MultiColumnViewHolder,
position: Int,
) {
- val id =
- try {
- rowCollection[position]
- } catch (e: Exception) {
- Timber.w(e)
- return
- }
+ val id = rows[position]
try {
val (row, isSelected) = viewModel.transformBrowserRow(id)
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 0da1f477aa7c..e016c8cd2a7c 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt
@@ -936,6 +936,12 @@ class CardBrowserFragment :
}
}
+ override fun onStart() {
+ super.onStart()
+ // Search update flows can be missed while the note editor is open.
+ cardsAdapter.refreshRows()
+ }
+
override fun onDestroyView() {
super.onDestroyView()
if (::cardsListView.isInitialized) {
@@ -952,7 +958,7 @@ class CardBrowserFragment :
private fun setupFlows() {
fun onIsTruncatedChanged(isTruncated: Boolean) = cardsAdapter.notifyDataSetChanged()
- fun cardsUpdatedChanged(unit: Unit) = cardsAdapter.notifyDataSetChanged()
+ fun cardsUpdatedChanged(unit: Unit) = cardsAdapter.refreshRows()
fun onColumnsChanged(columnCollection: BrowserColumnCollection) {
Timber.d("columns changed")
@@ -1035,7 +1041,7 @@ class CardBrowserFragment :
}
fun searchStateChanged(searchState: SearchState) {
- cardsAdapter.notifyDataSetChanged()
+ cardsAdapter.refreshRows()
progressIndicator.isVisible = searchState == Initializing || searchState == Searching
if (searchState is SearchState.Completed) {
onSearchCompleted(searchState)
@@ -1241,8 +1247,7 @@ class CardBrowserFragment :
showDialogFragment(dialog)
}
- /** Displays a snackbar: Sort by Card Type · A-Z*/
- fun onSortTypeChanged(notification: SortChangeNotification) {
+ fun describeSort(notification: SortChangeNotification): String {
val (title, subtitle) =
when (notification) {
is SortChangeNotification.NoOrdering ->
@@ -1253,8 +1258,16 @@ class CardBrowserFragment :
subtitleRes?.let(::getString)
}
}
- val text = if (subtitle != null) "$title · $subtitle" else title
- showSnackbar(text, Snackbar.LENGTH_SHORT)
+ return if (subtitle != null) "$title · $subtitle" else title
+ }
+
+ /** Displays a snackbar: Sort by Card Type · A-Z*/
+ fun onSortTypeChanged(notification: SortChangeNotification) {
+ showSnackbar(describeSort(notification), Snackbar.LENGTH_SHORT)
+ }
+
+ fun onCurrentSortChanged(sort: SortChangeNotification?) {
+ sortChip?.contentDescription = sort?.let(::describeSort) ?: getString(CommonString.card_browser_change_display_order_title)
}
activityViewModel.flowOfReverseDirection.launchCollectionInLifecycleScope(::reverseDirectionChanged)
@@ -1281,6 +1294,7 @@ class CardBrowserFragment :
activityViewModel.flowOfChangeNoteType.launchCollectionInLifecycleScope(::onChangeNoteType)
activityViewModel.flowOfSaveSearchNamePrompt.launchCollectionInLifecycleScope(::onSaveSearchNamePrompt)
activityViewModel.flowOfSortTypeChanged.launchCollectionInLifecycleScope(::onSortTypeChanged)
+ activityViewModel.flowOfCurrentSort.launchCollectionInLifecycleScope(::onCurrentSortChanged)
}
private fun setupFragmentResultListeners() {
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 64c688288e22..5932a16432d3 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt
@@ -69,6 +69,7 @@ import com.ichi2.anki.utils.ext.getCardOrNull
import com.ichi2.anki.utils.ext.setUserFlagForCards
import com.ichi2.utils.TagsUtil.getUpdatedTags
import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.channels.BufferOverflow
@@ -89,6 +90,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize
import net.ankiweb.rsdroid.BackendException
@@ -237,6 +239,9 @@ class CardBrowserViewModel(
/** Emits each time the user changes the sort order, with data for a snackbar */
val flowOfSortTypeChanged = MutableSharedFlow()
+ val flowOfCurrentSort: StateFlow
+ field = MutableStateFlow(null)
+
/**
* A map from column backend key to backend column definition
*
@@ -496,6 +501,11 @@ class CardBrowserViewModel(
*/
private var pendingSelectionRestore: List
+ /**
+ * The file backing the most recently saved [STATE_MULTISELECT_VALUES], until replaced or the activity finishes.
+ */
+ private var multiselectStateFile: IdsFile? = null
+
val flowOfColumnHeadings: StateFlow> =
combine(flowOfActiveColumns, flowOfCardsOrNotes, flowOfAllColumns) { activeColumns, cardsOrNotes, allColumns ->
if (allColumns.isEmpty()) return@combine emptyList()
@@ -545,6 +555,7 @@ class CardBrowserViewModel(
savedStateHandle.get(STATE_MULTISELECT_VALUES)?.let { bundle ->
BundleCompat.getParcelable(bundle, STATE_MULTISELECT_VALUES, IdsFile::class.java)
}
+ multiselectStateFile = idsFile
pendingSelectionRestore =
try {
idsFile?.getIds()?.map { CardOrNoteId(it) }
@@ -603,7 +614,7 @@ class CardBrowserViewModel(
setSelectedDeck(initialDeckId)
refreshBackendColumns()
- flowOfReverseDirection.update { (SortType.build(cardsOrNotes) as? SortType.CollectionOrdering)?.reverse }
+ refreshSortState()
Timber.i("initCompleted")
@@ -623,11 +634,32 @@ class CardBrowserViewModel(
fun generateExpensiveSavedState() =
Bundle().apply {
// a restored selection not yet applied to the rows is still the selection to save
- val selection = pendingSelectionRestore.ifEmpty { selectedRows.toList() }
- if (selection.isEmpty()) return@apply
- putParcelable(STATE_MULTISELECT_VALUES, IdsFile(cacheDir, selection.map { it.cardOrNoteId }, "multiselect-values"))
+ saveMultiselectState(pendingSelectionRestore.ifEmpty { selectedRows.toList() })
}
+ private fun Bundle.saveMultiselectState(selection: List) {
+ // Write the replacement before deleting the previous snapshot, so write failures preserve it.
+ val idsFile =
+ if (selection.isEmpty()) {
+ null
+ } else {
+ IdsFile(cacheDir, selection.map { it.cardOrNoteId }, "multiselect-values")
+ }
+ multiselectStateFile?.removeSafely("CardBrowserViewModel")
+ multiselectStateFile = idsFile
+ if (idsFile != null) putParcelable(STATE_MULTISELECT_VALUES, idsFile)
+ }
+
+ /**
+ * Delete the file backing the last saved selection when the activity is permanently dismissed.
+ *
+ * Call from [com.ichi2.anki.common.utils.ext.onPermanentDismissal], never from [onCleared].
+ */
+ internal fun deleteSavedSelectionFile() {
+ multiselectStateFile?.removeSafely("CardBrowserViewModel")
+ multiselectStateFile = null
+ }
+
/**
* Called if `onCreate` is called again, which may be due to the collection being reopened
*
@@ -650,6 +682,7 @@ class CardBrowserViewModel(
// if the language has changed, the backend column labels may have changed
viewModelScope.launch {
refreshBackendColumns()
+ refreshSortState()
}
}
@@ -819,7 +852,11 @@ class CardBrowserViewModel(
}
}
- fun setCardsOrNotes(newValue: CardsOrNotes) = viewModelScope.launch { browserOptionsRepository.setCardsOrNotes(newValue) }
+ fun setCardsOrNotes(newValue: CardsOrNotes) =
+ viewModelScope.launch {
+ browserOptionsRepository.setCardsOrNotes(newValue)
+ refreshSortState()
+ }
fun setTruncated(value: Boolean) = viewModelScope.launch { browserOptionsRepository.setIsTruncated(value) }
@@ -969,14 +1006,8 @@ class CardBrowserViewModel(
sortType.save(cardsOrNotes)
- flowOfReverseDirection.update {
- when (sortType) {
- is SortType.NoOrdering -> null
- is SortType.CollectionOrdering -> sortType.reverse
- }
- }
-
- flowOfSortTypeChanged.emit(buildSortChangeNotification(sortType))
+ val notification = updateSortState(sortType)
+ flowOfSortTypeChanged.emit(notification)
launchSearchForCards()
}
@@ -1001,6 +1032,17 @@ class CardBrowserViewModel(
}
}
+ private suspend fun refreshSortState() {
+ updateSortState(SortType.build(cardsOrNotes))
+ }
+
+ private fun updateSortState(sortType: SortType): SortChangeNotification {
+ val notification = buildSortChangeNotification(sortType)
+ flowOfReverseDirection.value = (sortType as? SortType.CollectionOrdering)?.reverse
+ flowOfCurrentSort.value = notification
+ return notification
+ }
+
/**
* Updates the backend with a new collection of columns
*
@@ -1449,8 +1491,11 @@ class CardBrowserViewModel(
val cards = com.ichi2.anki.searchForRows(searchString, sortOrder, cardsOrNotes)
Timber.d("Search returned %d card(s)", cards.size)
- ensureActive()
- this@CardBrowserViewModel.cards.replaceWith(cardsOrNotes, cards)
+ withContext(Dispatchers.Main) {
+ ensureActive()
+ // The adapter takes snapshots on the main thread.
+ this@CardBrowserViewModel.cards.replaceWith(cardsOrNotes, cards)
+ }
ensurePaneRowValid()
if (isFragmented) flowOfNoteEditorCommand.emit(NoteEditorCommand.fromCurrentSearchState())
flowOfSearchState.emit(SearchState.Completed.fromCurrentState(fromUserSearch))
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 0de70c8827bf..14c6d7a7d5c1 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/FindAndReplaceDialogFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/FindAndReplaceDialogFragment.kt
@@ -1,5 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
-// SPDX-FileCopyrightText: Copyright (c) 2025 lukstbit <52494258+lukstbit@users.noreply.github.com>
package com.ichi2.anki.browser
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/IdsFile.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/IdsFile.kt
index 5b3f90c879a4..766d3510560a 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/IdsFile.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/IdsFile.kt
@@ -10,11 +10,17 @@ import java.io.DataOutputStream
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
+import java.io.IOException
/**
* Temporary file containing cards or note IDs to be passed in a Bundle.
*
* It avoids [android.os.TransactionTooLargeException] when passing a big amount of data.
+ *
+ * For activity saved-state files, use [com.ichi2.anki.common.utils.ext.onPermanentDismissal] to
+ * clear state.
+ *
+ * Do not use `ViewModel.onCleared()`; see `onPermanentDismissal`.
*/
class IdsFile(
path: String,
@@ -33,9 +39,20 @@ class IdsFile(
}
}
+ /**
+ * Reads the Ids in the file. This may be an empty list.
+ *
+ * @throws IOException if the file is missing or corrupt.
+ */
fun getIds(): List =
- DataInputStream(FileInputStream(this)).use { inputStream ->
+ FileInputStream(this).use { fileStream ->
+ val inputStream = DataInputStream(fileStream.buffered())
val size = inputStream.readInt()
+ // Check before allocating: a corrupt count must not cause an enormous allocation,
+ // and a partial selection must never be used to perform an operation.
+ if (size < 0 || fileStream.channel.size() != Int.SIZE_BYTES + size.toLong() * Long.SIZE_BYTES) {
+ throw IOException("Invalid IDs file length or count: $name")
+ }
List(size) { inputStream.readLong() }
}
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 428119f93821..a4b5bb17cda8 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/RepositionCardFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/RepositionCardFragment.kt
@@ -1,5 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
-// SPDX-FileCopyrightText: Copyright (c) 2025 lukstbit <52494258+lukstbit@users.noreply.github.com>
package com.ichi2.anki.browser
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/SortOrderBottomSheetFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/SortOrderBottomSheetFragment.kt
index 25865d0406e8..a68b6dbfefbb 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/SortOrderBottomSheetFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/SortOrderBottomSheetFragment.kt
@@ -45,6 +45,16 @@ private typealias Reverse = Boolean?
/**
* A [BottomSheetDialogFragment] allowing selection of the sort order of the Card Browser
*
+ * Entitled "Choose display order"
+ *
+ * The fragment displays a list of columns which can be sorted by:
+ * Active, then Available, and allows a user to sort on each column in either an ascending or
+ * descending order.
+ *
+ * Columns have [sort types][ColumnType] which are displayed (text, numerical, dates etc...)
+ *
+ * 'No sorting' is also offered as an option.
+ *
* @param viewModelProviderFactory A factory producing a [CardBrowserViewModel]
*/
class SortOrderBottomSheetFragment(
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/MediaErrorHandler.kt b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/MediaErrorHandler.kt
index d8bb5f4b8fe1..b9f08d5d8141 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/MediaErrorHandler.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/MediaErrorHandler.kt
@@ -9,6 +9,7 @@ import android.webkit.WebResourceRequest
import androidx.annotation.VisibleForTesting
import androidx.core.net.toFile
import com.ichi2.anki.libanki.TtsPlayer
+import com.ichi2.anki.pages.AnkiServer.Companion.ANKIDROID_JS_PREFIX
import com.ichi2.anki.pages.AnkiServer.Companion.LOCALHOST
import timber.log.Timber
import java.io.File
@@ -92,6 +93,11 @@ class MediaErrorHandler : MediaErrorListener {
// Therefore limit this feature to the common case of local files, which should always work.
if (url.host != LOCALHOST) return
+ // API requests can fail during card replacement; they are not missing media files.
+ if (url.path?.startsWith(ANKIDROID_JS_PREFIX) == true) {
+ return
+ }
+
try {
val filename = URLUtil.guessFileName(url.toString(), null, null)
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TTS.kt b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TTS.kt
index 830ed39604ec..03fa841da9e4 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TTS.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TTS.kt
@@ -1,18 +1,5 @@
-/*
- * Copyright (c) 2021 Tyler Lewis
- *
- * This program is free software; you can redistribute it and/or modify it under
- * the terms of the GNU General Public License as published by the Free Software
- * Foundation; either version 3 of the License, or (at your option) any later
- * version.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT ANY
- * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
- * PARTICULAR PURPOSE. See the GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program. If not, see .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright (c) 2021 Tyler Lewis
// This time, we're going to be doing the most that we can using ReadText.java. Rather than the reimplementation
// that happened with branch Cloze_TTS_#9590.
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/contextmenu/DeckPickerMenuContentProvider.kt b/AnkiDroid/src/main/java/com/ichi2/anki/contextmenu/DeckPickerMenuContentProvider.kt
index 4806de6e4268..903d1c4ebb22 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/contextmenu/DeckPickerMenuContentProvider.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/contextmenu/DeckPickerMenuContentProvider.kt
@@ -1,18 +1,5 @@
-/*
- * Copyright (c) 2025 Hari Srinivasan
- *
- * This program is free software; you can redistribute it and/or modify it under
- * the terms of the GNU General Public License as published by the Free Software
- * Foundation; either version 3 of the License, or (at your option) any later
- * version.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT ANY
- * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
- * PARTICULAR PURPOSE. See the GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program. If not, see .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright (c) 2025 Hari Srinivasan
package com.ichi2.anki.contextmenu
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/contextmenu/MenuContentProvider.kt b/AnkiDroid/src/main/java/com/ichi2/anki/contextmenu/MenuContentProvider.kt
index 7c4a6528f4a6..de32a88fedc6 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/contextmenu/MenuContentProvider.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/contextmenu/MenuContentProvider.kt
@@ -1,18 +1,5 @@
-/*
- * Copyright (c) 2025 Hari Srinivasan
- *
- * This program is free software; you can redistribute it and/or modify it under
- * the terms of the GNU General Public License as published by the Free Software
- * Foundation; either version 3 of the License, or (at your option) any later
- * version.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT ANY
- * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
- * PARTICULAR PURPOSE. See the GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program. If not, see .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright (c) 2025 Hari Srinivasan
package com.ichi2.anki.contextmenu
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/contextmenu/MouseContextMenuHandler.kt b/AnkiDroid/src/main/java/com/ichi2/anki/contextmenu/MouseContextMenuHandler.kt
index 7e429a55028b..c4e1bfe3c824 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/contextmenu/MouseContextMenuHandler.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/contextmenu/MouseContextMenuHandler.kt
@@ -1,18 +1,5 @@
-/*
- * Copyright (c) 2025 Hari Srinivasan
- *
- * This program is free software; you can redistribute it and/or modify it under
- * the terms of the GNU General Public License as published by the Free Software
- * Foundation; either version 3 of the License, or (at your option) any later
- * version.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT ANY
- * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
- * PARTICULAR PURPOSE. See the GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program. If not, see .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright (c) 2025 Hari Srinivasan
package com.ichi2.anki.contextmenu
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckFilters.kt b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckFilters.kt
index d65c7cfbdb61..dfaeb0f067e5 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckFilters.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckFilters.kt
@@ -1,18 +1,5 @@
-/*
- * Copyright (c) 2026 Arthur Milchior
- *
- * This program is free software; you can redistribute it and/or modify it under
- * the terms of the GNU General Public License as published by the Free Software
- * Foundation; either version 3 of the License, or (at your option) any later
- * version.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT ANY
- * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
- * PARTICULAR PURPOSE. See the GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program. If not, see .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright (c) 2026 Arthur Milchior
package com.ichi2.anki.deckpicker
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 7537bfa40678..dcd652234454 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt
@@ -40,6 +40,7 @@ import com.ichi2.anki.observability.undoableOp
import com.ichi2.anki.performBackupInBackground
import com.ichi2.anki.reviewreminders.ScheduleRemindersDestination
import com.ichi2.anki.settings.Prefs
+import com.ichi2.anki.sync.syncStatus
import com.ichi2.anki.syncAuth
import com.ichi2.anki.utils.Destination
import kotlinx.coroutines.Dispatchers
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DisplayDeckNode.kt b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DisplayDeckNode.kt
index 15af23992b51..0359eb2a2926 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DisplayDeckNode.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DisplayDeckNode.kt
@@ -1,18 +1,5 @@
-/*
- * Copyright (c) 2025 Gautam Bhetanabhotla
- *
- * This program is free software; you can redistribute it and/or modify it under
- * the terms of the GNU General Public License as published by the Free Software
- * Foundation; either version 3 of the License, or (at your option) any later
- * version.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT ANY
- * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
- * PARTICULAR PURPOSE. See the GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program. If not, see .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright (c) 2025 Gautam Bhetanabhotla
package com.ichi2.anki.deckpicker
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DatabaseErrorDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DatabaseErrorDialog.kt
index 1d8934aaa624..ce92f5375bfc 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DatabaseErrorDialog.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DatabaseErrorDialog.kt
@@ -8,7 +8,6 @@ import android.app.Dialog
import android.os.Bundle
import android.os.Message
import android.os.Parcelable
-import androidx.activity.addCallback
import androidx.annotation.CheckResult
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
@@ -336,6 +335,8 @@ class DatabaseErrorDialog : AsyncDialogFragment() {
title(CommonString.restore_backup_title)
message(text = message)
positiveButton(CommonString.dialog_continue) {
+ // Cancelling backup selection should return to the deck list, not this warning.
+ dismiss()
showDatabaseErrorDialog(DIALOG_RESTORE_BACKUP)
}
negativeButton(CommonString.dialog_cancel)
@@ -420,22 +421,6 @@ class DatabaseErrorDialog : AsyncDialogFragment() {
}
}
- override fun setupDialog(
- dialog: Dialog,
- style: Int,
- ) {
- super.setupDialog(dialog, style)
-
- if (requireDialogType() == DIALOG_RESTORE_BACKUP) {
- // we don't want to go back to DIALOG_CONFIRM_RESTORE_BACKUP if back is pressed
- // instead, close all dialogs and return to the DeckPicker
- (dialog as AlertDialog).onBackPressedDispatcher.addCallback(this, true) {
- Timber.i("DIALOG_RESTORE_BACKUP caught hardware back button")
- requireActivity().dismissAllDialogFragments()
- }
- }
- }
-
/** @see DeckPicker.showDatabaseErrorDialog */
private fun showDatabaseErrorDialog(errorDialogType: DatabaseErrorDialogType) {
requireDeckPicker().showDatabaseErrorDialog(errorDialogType, exceptionData)
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckOptionsSelectionDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckOptionsSelectionDialog.kt
index 784ba36e8d91..e9a4091927dc 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckOptionsSelectionDialog.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckOptionsSelectionDialog.kt
@@ -1,5 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
-// SPDX-FileCopyrightText: Copyright (c) 2025 lukstbit <52494258+lukstbit@users.noreply.github.com>
package com.ichi2.anki.dialogs
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 a7827b7b2b69..44a83beb5447 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckSelectionDialog.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckSelectionDialog.kt
@@ -1,18 +1,5 @@
-/*
- Copyright (c) 2026 lukstbit <52494258+lukstbit@users.noreply.github.com>
+// SPDX-License-Identifier: GPL-3.0-or-later
- This program is free software; you can redistribute it and/or modify it under
- the terms of the GNU General Public License as published by the Free Software
- Foundation; either version 3 of the License, or (at your option) any later
- version.
-
- This program is distributed in the hope that it will be useful, but WITHOUT ANY
- WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
- PARTICULAR PURPOSE. See the GNU General Public License for more details.
-
- You should have received a copy of the GNU General Public License along with
- this program. If not, see .
- */
package com.ichi2.anki.dialogs
import android.app.Dialog
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EmptyCardsDialogFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EmptyCardsDialogFragment.kt
index eef818459856..53a4e41ad03d 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EmptyCardsDialogFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EmptyCardsDialogFragment.kt
@@ -1,5 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
-// SPDX-FileCopyrightText: Copyright (c) 2025 lukstbit <52494258+lukstbit@users.noreply.github.com>
package com.ichi2.anki.dialogs
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EmptyCardsViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EmptyCardsViewModel.kt
index 4bac3e3ef241..ab48a33c9be2 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EmptyCardsViewModel.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/EmptyCardsViewModel.kt
@@ -1,18 +1,4 @@
-/*
- * Copyright (c) 2025 lukstbit <52494258+lukstbit@users.noreply.github.com>
- *
- * This program is free software; you can redistribute it and/or modify it under
- * the terms of the GNU General Public License as published by the Free Software
- * Foundation; either version 3 of the License, or (at your option) any later
- * version.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT ANY
- * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
- * PARTICULAR PURPOSE. See the GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program. If not, see .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
package com.ichi2.anki.dialogs
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 2e101a36fb34..a74ad89f9677 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/LoadingDialogFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/LoadingDialogFragment.kt
@@ -1,5 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
-// SPDX-FileCopyrightText: Copyright (c) 2025 lukstbit <52494258+lukstbit@users.noreply.github.com>
package com.ichi2.anki.dialogs
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 1253d00e174d..eef776c9af4d 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/SaveBrowserSearchDialogFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/SaveBrowserSearchDialogFragment.kt
@@ -1,5 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
-// SPDX-FileCopyrightText: Copyright (c) 2025 lukstbit <52494258+lukstbit@users.noreply.github.com>
package com.ichi2.anki.dialogs
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 174e2d1167d1..bb007d5974b2 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/SavedBrowserSearchesDialogFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/SavedBrowserSearchesDialogFragment.kt
@@ -1,5 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
-// SPDX-FileCopyrightText: Copyright (c) 2025 lukstbit <52494258+lukstbit@users.noreply.github.com>
package com.ichi2.anki.dialogs
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 2729597cb025..4e84e06cad03 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
@@ -1,5 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
-// SPDX-FileCopyrightText: Copyright (c) 2025 lukstbit <52494258+lukstbit@users.noreply.github.com>
package com.ichi2.anki.dialogs.customstudy
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/IncludedExcludedTagsAdapter.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/IncludedExcludedTagsAdapter.kt
index b70368e51d8c..cae4c041ece6 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/IncludedExcludedTagsAdapter.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/IncludedExcludedTagsAdapter.kt
@@ -1,18 +1,5 @@
-/*
- * Copyright (c) 2025 lukstbit <52494258+lukstbit@users.noreply.github.com>
- *
- * This program is free software; you can redistribute it and/or modify it under
- * the terms of the GNU General Public License as published by the Free Software
- * Foundation; either version 3 of the License, or (at your option) any later
- * version.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT ANY
- * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
- * PARTICULAR PURPOSE. See the GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program. If not, see .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
+
package com.ichi2.anki.dialogs.customstudy
import android.annotation.SuppressLint
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 e33deea64214..3260ebd6f8e8 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
@@ -1,5 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
-// SPDX-FileCopyrightText: Copyright (c) 2025 lukstbit <52494258+lukstbit@users.noreply.github.com>
package com.ichi2.anki.dialogs.customstudy
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 7efa15552d94..a0756be4b046 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
@@ -203,7 +203,7 @@ class TagsArrayAdapter(
/**
* Whether there exists visible nested tag.
- * Recalculated everytime [buildTagTree] is called.
+ * Recalculated everytime [rebuildTagTree] is called.
*/
private var hasVisibleNestedTag: Boolean
@@ -224,6 +224,12 @@ class TagsArrayAdapter(
tags.sort()
}
+ /** Refresh cached subtree counts after bulk selection changes */
+ fun notifyCheckedStatusesChanged() {
+ rebuildTagTree()
+ notifyDataSetChanged()
+ }
+
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int,
@@ -332,7 +338,7 @@ class TagsArrayAdapter(
override fun getItemCount(): Int = treeRoot.subtreeSize
/**
- * Build the tag tree. The tags have been sorted using the hierarchical comparator
+ * Rebuild the tag tree. The tags have been sorted using the hierarchical comparator
* [TagsUtil.compareTag], which leads to a DFN order. Use a stack to build the tree without
* recursion.
* The initial expand states are inherited through [tagToIsExpanded].
@@ -341,7 +347,7 @@ class TagsArrayAdapter(
* @param expandTarget The target tag to expand. Do nothing if it is empty or not found.
*/
@NeedsTest("#18481 - case insensitivity")
- private fun buildTagTree(expandTarget: String) {
+ private fun rebuildTagTree(expandTarget: String = "") {
// init mapping for newly added tags
filteredList.forEach {
if (!tagToIsExpanded.containsKey(it)) {
@@ -447,7 +453,7 @@ class TagsArrayAdapter(
filteredList.clear()
filteredList.addAll(results)
sortData()
- buildTagTree(expandTarget)
+ rebuildTagTree(expandTarget)
expandTarget = String()
notifyDataSetChanged()
}
@@ -469,7 +475,7 @@ class TagsArrayAdapter(
// set the initial expand state according to its checked state
tags.forEach { tagToIsExpanded[it] = tags.isChecked(it) || tags.isIndeterminate(it) }
hasVisibleNestedTag = false
- buildTagTree(String())
+ rebuildTagTree()
}
companion object {
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt
index 0e4d436bc7ff..0a63dc85b07e 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt
@@ -39,6 +39,7 @@ import com.ichi2.anki.R
import com.ichi2.anki.analytics.AnalyticsDialogFragment
import com.ichi2.anki.browser.IdsFile
import com.ichi2.anki.common.annotations.NeedsTest
+import com.ichi2.anki.common.utils.android.showThemedToast
import com.ichi2.anki.databinding.DialogTagsBinding
import com.ichi2.anki.launchCatchingTask
import com.ichi2.anki.libanki.NoteId
@@ -65,6 +66,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
import timber.log.Timber
+import java.io.IOException
class TagsDialog : AnalyticsDialogFragment {
/**
@@ -85,7 +87,10 @@ class TagsDialog : AnalyticsDialogFragment {
CUSTOM_STUDY,
}
- private lateinit var binding: DialogTagsBinding
+ @VisibleForTesting
+ internal lateinit var binding: DialogTagsBinding
+ private set
+
private var type: DialogType? = null
internal val isEditingTags: Boolean get() = type == DialogType.EDIT_TAGS
private var tagsArrayAdapter: TagsArrayAdapter? = null
@@ -97,16 +102,16 @@ class TagsDialog : AnalyticsDialogFragment {
@VisibleForTesting
val viewModel: TagsDialogViewModel by viewModels {
- val idsFile = requireArguments().requireParcelable(ARG_TAGS_FILE)
- val noteIds = idsFile.getIds()
- val checkedTags =
- requireNotNull(requireArguments().getStringArrayList(ARG_CHECKED_TAGS)) {
- "$ARG_CHECKED_TAGS is required"
- }
- val type = BundleCompat.getParcelable(requireArguments(), ARG_DIALOG_TYPE, DialogType::class.java)
- val isCustomStudying = type != null && type == DialogType.CUSTOM_STUDY
viewModelFactory {
initializer {
+ val idsFile = requireArguments().requireParcelable(ARG_TAGS_FILE)
+ val noteIds = idsFile.getIds()
+ val checkedTags =
+ requireNotNull(requireArguments().getStringArrayList(ARG_CHECKED_TAGS)) {
+ "$ARG_CHECKED_TAGS is required"
+ }
+ val type = BundleCompat.getParcelable(requireArguments(), ARG_DIALOG_TYPE, DialogType::class.java)
+ val isCustomStudying = type != null && type == DialogType.CUSTOM_STUDY
TagsDialogViewModel(
noteIds = noteIds,
checkedTags = checkedTags,
@@ -172,6 +177,15 @@ class TagsDialog : AnalyticsDialogFragment {
" filled as prefix properly. In other dialog types, long-clicking a tag behaves like a short click.",
)
override fun onCreateDialog(savedInstanceState: Bundle?): Dialog {
+ // Resolve the selection before creating any controls that could submit an operation.
+ try {
+ viewModel
+ } catch (e: IOException) {
+ Timber.w(e, "Failed to read tag dialog IDs")
+ showThemedToast(requireContext(), CommonString.something_wrong, false)
+ dismiss()
+ return super.onCreateDialog(savedInstanceState)
+ }
binding = DialogTagsBinding.inflate(layoutInflater)
val positiveText =
@@ -280,7 +294,7 @@ class TagsDialog : AnalyticsDialogFragment {
val tags = viewModel.tags.await()
tagsDialogListener.onSelectedTags(
tags.copyOfCheckedTagList(),
- tags.copyOfIndeterminateTagList(),
+ tags.copyOfPartiallySelectedTagList(),
selectedOption,
)
}
@@ -360,7 +374,7 @@ class TagsDialog : AnalyticsDialogFragment {
val tags = viewModel.tags.await()
val didChange = tags.toggleAllCheckedStatuses()
if (didChange) {
- tagsArrayAdapter?.notifyDataSetChanged()
+ tagsArrayAdapter?.notifyCheckedStatusesChanged()
view?.showMaxTagSelectedNotice(tags)
}
}
@@ -477,9 +491,6 @@ class TagsDialog : AnalyticsDialogFragment {
}
}
- @VisibleForTesting(otherwise = VisibleForTesting.NONE)
- internal fun getSearchView(): AccessibleSearchView? = toolbarSearchView
-
companion object {
const val ARG_TAGS_FILE = "tagsFile"
private const val ARG_DIALOG_TYPE = "dialogType"
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 b353a21f3f5a..63724b6d255e 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
@@ -45,16 +45,21 @@ class TagsList(
private val checkedTags: MutableSet = TreeSet(java.lang.String.CASE_INSENSITIVE_ORDER)
/**
- * A Set containing the tags with indeterminate state.
- * For a tag to be in indeterminate state it should be present in checkedTags and also in uncheckedTags.
+ * Tags present on some notes, plus unchecked ancestors of selected tags.
*/
private val indeterminateTags: MutableSet
+ /** Tags present on some notes whose own selection has not been changed by the user. */
+ private val partiallySelectedTags: MutableSet = TreeSet(java.lang.String.CASE_INSENSITIVE_ORDER)
+
/**
* List of all available tags
*/
private val allTags: UniqueArrayList
+ /** Tags or selection have changed since the last sort. */
+ private var needsSort = true
+
init {
this.checkedTags.addAll(checkedTags)
this.allTags = from(allTags, java.lang.String.CASE_INSENSITIVE_ORDER)
@@ -69,6 +74,7 @@ class TagsList(
indeterminateTags.retainAll(uncheckedSet)
this.checkedTags.removeAll(indeterminateTags)
}
+ partiallySelectedTags.addAll(indeterminateTags)
prepareTagHierarchy()
}
@@ -119,6 +125,7 @@ class TagsList(
if (!allTags.add(tag)) {
return false
}
+ needsSort = true
addAncestors(tag)
return true
}
@@ -140,9 +147,11 @@ class TagsList(
return false
}
indeterminateTags.remove(tag)
+ partiallySelectedTags.remove(tag)
if (!checkedTags.add(tag)) {
return false
}
+ needsSort = true
if (processAncestors) {
markAncestorsIndeterminate(tag)
}
@@ -156,7 +165,14 @@ class TagsList(
* @return true if the tag changed its check status
* false if the tag was already unchecked or not in the list
*/
- fun uncheck(tag: String): Boolean = indeterminateTags.remove(tag) || checkedTags.remove(tag)
+ fun uncheck(tag: String): Boolean {
+ partiallySelectedTags.remove(tag)
+ val changed = indeterminateTags.remove(tag) || checkedTags.remove(tag)
+ if (changed) {
+ needsSort = true
+ }
+ return changed
+ }
/**
* Mark a tag as indeterminate tag
@@ -170,7 +186,11 @@ class TagsList(
return false
}
checkedTags.remove(tag)
- return indeterminateTags.add(tag)
+ val changed = indeterminateTags.add(tag)
+ if (changed) {
+ needsSort = true
+ }
+ return changed
}
/**
@@ -181,7 +201,9 @@ class TagsList(
* @return true if this tag list changed as a result of the call
*/
fun toggleAllCheckedStatuses(): Boolean {
+ needsSort = true
indeterminateTags.clear()
+ partiallySelectedTags.clear()
if (allTags.size == checkedTags.size) {
checkedTags.clear()
return true
@@ -220,6 +242,9 @@ class TagsList(
*/
fun copyOfIndeterminateTagList(): List = ArrayList(indeterminateTags)
+ /** Tags whose original per-note selection must be preserved when saving, excluding display-only ancestors. */
+ internal fun copyOfPartiallySelectedTagList(): List = ArrayList(partiallySelectedTags)
+
/**
* @return return a copy of all tags list
*/
@@ -266,22 +291,33 @@ class TagsList(
* A tag priors to another one if its root tag is checked or indeterminate while the other one's is not
*/
fun sort() {
- val sortedList =
- allTags.toList().sortedWith { lhs: String?, rhs: String? ->
- val lhsRoot = getTagRoot(lhs!!)
- val rhsRoot = getTagRoot(rhs!!)
- val lhsChecked = isChecked(lhsRoot) || isIndeterminate(lhsRoot)
- val rhsChecked = isChecked(rhsRoot) || isIndeterminate(rhsRoot)
- if (lhsChecked != rhsChecked) {
- if (lhsChecked) -1 else 1
- } else {
- compareTag(lhs, rhs)
- }
+ if (!needsSort) {
+ return
+ }
+ // Splitting tags and looking up root selection inside the comparator repeats this work
+ // for every comparison. Compute the keys once per tag instead.
+ val sortKeys =
+ allTags.associateWith { tag ->
+ val root = getTagRoot(tag)
+ SortKey(tag.split("::"), isChecked(root) || isIndeterminate(root))
+ }
+ allTags.sortWith { lhs, rhs ->
+ val lhsKey = sortKeys.getValue(lhs)
+ val rhsKey = sortKeys.getValue(rhs)
+ if (lhsKey.isSelected != rhsKey.isSelected) {
+ if (lhsKey.isSelected) -1 else 1
+ } else {
+ compareTag(lhsKey.parts, rhsKey.parts)
}
- allTags.clear()
- allTags.addAll(sortedList)
+ }
+ needsSort = false
}
+ private class SortKey(
+ val parts: List,
+ val isSelected: Boolean,
+ )
+
/**
* @return Iterator over all tags
*/
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/viewmodel/ExportReadyViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/viewmodel/ExportReadyViewModel.kt
index ca3fe06b903d..a9403436385d 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/viewmodel/ExportReadyViewModel.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/viewmodel/ExportReadyViewModel.kt
@@ -1,5 +1,5 @@
-// SPDX-FileCopyrightText: 2026 lukstbit <52494258+lukstbit@users.noreply.github.com>
// SPDX-License-Identifier: GPL-3.0-or-later
+
package com.ichi2.anki.dialogs.viewmodel
import android.os.Parcelable
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/exception/InvalidSearchException.kt b/AnkiDroid/src/main/java/com/ichi2/anki/exception/InvalidSearchException.kt
index 17bb0a984bba..7c4b1f5d08d8 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/exception/InvalidSearchException.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/exception/InvalidSearchException.kt
@@ -1,18 +1,4 @@
-/*
- * Copyright (c) 2026 lukstbit <52494258+lukstbit@users.noreply.github.com>
- *
- * This program is free software; you can redistribute it and/or modify it under
- * the terms of the GNU General Public License as published by the Free Software
- * Foundation; either version 3 of the License, or (at your option) any later
- * version.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT ANY
- * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
- * PARTICULAR PURPOSE. See the GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program. If not, see .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
package com.ichi2.anki.exception
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/exception/ReportableException.kt b/AnkiDroid/src/main/java/com/ichi2/anki/exception/ReportableException.kt
index a864773af86d..1a014db86224 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/exception/ReportableException.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/exception/ReportableException.kt
@@ -1,5 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
-// SPDX-FileCopyrightText: 2025 lukstbit <52494258+lukstbit@users.noreply.github.com>
package com.ichi2.anki.exception
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/export/ExportDialogFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/export/ExportDialogFragment.kt
index 89148ffe841d..8bc56f41a9fa 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/export/ExportDialogFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/export/ExportDialogFragment.kt
@@ -33,6 +33,7 @@ import com.ichi2.anki.browser.removeSafely
import com.ichi2.anki.common.ALL_DECKS_ID
import com.ichi2.anki.common.time.TimeManager
import com.ichi2.anki.common.time.getTimestamp
+import com.ichi2.anki.common.utils.android.showThemedToast
import com.ichi2.anki.compat.CompatHelper.Companion.getSerializableCompat
import com.ichi2.anki.databinding.DialogExportOptionsBinding
import com.ichi2.anki.exportApkgPackage
@@ -48,7 +49,9 @@ import com.ichi2.anki.utils.ext.requireParcelable
import com.ichi2.utils.negativeButton
import com.ichi2.utils.positiveButton
import kotlinx.coroutines.launch
+import timber.log.Timber
import java.io.File
+import java.io.IOException
/**
* Shows the possible options for exporting(collection, decks or notes/card selection).
@@ -103,9 +106,9 @@ class ExportDialogFragment : AnalyticsDialogFragment() {
if (selectedIndex != 0 && !binding.deckSelector.isEnabled) return@positiveButton
when (ExportConfiguration.from(selectedIndex)) {
ExportConfiguration.Collection -> handleCollectionExport()
- ExportConfiguration.Apkg -> handleAnkiPackageExport()
- ExportConfiguration.Notes -> handleNotesInPlainTextExport()
- ExportConfiguration.Cards -> handleCardsInPlainTextExport()
+ ExportConfiguration.Apkg -> withExportLimit(::handleAnkiPackageExport)
+ ExportConfiguration.Notes -> withExportLimit(::handleNotesInPlainTextExport)
+ ExportConfiguration.Cards -> withExportLimit(::handleCardsInPlainTextExport)
}
}.create()
}
@@ -262,8 +265,19 @@ class ExportDialogFragment : AnalyticsDialogFragment() {
requireAnkiActivity().exportCollectionPackage(exportPath, includeMedia, legacy)
}
- private fun handleAnkiPackageExport() {
- val limits = buildExportLimit()
+ private fun withExportLimit(export: (ExportLimit) -> Unit) {
+ val exportLimit =
+ try {
+ buildExportLimit()
+ } catch (e: IOException) {
+ Timber.w(e, "Failed to read export IDs")
+ showThemedToast(requireContext(), CommonString.something_wrong, false)
+ return
+ }
+ export(exportLimit)
+ }
+
+ private fun handleAnkiPackageExport(exportLimit: ExportLimit) {
val exportPath =
File(
getExportRootFile(),
@@ -274,7 +288,7 @@ class ExportDialogFragment : AnalyticsDialogFragment() {
withScheduling = binding.apkgIncludeSchedule.isChecked,
withDeckConfigs = binding.apkgIncludeDeckConfigs.isChecked,
withMedia = binding.apkgIncludeMedia.isChecked,
- limit = limits,
+ limit = exportLimit,
legacy = binding.apkgExportLegacy.isChecked,
)
}
@@ -296,8 +310,7 @@ class ExportDialogFragment : AnalyticsDialogFragment() {
return filename.value
}
- private fun handleNotesInPlainTextExport() {
- val exportLimit = buildExportLimit()
+ private fun handleNotesInPlainTextExport(exportLimit: ExportLimit) {
val exportPath =
File(
getExportRootFile(),
@@ -314,8 +327,7 @@ class ExportDialogFragment : AnalyticsDialogFragment() {
)
}
- private fun handleCardsInPlainTextExport() {
- val exportLimit = buildExportLimit()
+ private fun handleCardsInPlainTextExport(exportLimit: ExportLimit) {
val exportPath =
File(
getExportRootFile(),
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/filtered/FilteredDeckOptionsFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/filtered/FilteredDeckOptionsFragment.kt
index 462aac38ae88..c784b782fd04 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/filtered/FilteredDeckOptionsFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/filtered/FilteredDeckOptionsFragment.kt
@@ -1,5 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
-// SPDX-FileCopyrightText: Copyright (c) 2025 lukstbit <52494258+lukstbit@users.noreply.github.com>
package com.ichi2.anki.filtered
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/filtered/FilteredDeckOptionsState.kt b/AnkiDroid/src/main/java/com/ichi2/anki/filtered/FilteredDeckOptionsState.kt
index fdc1dacf88de..aa34d9493700 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/filtered/FilteredDeckOptionsState.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/filtered/FilteredDeckOptionsState.kt
@@ -1,18 +1,4 @@
-/*
- * Copyright (c) 2025 lukstbit <52494258+lukstbit@users.noreply.github.com>
- *
- * This program is free software; you can redistribute it and/or modify it under
- * the terms of the GNU General Public License as published by the Free Software
- * Foundation; either version 3 of the License, or (at your option) any later
- * version.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT ANY
- * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
- * PARTICULAR PURPOSE. See the GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program. If not, see .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
package com.ichi2.anki.filtered
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/filtered/FilteredDeckOptionsViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/filtered/FilteredDeckOptionsViewModel.kt
index f8ff99e7f60e..e817a68f89fd 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/filtered/FilteredDeckOptionsViewModel.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/filtered/FilteredDeckOptionsViewModel.kt
@@ -1,5 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
-// SPDX-FileCopyrightText: Copyright (c) 2025 lukstbit <52494258+lukstbit@users.noreply.github.com>
package com.ichi2.anki.filtered
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 f7aa110c484d..f7ad03c8f980 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckFragment.kt
@@ -66,7 +66,7 @@ class MediaCheckFragment : Fragment(R.layout.fragment_media_check) {
(requireActivity() as AppCompatActivity).setSupportActionBar(binding.toolbar)
observeProgress(viewModel) { progress -> getString(progress.messageRes) }
- viewModel.checkMedia()
+ launchCatchingTask { viewModel.checkMedia().await() }
lifecycleScope.launch {
viewModel.mediaCheckResult.collectLatest { result ->
@@ -149,7 +149,7 @@ class MediaCheckFragment : Fragment(R.layout.fragment_media_check) {
setOnClickListener {
launchCatchingTask {
- viewModel.tagMissing(TR.mediaCheckMissingMediaTag()).join()
+ viewModel.tagMissing(TR.mediaCheckMissingMediaTag()).await()
showResultDialog(
CommonString.check_media_tags_added,
TR.browsingNotesUpdated(viewModel.taggedFiles),
@@ -169,14 +169,14 @@ class MediaCheckFragment : Fragment(R.layout.fragment_media_check) {
private fun confirmMediaRestore() {
launchCatchingTask {
- viewModel.restoreTrash().join()
+ viewModel.restoreTrash().await()
showTrashRestoredDialog()
}
}
private fun deleteTrash() {
launchCatchingTask {
- viewModel.deleteTrash().join()
+ viewModel.deleteTrash().await()
showTrashDeletedDialog()
}
}
@@ -191,7 +191,7 @@ class MediaCheckFragment : Fragment(R.layout.fragment_media_check) {
private fun handleDeleteConfirmation() {
launchCatchingTask {
- viewModel.deleteUnusedMedia().join()
+ viewModel.deleteUnusedMedia().await()
showDeletionResult()
}
}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckViewModel.kt
index d031bbc9f326..876803dc3375 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckViewModel.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckViewModel.kt
@@ -25,10 +25,10 @@ import com.ichi2.anki.common.annotations.NeedsTest
import com.ichi2.anki.observability.undoableOp
import com.ichi2.anki.progress.HasProgress
import com.ichi2.anki.progress.ProgressManager
-import kotlinx.coroutines.Job
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.launch
/** The operation [MediaCheckViewModel] is running, shown as a progress message by the UI. */
enum class MediaCheckProgress {
@@ -37,6 +37,7 @@ enum class MediaCheckProgress {
DELETING_MEDIA,
}
+// TODO: handle errors in the ViewModel instead of returning a Deferred for the UI to await
@NeedsTest("Test the media check process i.e. the buttons and views")
class MediaCheckViewModel :
ViewModel(),
@@ -55,8 +56,8 @@ class MediaCheckViewModel :
val taggedFiles: Int
get() = taggedFilesCount.value
- fun tagMissing(tag: String): Job =
- viewModelScope.launch {
+ fun tagMissing(tag: String): Deferred =
+ viewModelScope.async {
progressManager.withProgress(message = MediaCheckProgress.ADDING_TAGS) {
val taggedNotes =
undoableOp {
@@ -66,26 +67,26 @@ class MediaCheckViewModel :
}
}
- fun checkMedia(): Job =
- viewModelScope.launch {
+ fun checkMedia(): Deferred =
+ viewModelScope.async {
progressManager.withProgress(message = MediaCheckProgress.CHECKING_MEDIA) {
mediaCheckResult.value = withCol { media.check() }
}
}
- fun deleteTrash(): Job =
- viewModelScope.launch {
+ fun deleteTrash(): Deferred =
+ viewModelScope.async {
progressManager.withProgress { withCol { media.emptyTrash() } }
}
- fun restoreTrash(): Job =
- viewModelScope.launch {
+ fun restoreTrash(): Deferred =
+ viewModelScope.async {
progressManager.withProgress { withCol { media.restoreTrash() } }
}
// TODO: investigate: the underlying implementation exposes progress, which we do not yet handle.
- fun deleteUnusedMedia(): Job =
- viewModelScope.launch {
+ fun deleteUnusedMedia(): Deferred =
+ viewModelScope.async {
progressManager.withProgress(message = MediaCheckProgress.DELETING_MEDIA) {
val unused = mediaCheckResult.value?.unusedList ?: listOf()
withCol { media.trashFiles(unused) }
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 1c067eddf804..345764127f64 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNoteTypesState.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNoteTypesState.kt
@@ -1,18 +1,4 @@
-/*
- * Copyright (c) 2025 lukstbit <52494258+lukstbit@users.noreply.github.com>
- *
- * This program is free software; you can redistribute it and/or modify it under
- * the terms of the GNU General Public License as published by the Free Software
- * Foundation; either version 3 of the License, or (at your option) any later
- * version.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT ANY
- * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
- * PARTICULAR PURPOSE. See the GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program. If not, see .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
package com.ichi2.anki.notetype
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 37d48c31899c..22fee2a794b5 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNoteTypesViewModel.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNoteTypesViewModel.kt
@@ -1,18 +1,4 @@
-/*
- * Copyright (c) 2025 lukstbit <52494258+lukstbit@users.noreply.github.com>
- *
- * This program is free software; you can redistribute it and/or modify it under
- * the terms of the GNU General Public License as published by the Free Software
- * Foundation; either version 3 of the License, or (at your option) any later
- * version.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT ANY
- * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
- * PARTICULAR PURPOSE. See the GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program. If not, see .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
package com.ichi2.anki.notetype
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 86fda6aea89c..681ca558fe51 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNotetypes.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNotetypes.kt
@@ -1,5 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
-// SPDX-FileCopyrightText: Copyright (c) 2022 lukstbit <52494258+lukstbit@users.noreply.github.com>
package com.ichi2.anki.notetype
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/NoteTypesAdapter.kt b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/NoteTypesAdapter.kt
index 12930202013b..5dbed590b9d7 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/NoteTypesAdapter.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/NoteTypesAdapter.kt
@@ -1,18 +1,5 @@
-/*
- * Copyright (c) 2022 lukstbit <52494258+lukstbit@users.noreply.github.com>
- *
- * This program is free software; you can redistribute it and/or modify it under
- * the terms of the GNU General Public License as published by the Free Software
- * Foundation; either version 3 of the License, or (at your option) any later
- * version.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT ANY
- * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
- * PARTICULAR PURPOSE. See the GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program. If not, see .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
+
package com.ichi2.anki.notetype
import android.content.Context
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/NotetypeUiModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/NotetypeUiModel.kt
index 4d2c4ec52e44..5df111b72502 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/NotetypeUiModel.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/NotetypeUiModel.kt
@@ -1,18 +1,5 @@
-/*
- * Copyright (c) 2022 lukstbit <52494258+lukstbit@users.noreply.github.com>
- *
- * This program is free software; you can redistribute it and/or modify it under
- * the terms of the GNU General Public License as published by the Free Software
- * Foundation; either version 3 of the License, or (at your option) any later
- * version.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT ANY
- * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
- * PARTICULAR PURPOSE. See the GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program. If not, see .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
+
package com.ichi2.anki.notetype
import anki.notetypes.NotetypeNameId
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/RenameCardTypeDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/RenameCardTypeDialog.kt
index 98f108240d85..8e5721498b82 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/RenameCardTypeDialog.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/RenameCardTypeDialog.kt
@@ -44,6 +44,7 @@ class RenameCardTypeDialog {
validator = { text ->
val name = CardTypeName.fromString(text)
when {
+ name.value.replace("\"", "").isBlank() -> ValidationResult.REJECTED
currentName == name -> ValidationResult.REJECTED
!existingNames.contains(name) -> ValidationResult.VALID
else -> ValidationResult.error(context.getString(CommonString.error_name_exists))
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 0ee56925dbb1..7bc8b3ae0623 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/pages/DeckOptions.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/pages/DeckOptions.kt
@@ -30,7 +30,6 @@ import com.ichi2.anki.libanki.DeckId
import com.ichi2.anki.libanki.updateDeckConfigsRaw
import com.ichi2.anki.observability.undoableOp
import com.ichi2.anki.settings.Prefs
-import com.ichi2.anki.utils.openUrl
import com.ichi2.anki.withProgress
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -178,8 +177,6 @@ class DeckOptions : PageFragment() {
activity?.onBackPressedDispatcher?.addCallback(this, onBackFromManual)
return object : PageWebViewClient() {
- private val ankiManualHostRegex = Regex("^docs\\.ankiweb\\.net$")
-
/** @see onWebViewReady */
override fun onShowWebView(webView: WebView) {
// no-op: handled in onVebViewReady
@@ -190,15 +187,18 @@ class DeckOptions : PageFragment() {
request: WebResourceRequest?,
): Boolean {
// #16715: ensure that the fragment can't be used for general web browsing
- val host = request?.url?.host ?: return shouldOverrideUrlLoading(view, request)
- if (request.url.toString().substringBefore('#') == view?.url?.substringBefore('#')) {
- return saveAndOptimizeReload.deferReloadIfSaving()
+ val url = request?.url ?: return true
+ if (
+ request.isForMainFrame &&
+ url.toString().substringBefore('#') == view?.url?.substringBefore('#') &&
+ saveAndOptimizeReload.deferReloadIfSaving()
+ ) {
+ return true
}
- return if (ankiManualHostRegex.matches(host)) {
- super.shouldOverrideUrlLoading(view, request)
+ return if (request.isForMainFrame && url.scheme == "https" && url.encodedAuthority == "docs.ankiweb.net") {
+ false
} else {
- openUrl(request.url)
- true
+ super.shouldOverrideUrlLoading(view, request)
}
}
}.apply {
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/pages/PageChromeClient.kt b/AnkiDroid/src/main/java/com/ichi2/anki/pages/PageChromeClient.kt
index 5ea668d65697..9561f897b8ea 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/pages/PageChromeClient.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/pages/PageChromeClient.kt
@@ -33,12 +33,13 @@ open class PageChromeClient : WebChromeClient() {
} catch (e: IllegalStateException) {
// window count is over max!!
Timber.w(e, "onJsAlert: message ignored")
- // we want to know what went wrong
- CrashReportService.sendExceptionReport("$url: $message", "onJsAlert:windowCount")
- return false
+ // Report without opening a consent dialog while the window limit is exhausted.
+ CrashReportService.sendExceptionReport(e, "onJsAlert:windowCount", additionalInfo = "$url: $message", onlyIfSilent = true)
+ // Returning false asks WebView to show another dialog, which can fail for the same reason.
+ result?.cancel()
} catch (e: WindowManager.BadTokenException) {
- Timber.w(e, "onJsAlert")
- return false
+ Timber.w(e, "onJsAlert: activity destroyed?")
+ result?.cancel()
}
return true
@@ -58,9 +59,14 @@ open class PageChromeClient : WebChromeClient() {
negativeButton(CommonString.dialog_cancel) { result?.cancel() }
cancelable(false)
}
+ } catch (e: IllegalStateException) {
+ Timber.w(e, "onJsConfirm: message ignored")
+ // Report without opening a consent dialog while the window limit is exhausted.
+ CrashReportService.sendExceptionReport(e, "onJsConfirm:windowCount", additionalInfo = "$url: $message", onlyIfSilent = true)
+ result?.cancel()
} catch (e: WindowManager.BadTokenException) {
Timber.w(e, "onJsConfirm")
- return false // unhandled - shown in WebView
+ result?.cancel()
}
return true
}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/pages/PageFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/pages/PageFragment.kt
index c95cfc6c9fe2..9f771482f6db 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/pages/PageFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/pages/PageFragment.kt
@@ -113,7 +113,7 @@ abstract class PageFragment(
}
private fun setupWebView(savedInstanceState: Bundle?) {
- val pageWebViewClient = onCreateWebViewClient(savedInstanceState)
+ val pageWebViewClient = onCreateWebViewClient(savedInstanceState).apply { serverUrl = server.baseUrl().toUri() }
webViewLayout.apply {
setAcceptThirdPartyCookies(true)
with(settings) {
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/pages/PageWebViewClient.kt b/AnkiDroid/src/main/java/com/ichi2/anki/pages/PageWebViewClient.kt
index 82156eb705f8..f7153e2cfdfc 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/pages/PageWebViewClient.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/pages/PageWebViewClient.kt
@@ -3,6 +3,7 @@
package com.ichi2.anki.pages
import android.graphics.Bitmap
+import android.net.Uri
import android.webkit.ValueCallback
import android.webkit.WebResourceRequest
import android.webkit.WebResourceResponse
@@ -10,6 +11,7 @@ import android.webkit.WebView
import androidx.core.view.isVisible
import com.google.android.material.color.MaterialColors
import com.ichi2.anki.OnPageFinishedCallback
+import com.ichi2.anki.utils.openUrl
import com.ichi2.anki.workarounds.SafeWebViewClient
import com.ichi2.anki.workarounds.SafeWebViewLayout
import com.ichi2.utils.AssetHelper.guessMimeType
@@ -22,14 +24,50 @@ import java.io.IOException
* Base WebViewClient to be used on [PageFragment]
*/
open class PageWebViewClient : SafeWebViewClient() {
+ /**
+ * The URL of the trusted local server.
+ *
+ * Allows us to distinguish our URLs from URLs served by other origins.
+ *
+ * Without this, another app could host on localhost, and we could not tell them apart:
+ *
+ * - http://127.0.0.1:12345/graphs
+ * - http://127.0.0.1:22345/graphs
+ *
+ * SECURITY: Set this via [PageFragment], this must never be inferred from page navigation.
+ */
+ internal var serverUrl: Uri? = null
+
val onPageFinishedCallbacks: MutableList = mutableListOf()
+ private fun isInternalUrl(url: Uri): Boolean =
+ serverUrl?.let { url.scheme == it.scheme && url.encodedAuthority == it.encodedAuthority } == true
+
+ /**
+ * Keeps bundled routes in this WebView and opens external HTTP(S) main-frame links outside it.
+ *
+ * WebView skips this callback for POST navigations and app-initiated [WebView.loadUrl] calls.
+ */
+ override fun shouldOverrideUrlLoading(
+ view: WebView?,
+ request: WebResourceRequest?,
+ ): Boolean {
+ val url = request?.url ?: return true
+ if (isInternalUrl(url)) {
+ return !isSvelteKitPage(url.path.orEmpty().removePrefix("/"))
+ }
+ if (request.isForMainFrame && url.scheme in listOf("http", "https")) {
+ view?.context?.openUrl(url)
+ }
+ return true
+ }
+
override fun shouldInterceptRequest(
view: WebView,
request: WebResourceRequest,
): WebResourceResponse? {
val path = request.url.path
- if (request.method != "GET" || path == null) return null
+ if (request.method != "GET" || path == null || !isInternalUrl(request.url)) return null
if (path == "/favicon.png") {
return WebResourceResponse("image/x-icon", null, ByteArrayInputStream(byteArrayOf()))
}
@@ -37,7 +75,7 @@ open class PageWebViewClient : SafeWebViewClient() {
val assetPath =
if (path.startsWith("/_app/")) {
"backend/sveltekit/app/${path.substring(6)}"
- } else if (isSvelteKitPage(path.substring(1))) {
+ } else if (isSvelteKitPage(path.removePrefix("/"))) {
"backend/sveltekit/index.html"
} else {
return null
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/AboutFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/AboutFragment.kt
index 19cfffca2ad1..7cc4db782918 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/AboutFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/AboutFragment.kt
@@ -44,6 +44,23 @@ class AboutFragment : Fragment(R.layout.fragment_about) {
@VisibleForTesting
val binding by viewBinding(FragmentAboutBinding::bind)
+ private val aboutContributorsSpannable: CharSequence
+ get() {
+ val contributorsLink = getString(R.string.link_contributors)
+ val contributingGuideLink = getString(R.string.link_contribution)
+ val strRes =
+ if (BuildConfig.SHOW_DONATE_LINKS) {
+ CommonString.about_contributors_description
+ } else {
+ CommonString.about_contributors_description_no_donate
+ }
+ return getString(
+ strRes,
+ contributorsLink,
+ contributingGuideLink,
+ ).parseAsHtml()
+ }
+
override fun onViewCreated(
view: View,
savedInstanceState: Bundle?,
@@ -65,10 +82,8 @@ class AboutFragment : Fragment(R.layout.fragment_about) {
binding.appLogo.setOnClickListener(DeveloperOptionsSecretClickListener(this))
// Contributors text
- val contributorsLink = getString(R.string.link_contributors)
- val contributingGuideLink = getString(R.string.link_contribution)
binding.contributorsDescription.apply {
- text = getString(CommonString.about_contributors_description, contributorsLink, contributingGuideLink).parseAsHtml()
+ text = aboutContributorsSpannable
movementMethod = LinkMovementMethod.getInstance()
}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/BackupLimitsSettingsFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/BackupLimitsSettingsFragment.kt
index a5e4529c776e..9c47bdb4db4d 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/BackupLimitsSettingsFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/BackupLimitsSettingsFragment.kt
@@ -1,18 +1,5 @@
-/*
- * Copyright (c) 2023 lukstbit <52494258+lukstbit@users.noreply.github.com>
- *
- * This program is free software; you can redistribute it and/or modify it under
- * the terms of the GNU General Public License as published by the Free Software
- * Foundation; either version 3 of the License, or (at your option) any later
- * version.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT ANY
- * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
- * PARTICULAR PURPOSE. See the GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program. If not, see .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
+
package com.ichi2.anki.preferences
import com.ichi2.anki.CollectionManager
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 e1e0aa2d940f..c879fc2a4517 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/DeveloperOptionsFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/DeveloperOptionsFragment.kt
@@ -45,6 +45,8 @@ class DeveloperOptionsFragment : SettingsFragment() {
override fun initSubscreen() {
setupEnableDeveloperOptions()
+ requirePreference(R.string.dev_bottom_nav_key).isVisible =
+ resources.getBoolean(R.bool.bottom_navigation_available)
// Make it possible to test crash reporting
requirePreference(R.string.pref_trigger_crash_key).setOnPreferenceClickListener {
// If we don't delete the limiter data, our test crash may not go through,
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/HeaderFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/HeaderFragment.kt
index 577a709efd02..b50a0786ee34 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/HeaderFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/HeaderFragment.kt
@@ -195,6 +195,9 @@ class HeaderFragment : SettingsFragment() {
if (BuildConfig.DEBUG) {
searchConfiguration.ignorePreference(activity.getString(R.string.developer_options_enabled_by_user_key))
}
+ if (!activity.resources.getBoolean(R.bool.bottom_navigation_available)) {
+ searchConfiguration.ignorePreference(activity.getString(R.string.dev_bottom_nav_key))
+ }
}
// From [HeaderFragment.onCreatePreferences]
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 7b74eabf2757..e9da4fd0f242 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/SettingsFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/SettingsFragment.kt
@@ -14,6 +14,7 @@ import androidx.core.view.ViewGroupCompat
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.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
@@ -82,13 +83,23 @@ abstract class SettingsFragment :
}
ViewGroupCompat.installCompatInsetsDispatch(binding.root)
+ val initialScrimVisibleHeightTrigger = binding.collapsingToolbarLayout.scrimVisibleHeightTrigger
+ var previousTopInset = 0
ViewCompat.setOnApplyWindowInsetsListener(view) { _, insets ->
val bars = insets.getInsets(systemBars() or displayCutout() or ime())
view.updatePadding(
left = bars.left,
right = bars.right,
)
- binding.appbar.updatePadding(top = bars.top)
+ val topInsetChange = bars.top - previousTopInset
+ if (topInsetChange != 0) {
+ binding.toolbar.updatePadding(top = bars.top)
+ binding.toolbar.updateLayoutParams { height += topInsetChange }
+ binding.collapsingToolbarLayout.updateLayoutParams { height += topInsetChange }
+ // The scrim must still appear at the same scroll offset, even with a tall cutout.
+ binding.collapsingToolbarLayout.scrimVisibleHeightTrigger = initialScrimVisibleHeightTrigger + bars.top
+ previousTopInset = bars.top
+ }
listView.updatePadding(bottom = bars.bottom)
insets
}
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 60d59b8beaf9..805c6fc4a939 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerFragment.kt
@@ -361,6 +361,8 @@ abstract class CardViewerFragment(
request: WebResourceRequest,
error: WebResourceError,
) {
+ Timber.w("WebView error received")
+ Timber.d("WebView error %d (%s): %s %s", error.errorCode, error.description, request.method, request.url)
viewModel.mediaErrorHandler.processFailure(request) { filename: String ->
showMediaErrorSnackbar(filename)
}
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 099d8fcdec13..3c39654ec4db 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerFragment.kt
@@ -10,13 +10,17 @@ import android.view.KeyEvent
import android.view.Menu
import android.view.MenuItem
import android.view.View
+import androidx.annotation.VisibleForTesting
import androidx.appcompat.widget.Toolbar
import androidx.core.view.ViewCompat
import androidx.core.view.doOnLayout
import androidx.core.view.isVisible
import androidx.fragment.app.viewModels
+import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.viewmodel.initializer
+import androidx.lifecycle.viewmodel.viewModelFactory
import com.google.android.material.slider.Slider
import com.ichi2.anki.CollectionManager.TR
import com.ichi2.anki.CommonString
@@ -26,6 +30,7 @@ import com.ichi2.anki.R
import com.ichi2.anki.browser.IdsFile
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.reviewer.BindingMap
@@ -35,6 +40,7 @@ import com.ichi2.anki.snackbar.BaseSnackbarBuilderProvider
import com.ichi2.anki.snackbar.SnackbarBuilder
import com.ichi2.anki.ui.internationalization.sentenceCase
import com.ichi2.anki.utils.ext.collectIn
+import com.ichi2.anki.utils.ext.require
import com.ichi2.anki.utils.ext.setIconRes
import com.ichi2.anki.utils.ext.sharedPrefs
import com.ichi2.anki.workarounds.SafeWebViewLayout
@@ -42,6 +48,8 @@ import com.ichi2.utils.performClickIfEnabled
import dev.androidbroadcast.vbpd.viewBinding
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
+import timber.log.Timber
+import java.io.IOException
class PreviewerFragment :
CardViewerFragment(R.layout.fragment_previewer),
@@ -49,8 +57,26 @@ class PreviewerFragment :
BaseSnackbarBuilderProvider,
DispatchKeyEventListener,
BindingProcessor {
- override val viewModel: PreviewerViewModel by viewModels()
- private val binding by viewBinding(FragmentPreviewerBinding::bind)
+ override val viewModel: PreviewerViewModel by viewModels {
+ viewModelFactory {
+ initializer {
+ val handle = createSavedStateHandle()
+ // Read before constructing the ViewModel, which immediately loads the first card.
+ val ids =
+ try {
+ handle.require(CARD_IDS_FILE_ARG).getIds()
+ } catch (e: IOException) {
+ throw UnavailableSelectionException(e)
+ }
+ if (ids.isEmpty()) throw UnavailableSelectionException()
+ PreviewerViewModel(handle, ids)
+ }
+ }
+ }
+
+ @VisibleForTesting
+ internal val binding by viewBinding(FragmentPreviewerBinding::bind)
+
override val webViewLayout: SafeWebViewLayout get() = binding.webViewLayout
override val baseSnackbarBuilder: SnackbarBuilder
@@ -69,6 +95,16 @@ class PreviewerFragment :
view: View,
savedInstanceState: Bundle?,
) {
+ try {
+ viewModel
+ } catch (e: UnavailableSelectionException) {
+ Timber.w(e, "Failed to read previewer IDs")
+ showThemedToast(requireContext(), CommonString.something_wrong, false)
+ // The activity may still lay out this view before finishing. Its slider has no range yet.
+ view.isVisible = false
+ requireActivity().finish()
+ return
+ }
super.onViewCreated(view, savedInstanceState)
val cardsCount = viewModel.cardsCount()
@@ -265,10 +301,15 @@ class PreviewerFragment :
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
- if (event.action != KeyEvent.ACTION_DOWN) return false
+ if (event.action != KeyEvent.ACTION_DOWN || !::bindingMap.isInitialized) return false
return bindingMap.onKeyDown(event)
}
+ /** Distinguishes selection failures from I/O failures during ViewModel construction. */
+ private class UnavailableSelectionException(
+ cause: IOException? = null,
+ ) : 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"
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerHelpers.kt b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerHelpers.kt
index 750485314776..bb6b7f22c2d9 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerHelpers.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerHelpers.kt
@@ -47,8 +47,6 @@ fun stdHtml(
val jsAssets: List =
listOf(
"backend/js/jquery.min.js",
- "backend/js/mathjax.js",
- "backend/js/vendor/mathjax/tex-chtml-full.js",
"backend/js/reviewer.js",
"scripts/ankidroid-cardviewer.js",
) + extraJsAssets
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 f51be3daa4f8..2f47242e4507 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerViewModel.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerViewModel.kt
@@ -8,7 +8,6 @@ import anki.collection.OpChanges
import com.ichi2.anki.CollectionManager.withCol
import com.ichi2.anki.Flag
import com.ichi2.anki.asyncIO
-import com.ichi2.anki.browser.IdsFile
import com.ichi2.anki.cardviewer.SingleCardSide
import com.ichi2.anki.common.annotations.NeedsTest
import com.ichi2.anki.common.destinations.NoteEditorDestination
@@ -31,6 +30,7 @@ import timber.log.Timber
class PreviewerViewModel(
savedStateHandle: SavedStateHandle,
+ @VisibleForTesting val selectedCardIds: List,
) : CardViewerViewModel(savedStateHandle),
ChangeManager.Subscriber {
val currentIndex =
@@ -42,9 +42,6 @@ class PreviewerViewModel(
val isMarked = MutableStateFlow(false)
val flag: MutableStateFlow = MutableStateFlow(Flag.NONE)
- @VisibleForTesting
- val selectedCardIds: List = savedStateHandle.require(PreviewerFragment.CARD_IDS_FILE_ARG).getIds()
-
val isBackButtonEnabled =
combine(currentIndex, showingAnswer, backSideOnly) { index, showingAnswer, isBackSideOnly ->
index != 0 || (showingAnswer && !isBackSideOnly)
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt b/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt
index 424ca9526ada..d6113e638c43 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt
@@ -88,6 +88,8 @@ import java.io.IOException
// to avoid potential OOM for large queries.
// Tracked in: https://github.com/ankidroid/Anki-Android/issues/20253
class CardContentProvider : ContentProvider() {
+ private val applicationStartup = ContentProviderStartup()
+
companion object {
// URI types
private const val NOTES = 1000
@@ -108,7 +110,7 @@ class CardContentProvider : ContentProvider() {
private const val MEDIA = 5000
private const val CARDS = 6000
private const val CARD_ID = 6001
- private val sUriMatcher = UriMatcher(UriMatcher.NO_MATCH)
+ private val uriMatcher = UriMatcher(UriMatcher.NO_MATCH)
/**
* The names of the columns returned by this content provider differ slightly from the names
@@ -119,17 +121,18 @@ class CardContentProvider : ContentProvider() {
* This is currently only "_id" (projection) vs. "id" (Anki DB). But should probably be
* applied to more columns. "MID", "USN", "MOD" are not really user friendly.
*/
- private val sDefaultNoteProjectionDBAccess = FlashCardsContract.Note.DEFAULT_PROJECTION.clone()
+ private val defaultNoteProjectionDbAccess = FlashCardsContract.Note.DEFAULT_COLUMNS
private fun sanitizeNoteProjection(projection: Array?): Array {
if (projection.isNullOrEmpty()) {
- return sDefaultNoteProjectionDBAccess
+ return defaultNoteProjectionDbAccess
}
+ val defaultColumns = FlashCardsContract.Note.DEFAULT_COLUMNS
val sanitized = ArrayList(projection.size)
for (column in projection) {
- val idx = FlashCardsContract.Note.DEFAULT_PROJECTION.indexOf(column)
+ val idx = defaultColumns.indexOf(column)
if (idx >= 0) {
- sanitized.add(sDefaultNoteProjectionDBAccess[idx])
+ sanitized.add(defaultNoteProjectionDbAccess[idx])
} else {
throw IllegalArgumentException("Unknown column $column")
}
@@ -141,7 +144,7 @@ class CardContentProvider : ContentProvider() {
fun addUri(
path: String,
code: Int,
- ) = sUriMatcher.addURI(FlashCardsContract.AUTHORITY, path, code)
+ ) = uriMatcher.addURI(FlashCardsContract.AUTHORITY, path, code)
// Here you can see all the URIs at a glance
addUri("notes", NOTES)
addUri("notes_v2", NOTES_V2)
@@ -162,9 +165,9 @@ class CardContentProvider : ContentProvider() {
addUri("cards", CARDS)
addUri("cards/#", CARD_ID)
- for (idx in sDefaultNoteProjectionDBAccess.indices) {
- if (sDefaultNoteProjectionDBAccess[idx] == FlashCardsContract.Note._ID) {
- sDefaultNoteProjectionDBAccess[idx] = "id as _id"
+ for (idx in defaultNoteProjectionDbAccess.indices) {
+ if (defaultNoteProjectionDbAccess[idx] == FlashCardsContract.Note._ID) {
+ defaultNoteProjectionDbAccess[idx] = "id as _id"
}
}
}
@@ -174,6 +177,7 @@ class CardContentProvider : ContentProvider() {
// Initialize content provider on startup.
Timber.d("CardContentProvider: onCreate")
AnkiDroidApp.makeBackendUsable(context!!)
+ applicationStartup.onProviderCreate()
return true
}
@@ -181,7 +185,7 @@ class CardContentProvider : ContentProvider() {
@Suppress("RedundantNullableReturnType")
override fun getType(uri: Uri): String? {
// Find out what data the user is requesting
- return when (sUriMatcher.match(uri)) {
+ return when (uriMatcher.match(uri)) {
NOTES_V2, NOTES -> FlashCardsContract.Note.CONTENT_TYPE
NOTES_ID -> FlashCardsContract.Note.CONTENT_ITEM_TYPE
NOTES_ID_CARDS, NOTE_TYPES_ID_EMPTY_CARDS -> FlashCardsContract.Card.CONTENT_TYPE
@@ -218,6 +222,7 @@ class CardContentProvider : ContentProvider() {
*/
private fun getColUnsafe(): Collection =
try {
+ applicationStartup.awaitCompletion()
CollectionManager.getColUnsafe()
} catch (e: StorageNotConfiguredException) {
// StorageNotConfiguredException is not supported by Parcel.writeException
@@ -238,7 +243,7 @@ class CardContentProvider : ContentProvider() {
Timber.d(getLogMessage("query", uri))
// Find out what data the user is requesting
- return when (sUriMatcher.match(uri)) {
+ return when (uriMatcher.match(uri)) {
NOTES_V2 -> {
// Search for notes using direct SQL query
val proj = sanitizeNoteProjection(projection)
@@ -267,7 +272,7 @@ class CardContentProvider : ContentProvider() {
}
NOTES_ID_CARDS -> {
val currentNote = getNoteFromUri(uri, col)
- val columns = projection ?: FlashCardsContract.Card.DEFAULT_PROJECTION
+ val columns = projection ?: FlashCardsContract.Card.DEFAULT_COLUMNS
val rv = MatrixCursor(columns, 1)
for (currentCard: Card in currentNote.cards(col)) {
addCardToCursor(currentCard, rv, col, columns)
@@ -276,13 +281,13 @@ class CardContentProvider : ContentProvider() {
}
NOTES_ID_CARDS_ORD -> {
val currentCard = getCardFromUri(uri, col)
- val columns = projection ?: FlashCardsContract.Card.DEFAULT_PROJECTION
+ val columns = projection ?: FlashCardsContract.Card.DEFAULT_COLUMNS
val rv = MatrixCursor(columns, 1)
addCardToCursor(currentCard, rv, col, columns)
rv
}
NOTE_TYPES -> {
- val columns = projection ?: FlashCardsContract.Model.DEFAULT_PROJECTION
+ val columns = projection ?: FlashCardsContract.Model.DEFAULT_COLUMNS
val rv = MatrixCursor(columns, 1)
for (noteTypeId: NoteTypeId in col.notetypes.ids()) {
addNoteTypeToCursor(noteTypeId, col.notetypes, rv, columns)
@@ -291,7 +296,7 @@ class CardContentProvider : ContentProvider() {
}
NOTE_TYPES_ID -> {
val noteTypeId = getNoteTypeIdFromUri(uri, col)
- val columns = projection ?: FlashCardsContract.Model.DEFAULT_PROJECTION
+ val columns = projection ?: FlashCardsContract.Model.DEFAULT_COLUMNS
val rv = MatrixCursor(columns, 1)
addNoteTypeToCursor(noteTypeId, col.notetypes, rv, columns)
rv
@@ -299,7 +304,7 @@ class CardContentProvider : ContentProvider() {
NOTE_TYPES_ID_TEMPLATES -> {
// Direct access note type templates
val currentNoteType = col.notetypes.get(getNoteTypeIdFromUri(uri, col))
- val columns = projection ?: FlashCardsContract.CardTemplate.DEFAULT_PROJECTION
+ val columns = projection ?: FlashCardsContract.CardTemplate.DEFAULT_COLUMNS
val rv = MatrixCursor(columns, 1)
try {
for ((ord, template) in currentNoteType!!.templates.withIndex()) {
@@ -314,7 +319,7 @@ class CardContentProvider : ContentProvider() {
// Direct access note type template with specific ID
val ord = uri.lastPathSegment!!.toInt()
val currentNoteType = col.notetypes.get(getNoteTypeIdFromUri(uri, col))
- val columns = projection ?: FlashCardsContract.CardTemplate.DEFAULT_PROJECTION
+ val columns = projection ?: FlashCardsContract.CardTemplate.DEFAULT_COLUMNS
val rv = MatrixCursor(columns, 1)
try {
val template = getTemplateFromUri(uri, col)
@@ -325,7 +330,7 @@ class CardContentProvider : ContentProvider() {
rv
}
SCHEDULE -> {
- val columns = projection ?: FlashCardsContract.ReviewInfo.DEFAULT_PROJECTION
+ val columns = projection ?: FlashCardsContract.ReviewInfo.DEFAULT_COLUMNS
val rv = MatrixCursor(columns, 1)
val selectedDeckBeforeQuery = col.decks.selected()
var deckIdOfTemporarilySelectedDeck: Long = -1
@@ -388,7 +393,7 @@ class CardContentProvider : ContentProvider() {
rv
}
DECKS -> {
- val columns = projection ?: FlashCardsContract.Deck.DEFAULT_PROJECTION
+ val columns = projection ?: FlashCardsContract.Deck.DEFAULT_COLUMNS
val allDecks = col.sched.deckDueTree()
val rv = MatrixCursor(columns, 1)
allDecks.forEach {
@@ -405,7 +410,7 @@ class CardContentProvider : ContentProvider() {
}
DECKS_ID -> {
// Direct access deck
- val columns = projection ?: FlashCardsContract.Deck.DEFAULT_PROJECTION
+ val columns = projection ?: FlashCardsContract.Deck.DEFAULT_COLUMNS
val rv = MatrixCursor(columns, 1)
val allDecks = col.sched.deckDueTree()
val desiredDeckId = uri.pathSegments[1].toLong()
@@ -417,7 +422,7 @@ class CardContentProvider : ContentProvider() {
DECK_SELECTED -> {
val id = col.decks.selected()
val name = col.decks.name(id)
- val columns = projection ?: FlashCardsContract.Deck.DEFAULT_PROJECTION
+ val columns = projection ?: FlashCardsContract.Deck.DEFAULT_COLUMNS
val rv = MatrixCursor(columns, 1)
val counts = JSONArray(listOf(col.sched.counts()))
addDeckToCursor(id, name, counts, rv, col, columns)
@@ -425,7 +430,7 @@ class CardContentProvider : ContentProvider() {
}
CARDS -> {
// Search for cards using Anki browser syntax
- val columns = projection ?: FlashCardsContract.Card.DEFAULT_PROJECTION
+ val columns = projection ?: FlashCardsContract.Card.DEFAULT_COLUMNS
val query = selection ?: ""
val cardIds =
@@ -461,7 +466,7 @@ class CardContentProvider : ContentProvider() {
CARD_ID -> {
// Direct access to specific card by ID
val cardId = uri.pathSegments[1].toLong()
- val columns = projection ?: FlashCardsContract.Card.DEFAULT_PROJECTION
+ val columns = projection ?: FlashCardsContract.Card.DEFAULT_COLUMNS
val rv = MatrixCursor(columns, 1)
val card = col.getCard(cardId)
addCardToCursor(card, rv, col, columns)
@@ -492,7 +497,7 @@ class CardContentProvider : ContentProvider() {
Timber.d(getLogMessage("update", uri))
// Find out what data the user is requesting
- val match = sUriMatcher.match(uri)
+ val match = uriMatcher.match(uri)
var updated = 0 // Number of updated entries (return value)
when (match) {
NOTES_V2, NOTES -> throw IllegalArgumentException("Not possible to update notes directly (only through data URI)")
@@ -765,7 +770,7 @@ class CardContentProvider : ContentProvider() {
Timber.d(getLogMessage("delete", uri))
val deletedCount =
- when (sUriMatcher.match(uri)) {
+ when (uriMatcher.match(uri)) {
NOTES_ID -> {
col.removeNotes(noteIds = listOf(uri.pathSegments[1].toLong())).count
}
@@ -804,7 +809,7 @@ class CardContentProvider : ContentProvider() {
// by default, #bulkInsert simply calls insert for each item in #values
// but in some cases, we want to override this behavior
- val match = sUriMatcher.match(uri)
+ val match = uriMatcher.match(uri)
val deckIdStr = uri.getQueryParameter(FlashCardsContract.Note.DECK_ID_QUERY_PARAM)
val deckId =
@@ -896,7 +901,7 @@ class CardContentProvider : ContentProvider() {
// Find out what data the user is requesting
val insertedUri =
- when (sUriMatcher.match(uri)) {
+ when (uriMatcher.match(uri)) {
NOTES -> {
/* Insert new note with specified fields and tags
*/
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/provider/ContentProviderStartup.kt b/AnkiDroid/src/main/java/com/ichi2/anki/provider/ContentProviderStartup.kt
new file mode 100644
index 000000000000..b7cb364b7771
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/provider/ContentProviderStartup.kt
@@ -0,0 +1,44 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.provider
+
+import android.os.Handler
+import android.os.Looper
+import androidx.annotation.MainThread
+import java.util.concurrent.CountDownLatch
+
+/**
+ * Keeps provider workers from accessing application state before Application.onCreate finishes.
+ *
+ * Android publishes content providers before calling `Application.onCreate`.
+ *
+ * Call [onProviderCreate] from the provider's onCreate, then [awaitCompletion] before accessing
+ * application state. This waits for synchronous application startup, not any asynchronous work
+ * it launches.
+ */
+internal class ContentProviderStartup {
+ private val initialized = CountDownLatch(1)
+
+ /** Queues completion after the current main-thread application startup message finishes. */
+ @MainThread
+ fun onProviderCreate() {
+ Handler(Looper.getMainLooper()).post { initialized.countDown() }
+ }
+
+ /**
+ * Waits for startup on worker threads. Main-thread callers continue without waiting on
+ * their own message queue.
+ *
+ * @throws IllegalStateException if interrupted while waiting; the interrupt flag is preserved.
+ */
+ fun awaitCompletion() {
+ if (Looper.myLooper() == Looper.getMainLooper()) return
+
+ try {
+ initialized.await()
+ } catch (e: InterruptedException) {
+ Thread.currentThread().interrupt()
+ throw IllegalStateException("Interrupted while waiting for AnkiDroid startup", e)
+ }
+ }
+}
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 7176fc7708d0..a91bed62c7af 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/SetDueDateDialog.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/SetDueDateDialog.kt
@@ -15,6 +15,7 @@ import android.view.WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
import android.view.inputmethod.EditorInfo
import android.widget.EditText
import androidx.annotation.CheckResult
+import androidx.annotation.MainThread
import androidx.annotation.VisibleForTesting
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
@@ -47,12 +48,10 @@ import com.ichi2.anki.libanki.CardId
import com.ichi2.anki.libanki.sched.Scheduler
import com.ichi2.anki.requireAnkiActivity
import com.ichi2.anki.scheduling.SetDueDateViewModel.Tab
-import com.ichi2.anki.servicelayer.getFSRSStatus
import com.ichi2.anki.snackbar.showSnackbar
import com.ichi2.anki.ui.internationalization.sentenceCase
import com.ichi2.anki.utils.doOnImeHidden
import com.ichi2.anki.utils.ext.launchCollectionInLifecycleScope
-import com.ichi2.anki.utils.ext.requireBoolean
import com.ichi2.anki.utils.ext.requireParcelable
import com.ichi2.anki.utils.ext.showDialogFragment
import com.ichi2.anki.utils.openUrl
@@ -66,11 +65,7 @@ import com.ichi2.utils.title
import com.ichi2.utils.titleWithHelpIcon
import dev.androidbroadcast.vbpd.viewBinding
import kotlinx.coroutines.Deferred
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.currentCoroutineContext
-import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
import java.io.IOException
@@ -101,9 +96,6 @@ class SetDueDateDialog : AnalyticsDialogFragment() {
val cardIds: List
get() = requireArguments().requireParcelable(ARG_IDS_FILE).getIds()
- val fsrsEnabled: Boolean
- get() = requireArguments().requireBoolean(ARG_FSRS)
-
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val cardIds =
@@ -117,7 +109,7 @@ class SetDueDateDialog : AnalyticsDialogFragment() {
return
}
- viewModel.init(cardIds, fsrsEnabled)
+ viewModel.init(cardIds)
Timber.d("Set due date dialog: %d card(s)", cardIds.size)
this.initialRotation = getScreenRotation()
@@ -272,50 +264,37 @@ class SetDueDateDialog : AnalyticsDialogFragment() {
companion object {
const val ARG_IDS_FILE = "ARGS_IDS_FILE"
- const val ARG_FSRS = "ARGS_FSRS"
const val MAX_WIDTH_DP = 450f
private const val RESULT_SUBMIT_DUE_DATE = "SubmitDueDate"
- // Only accessed on the main thread.
- private val pendingActivities = mutableSetOf()
-
/** Keeps the existing dialog and its input when another request arrives. */
- suspend fun show(
+ @MainThread
+ fun show(
activity: FragmentActivity,
cardIds: List,
- ) = withContext(Dispatchers.Main.immediate) {
- if (activity.supportFragmentManager.fragments.any { it is SetDueDateDialog } || !pendingActivities.add(activity)) {
- Timber.d("Ignoring 'set due date' request: dialog is already open or being prepared")
- return@withContext
- }
- try {
- val dialog = newInstance(activity.externalCacheDir ?: activity.cacheDir, cardIds)
- activity.showDialogFragment(dialog)
- } finally {
- pendingActivities.remove(activity)
+ ) {
+ if (activity.supportFragmentManager.fragments.any { it is SetDueDateDialog }) {
+ Timber.d("Ignoring 'set due date' request: dialog is already open")
+ return
}
+ val dialog = newInstance(activity.externalCacheDir ?: activity.cacheDir, cardIds)
+ activity.showDialogFragment(dialog)
}
@VisibleForTesting
@CheckResult
- suspend fun newInstance(
+ fun newInstance(
cacheDir: File,
cardIds: List,
- ): SetDueDateDialog {
- val fsrsEnabled = getFSRSStatus() ?: false.also { Timber.w("FSRS Status error") }
- // getFSRSStatus swallows cancellation, so check for it explicitly: the dialog would
- // fail to show and leave behind a file which only onDismiss removes
- currentCoroutineContext().ensureActive()
- return SetDueDateDialog().apply {
+ ): SetDueDateDialog =
+ SetDueDateDialog().apply {
arguments =
Bundle().apply {
putParcelable(ARG_IDS_FILE, IdsFile(cacheDir, cardIds, "set-due-date"))
- putBoolean(ARG_FSRS, fsrsEnabled)
}
Timber.i("Showing 'set due date' dialog for %d cards", cardIds.size)
}
- }
}
class DueDateStateAdapter(
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/SetDueDateViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/SetDueDateViewModel.kt
index 1ddee60333b1..eea40cefc9fe 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/SetDueDateViewModel.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/SetDueDateViewModel.kt
@@ -12,7 +12,10 @@ import com.ichi2.anki.libanki.CardId
import com.ichi2.anki.libanki.CardType
import com.ichi2.anki.libanki.sched.SetDueDateDays
import com.ichi2.anki.observability.undoableOp
+import com.ichi2.anki.servicelayer.getFSRSStatus
+import kotlinx.coroutines.Job
import kotlinx.coroutines.async
+import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
@@ -47,6 +50,8 @@ class SetDueDateViewModel : ViewModel() {
val fsrsEnabled: StateFlow
field = MutableStateFlow(null)
+ private var loadJob: Job? = null
+
/** Whether the user can set [updateIntervalToMatchDueDate] */
val canSetUpdateIntervalToMatchDueDate
// this only makes sense in SM-2, where the due date does not directly impact the next
@@ -112,19 +117,26 @@ class SetDueDateViewModel : ViewModel() {
*/
val currentInterval = MutableStateFlow(null)
- fun init(
- cardIds: List,
- fsrsEnabled: Boolean,
- ) {
+ fun init(cardIds: List) {
+ loadJob?.cancel()
this.cardIds = cardIds
- Timber.d("fsrsEnabled : %b", fsrsEnabled)
- if (fsrsEnabled) {
- Timber.d("updateIntervalToMatchDueDate forced to true: FSRS is enabled")
- updateIntervalToMatchDueDate = true
- }
- this.fsrsEnabled.value = fsrsEnabled
+ fsrsEnabled.value = null
refreshIsValid()
+ loadJob =
+ viewModelScope.launch {
+ val enabled = getFSRSStatus() ?: false.also { Timber.w("FSRS Status error") }
+ // getFSRSStatus swallows cancellation. An earlier load must not update a reopened dialog.
+ ensureActive()
+ Timber.d("fsrsEnabled : %b", enabled)
+ if (enabled) {
+ Timber.d("updateIntervalToMatchDueDate forced to true: FSRS is enabled")
+ updateIntervalToMatchDueDate = true
+ }
+ fsrsEnabled.value = enabled
+ refreshIsValid()
+ }
+
initCurrentInterval(cardIds)
}
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 2de3d10a0d6e..879aaec4c884 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/ThrowableFilterService.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/servicelayer/ThrowableFilterService.kt
@@ -18,6 +18,7 @@ package com.ichi2.anki.servicelayer
import androidx.annotation.VisibleForTesting
import com.ichi2.anki.exception.StorageAccessException
+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
@@ -93,6 +94,7 @@ object ThrowableFilterService {
when (t) {
is BackendNetworkException -> return true
is BackendSyncException -> return true
+ is BackendSchedulerUpgradeRequiredException -> return true
is StorageAccessException -> return true
}
Timber.v("exceptionIsUnwanted - exception was wanted")
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 069388c1cf33..3b2a89ebf967 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/settings/Prefs.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/settings/Prefs.kt
@@ -15,6 +15,7 @@ import com.ichi2.anki.cardviewer.TapGestureMode
import com.ichi2.anki.common.preferences.AnimationPreferences
import com.ichi2.anki.common.preferences.sharedPrefs
import com.ichi2.anki.common.utils.isRunningAsUnitTest
+import com.ichi2.anki.preferences.SharedPreferencesProvider
import com.ichi2.anki.settings.enums.AppTheme
import com.ichi2.anki.settings.enums.DayTheme
import com.ichi2.anki.settings.enums.FrameStyle
@@ -28,15 +29,21 @@ import kotlin.reflect.KProperty
// TODO move this to `com.ichi2.anki.preferences`
// after the UI classes of that package are moved to `com.ichi2.anki.ui.preferences`
-object Prefs : PrefsRepository(AnkiDroidApp.sharedPrefs(), AnkiDroidApp.appResources)
+// Use a provider to avoid retaining SharedPreferences from a previous Robolectric test.
+object Prefs : PrefsRepository(AnkiDroidApp.sharedPreferencesProvider, AnkiDroidApp.appResources)
// TODO: enforce that `preferences.xml` is used
-open class PrefsRepository(
- val sharedPrefs: SharedPreferences,
+open class PrefsRepository protected constructor(
+ private val preferences: SharedPreferencesProvider,
private val resources: Resources,
) : AnimationPreferences {
+ constructor(sharedPrefs: SharedPreferences, resources: Resources) : this(SharedPreferencesProvider { sharedPrefs }, resources)
+
constructor(context: Context) : this(context.sharedPrefs(), context.resources)
+ val sharedPrefs: SharedPreferences
+ get() = preferences.sharedPrefs()
+
@VisibleForTesting
fun key(
@StringRes resId: Int,
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/DownloadSpeedCalculator.kt b/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/DownloadSpeedCalculator.kt
new file mode 100644
index 000000000000..c9a05f1f8a12
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/DownloadSpeedCalculator.kt
@@ -0,0 +1,61 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright (c) 2026 Colby Cabrera
+
+package com.ichi2.anki.shareddeck
+
+/**
+ * Smooths the download speed so it doesn't jump around between polls.
+ *
+ * @param alpha how much the newest sample counts, from 0 to 1
+ * @param stallTimeoutMillis how long the byte count can stay still before the speed starts to drop
+ */
+class DownloadSpeedCalculator(
+ private val alpha: Double = 0.5,
+ private val stallTimeoutMillis: Long = 5_000,
+) {
+ init {
+ require(alpha in 0.0..1.0) { "Alpha must be between 0.0 and 1.0" }
+ }
+
+ private var lastSmoothedSpeed: Double = 0.0
+ private var lastBytesDownloaded: Long = 0
+ private var lastTimeChecked: Long? = null
+
+ /** Returns the smoothed speed in bytes per second, given the total bytes downloaded so far. */
+ fun update(
+ downloadedBytes: Long,
+ currentTimeMillis: Long,
+ ): Double {
+ val lastTime = lastTimeChecked
+ if (lastTime != null && currentTimeMillis > lastTime) {
+ val timeDiff = currentTimeMillis - lastTime
+ val bytesDiff = downloadedBytes - lastBytesDownloaded
+
+ if (bytesDiff == 0L) {
+ if (timeDiff >= stallTimeoutMillis) lastSmoothedSpeed *= 1 - alpha
+ return lastSmoothedSpeed
+ }
+
+ if (bytesDiff >= 0) {
+ val instantSpeed = (bytesDiff * 1000.0) / timeDiff
+ lastSmoothedSpeed =
+ if (lastSmoothedSpeed == 0.0 && instantSpeed > 0) {
+ // Initialize with the first non-zero speed to avoid a slow ramp up from 0.
+ instantSpeed
+ } else {
+ (lastSmoothedSpeed * (1 - alpha)) + (instantSpeed * alpha)
+ }
+ }
+ }
+ lastBytesDownloaded = downloadedBytes
+ lastTimeChecked = currentTimeMillis
+ return lastSmoothedSpeed
+ }
+
+ /** Starts over for a new download. */
+ fun reset() {
+ lastSmoothedSpeed = 0.0
+ lastBytesDownloaded = 0
+ lastTimeChecked = null
+ }
+}
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 e3280accb96b..6989f7a1ffa7 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksDownloadFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksDownloadFragment.kt
@@ -13,43 +13,45 @@ import android.database.Cursor
import android.os.Bundle
import android.os.Handler
import android.os.Looper
+import android.view.LayoutInflater
import android.view.View
+import android.view.ViewGroup
import android.webkit.CookieManager
import androidx.activity.OnBackPressedCallback
import androidx.annotation.VisibleForTesting
import androidx.appcompat.app.AlertDialog
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.platform.ViewCompositionStrategy
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.core.net.toFile
import androidx.core.net.toUri
-import androidx.core.view.ViewCompat
-import androidx.core.view.WindowInsetsCompat
-import androidx.core.view.isVisible
-import androidx.core.view.updatePadding
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
-import com.ichi2.anki.CollectionManager.TR
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.google.android.material.color.MaterialColors
import com.ichi2.anki.CommonString
import com.ichi2.anki.IntentHandler
import com.ichi2.anki.R
import com.ichi2.anki.common.android.AnkiBroadcastReceiver
import com.ichi2.anki.common.crashreporting.CrashReportService
+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.databinding.FragmentSharedDecksDownloadBinding
import com.ichi2.anki.shareddeck.SharedDecksActivity.Companion.DOWNLOAD_FILE
import com.ichi2.anki.snackbar.showSnackbar
-import com.ichi2.anki.utils.ext.launchCollectionInLifecycleScope
import com.ichi2.anki.utils.openUrl
+import com.ichi2.compose.theme.AnkiDroidTheme
import com.ichi2.utils.ImportUtils
import com.ichi2.utils.create
-import dev.androidbroadcast.vbpd.viewBinding
import timber.log.Timber
import java.io.File
import java.net.URLConnection
+import com.ichi2.anki.common.android.R as CommonR
/**
* Used when a download is captured from AnkiWeb shared decks WebView.
@@ -58,15 +60,17 @@ import java.net.URLConnection
* Only one download is supported at a time, since importing multiple decks
* simultaneously is not supported.
*/
-class SharedDecksDownloadFragment : Fragment(R.layout.fragment_shared_decks_download) {
- @VisibleForTesting
- internal val binding by viewBinding(FragmentSharedDecksDownloadBinding::bind)
+class SharedDecksDownloadFragment : Fragment() {
private val viewModel: SharedDecksDownloadViewModel by viewModels()
+ private val fileToBeDownloaded by lazy { arguments?.getSerializableCompat(DOWNLOAD_FILE)!! }
+
private var downloadId: Long = 0
private var fileName: String? = null
+ private var completedFile: File? = null
+
private var handler: Handler = Handler(Looper.getMainLooper())
private var isProgressCheckerRunning = false
@@ -136,37 +140,42 @@ class SharedDecksDownloadFragment : Fragment(R.layout.fragment_shared_decks_down
}
}
+ override fun onCreateView(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ savedInstanceState: Bundle?,
+ ): View =
+ ComposeView(requireContext()).apply {
+ setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed)
+ setBackgroundColor(MaterialColors.getColor(this, CommonR.attr.appBarColor))
+ setContent {
+ AnkiDroidTheme {
+ val state by viewModel.uiState.collectAsStateWithLifecycle()
+ SharedDecksDownloadScreen(
+ state = state,
+ onCancelClick = {
+ Timber.i("Cancel download button clicked")
+ showCancelConfirmationDialog()
+ },
+ onImportClick = {
+ Timber.i("Import deck button clicked")
+ completedFile?.let { openDownloadedDeck(requireContext(), it) }
+ },
+ onTryAgainClick = ::retryDownload,
+ onOpenInBrowserClick = ::openInBrowser,
+ )
+ }
+ }
+ }
+
override fun onViewCreated(
view: View,
savedInstanceState: Bundle?,
) {
super.onViewCreated(view, savedInstanceState)
- setupEdgeToEdge(view)
- viewModel.uiState.launchCollectionInLifecycleScope(::render)
-
- val fileToBeDownloaded = arguments?.getSerializableCompat(DOWNLOAD_FILE)!!
downloadManager = (activity as SharedDecksActivity).downloadManager
-
registerDownloadReceiver()
downloadFile(fileToBeDownloaded)
-
- binding.cancelDownloadButton.setOnClickListener {
- Timber.i("Cancel download button clicked")
- showCancelConfirmationDialog()
- }
-
- binding.openInWebBrowserButton.setOnClickListener {
- Timber.i("'Open in Browser' clicked")
- downloadManager.remove(downloadId)
- openUrl(requireContext().getDeckPageUri(fileToBeDownloaded.url).toUri())
- parentFragmentManager.popBackStack()
- }
-
- binding.tryDownloadAgainButton.setOnClickListener {
- Timber.i("Try again button clicked, retry downloading of deck")
- downloadManager.remove(downloadId)
- downloadFile(fileToBeDownloaded)
- }
}
override fun onDestroyView() {
@@ -193,38 +202,17 @@ class SharedDecksDownloadFragment : Fragment(R.layout.fragment_shared_decks_down
)
}
- private fun render(state: SharedDecksDownloadUiState) {
- binding.downloadingTitle.text = state.fileName?.let { getString(CommonString.downloading_file, it) }
- binding.downloadPercentageText.text =
- when {
- state.phase == DownloadPhase.Failed -> getString(CommonString.download_failed)
- // 19812: DownloadManager could not be queried, so all we can say is that it is running
- state.percent == null -> TR.syncDownloadingFromAnkiweb()
- else -> getString(CommonString.percentage, formatDownloadPercent(state.percent))
- }
- binding.downloadProgressBar.progress = state.percent?.toInt() ?: 0
- binding.checkNetworkInfoText.isVisible = state.isWaitingForNetwork
- binding.cancelDownloadButton.isVisible = state.phase == DownloadPhase.Downloading
- binding.importSharedDeckButton.isVisible = state.phase == DownloadPhase.Complete
- binding.tryDownloadAgainButton.isVisible = state.phase == DownloadPhase.Failed
- binding.openInWebBrowserButton.isVisible = state.phase == DownloadPhase.Failed
+ private fun retryDownload() {
+ Timber.i("Try again button clicked, retry downloading of deck")
+ downloadManager.remove(downloadId)
+ downloadFile(fileToBeDownloaded)
}
- /** Applies edge-to-edge insets for the screen */
- private fun setupEdgeToEdge(view: View) {
- // systemBars (not just statusBars) so a landscape 3-button navigation bar,
- // which is a side inset, is also cleared
- ViewCompat.setOnApplyWindowInsetsListener(view) { root, insets ->
- val bars =
- insets.getInsets(
- WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.displayCutout(),
- )
- // top inset on the root only: the strip behind the status bar shows the
- // root's app bar color, while the other sides keep the content background
- root.updatePadding(top = bars.top)
- binding.downloadContent.updatePadding(left = bars.left, right = bars.right, bottom = bars.bottom)
- insets
- }
+ private fun openInBrowser() {
+ Timber.i("'Open in Browser' clicked")
+ downloadManager.remove(downloadId)
+ openUrl(requireContext().getDeckPageUri(fileToBeDownloaded.url).toUri())
+ parentFragmentManager.popBackStack()
}
/**
@@ -248,7 +236,7 @@ class SharedDecksDownloadFragment : Fragment(R.layout.fragment_shared_decks_down
val downloadRequest = generateDeckDownloadRequest(fileToBeDownloaded, currentFileName)
- binding.importSharedDeckButton.setOnClickListener(null)
+ completedFile = null
// Store unique download ID to be used when onReceiveBroadcast() of AnkiBroadcastReceiver gets executed.
downloadId = downloadManager.enqueue(downloadRequest)
fileName = currentFileName
@@ -375,10 +363,7 @@ class SharedDecksDownloadFragment : Fragment(R.layout.fragment_shared_decks_down
return
}
- binding.importSharedDeckButton.setOnClickListener {
- Timber.i("Import deck button clicked")
- openDownloadedDeck(requireContext(), downloadedFile)
- }
+ completedFile = downloadedFile
// the progress checker can stop before it sees 100%, so complete it here
viewModel.onDownloadComplete()
@@ -469,6 +454,7 @@ class SharedDecksDownloadFragment : Fragment(R.layout.fragment_shared_decks_down
viewModel.onProgress(
downloadedBytes = it.getLong(it.getColumnIndex(DownloadManager.COLUMN_BYTES_DOWNLOADED_SO_FAR)),
totalBytes = it.getLong(it.getColumnIndex(DownloadManager.COLUMN_TOTAL_SIZE_BYTES)),
+ timeMillis = TimeManager.time.intTimeMS(),
)
val columnIndexForStatus = it.getColumnIndex(DownloadManager.COLUMN_STATUS)
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksDownloadScreen.kt b/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksDownloadScreen.kt
new file mode 100644
index 000000000000..17f170ec0531
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksDownloadScreen.kt
@@ -0,0 +1,437 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright (c) 2026 Ashish Yadav
+// SPDX-FileCopyrightText: Copyright (c) 2026 Colby Cabrera
+
+package com.ichi2.anki.shareddeck
+
+import android.text.format.DateUtils
+import android.text.format.Formatter
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.layout.WindowInsetsSides
+import androidx.compose.foundation.layout.displayCutout
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.only
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.systemBars
+import androidx.compose.foundation.layout.union
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.layout.windowInsetsPadding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+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.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.LiveRegionMode
+import androidx.compose.ui.semantics.liveRegion
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import androidx.compose.ui.unit.dp
+import com.ichi2.anki.CollectionManager.TR
+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.ui.preview.ThemePreviews
+
+private val MaxContentWidth = 600.dp
+private val ProgressRingSize = 240.dp
+private val ProgressRingStroke = 10.dp
+private val InfoBadgeSize = 28.dp
+
+/** Stateless download screen. State lives in [SharedDecksDownloadViewModel]. */
+@Composable
+fun SharedDecksDownloadScreen(
+ state: SharedDecksDownloadUiState,
+ onCancelClick: () -> Unit,
+ onImportClick: () -> Unit,
+ onTryAgainClick: () -> Unit,
+ onOpenInBrowserClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val insets = WindowInsets.systemBars.union(WindowInsets.displayCutout)
+ BoxWithConstraints(
+ modifier =
+ modifier
+ .fillMaxSize()
+ .windowInsetsPadding(insets.only(WindowInsetsSides.Top))
+ .background(MaterialTheme.colorScheme.background)
+ .windowInsetsPadding(insets.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom)),
+ contentAlignment = Alignment.TopCenter,
+ ) {
+ Column(
+ modifier =
+ Modifier
+ .widthIn(max = MaxContentWidth)
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState())
+ .heightIn(min = maxHeight),
+ ) {
+ Header(fileName = state.fileName)
+ Spacer(Modifier.weight(1f))
+ ProgressRing(
+ state = state,
+ modifier = Modifier.padding(vertical = MaterialTheme.dimensions.screenEdge),
+ )
+ Spacer(Modifier.weight(1f))
+ StatCards(state)
+ InfoCard()
+ if (state.isWaitingForNetwork) NetworkWarning()
+ Actions(
+ phase = state.phase,
+ onCancelClick = onCancelClick,
+ onImportClick = onImportClick,
+ onTryAgainClick = onTryAgainClick,
+ onOpenInBrowserClick = onOpenInBrowserClick,
+ )
+ }
+ }
+}
+
+/** Title naming the file being downloaded. */
+@Composable
+private fun Header(fileName: String?) {
+ val edge = MaterialTheme.dimensions.screenEdge
+ Text(
+ text = fileName?.let { stringResource(CommonString.downloading_file, it) }.orEmpty(),
+ style = MaterialTheme.typography.headlineMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.fillMaxWidth().padding(top = edge, start = edge, end = edge),
+ )
+}
+
+/** Progress ring with the percentage and size in the middle. */
+@Composable
+private fun ProgressRing(
+ state: SharedDecksDownloadUiState,
+ modifier: Modifier = Modifier,
+) {
+ Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
+ val percent = state.percent
+ if (percent == null) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(ProgressRingSize),
+ strokeWidth = ProgressRingStroke,
+ trackColor = MaterialTheme.colorScheme.surfaceVariant,
+ )
+ } else {
+ CircularProgressIndicator(
+ progress = { (percent / 100f).coerceIn(0f, 1f) },
+ modifier = Modifier.size(ProgressRingSize),
+ strokeWidth = ProgressRingStroke,
+ trackColor = MaterialTheme.colorScheme.surfaceVariant,
+ )
+ }
+ Column(horizontalAlignment = Alignment.CenterHorizontally) {
+ when {
+ state.phase == DownloadPhase.Failed -> RingLabel(stringResource(CommonString.download_failed))
+ percent == null -> RingLabel(TR.syncDownloadingFromAnkiweb())
+ else -> {
+ PercentageText(percent)
+ downloadSizeText(state.downloadedBytes, state.totalBytes)?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(top = MaterialTheme.dimensions.space50),
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+/** Text in the ring when there's no percentage to show. */
+@Composable
+private fun RingLabel(text: String) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Center,
+ modifier = Modifier.widthIn(max = ProgressRingSize).padding(horizontal = MaterialTheme.dimensions.space400),
+ )
+}
+
+/** The percentage in the ring, with a smaller % sign. */
+@Composable
+private fun PercentageText(percent: Float) {
+ val text = stringResource(CommonString.percentage, formatDownloadPercent(percent))
+ val style = MaterialTheme.typography.displayLarge
+ val sign = text.indexOf('%')
+ Text(
+ text =
+ buildAnnotatedString {
+ append(text)
+ if (sign >= 0) addStyle(SpanStyle(fontSize = style.fontSize * 0.6f), sign, sign + 1)
+ },
+ style = style,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurface,
+ textAlign = TextAlign.Center,
+ )
+}
+
+/** Size downloaded out of the total, or null while neither is known. */
+@Composable
+private fun downloadSizeText(
+ downloadedBytes: Long,
+ totalBytes: Long,
+): String? {
+ val context = LocalContext.current
+ if (totalBytes <= 0 && downloadedBytes <= 0) return null
+ val downloaded = Formatter.formatFileSize(context, downloadedBytes)
+ if (totalBytes <= 0) return downloaded
+ return stringResource(CommonString.progress_amount_bytes, downloaded, Formatter.formatFileSize(context, totalBytes))
+}
+
+/** Speed and time left, side by side. */
+@Composable
+private fun StatCards(state: SharedDecksDownloadUiState) {
+ val dimensions = MaterialTheme.dimensions
+ Row(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(start = dimensions.screenEdge, end = dimensions.screenEdge, bottom = dimensions.space150),
+ horizontalArrangement = Arrangement.spacedBy(dimensions.space150),
+ ) {
+ StatCard(
+ label = stringResource(CommonString.download_speed_label),
+ value = speedText(state.speedBytesPerSecond, state.downloadedBytes),
+ modifier = Modifier.weight(1f),
+ )
+ StatCard(
+ label = stringResource(CommonString.download_time_left_label),
+ value = timeLeftText(state.secondsRemaining),
+ modifier = Modifier.weight(1f),
+ )
+ }
+}
+
+/** Card with a small label above its value. */
+@Composable
+private fun StatCard(
+ label: String,
+ value: String,
+ modifier: Modifier = Modifier,
+) {
+ TonalCard(modifier = modifier) {
+ Column(modifier = Modifier.padding(MaterialTheme.dimensions.sectionGap)) {
+ Text(
+ text = label.uppercase(),
+ style = MaterialTheme.typography.labelMedium,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ Text(
+ text = value,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.padding(top = MaterialTheme.dimensions.space50),
+ )
+ }
+ }
+}
+
+/** Download speed, or a placeholder until the first bytes arrive. */
+@Composable
+private fun speedText(
+ bytesPerSecond: Long,
+ downloadedBytes: Long,
+): String {
+ if (bytesPerSecond <= 0 && downloadedBytes <= 0) return stringResource(CommonString.download_speed_unknown)
+ val speed = Formatter.formatFileSize(LocalContext.current, bytesPerSecond.coerceAtLeast(0))
+ return stringResource(CommonString.download_speed_unit, speed)
+}
+
+/** Time left, or a placeholder until it can be estimated. */
+@Composable
+private fun timeLeftText(secondsRemaining: Long?): String =
+ secondsRemaining?.let(DateUtils::formatElapsedTime) ?: stringResource(CommonString.download_time_unknown)
+
+/** Card saying it's fine to use other apps while the download runs. */
+@Composable
+private fun InfoCard() {
+ val dimensions = MaterialTheme.dimensions
+ TonalCard(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(start = dimensions.screenEdge, end = dimensions.screenEdge, bottom = dimensions.screenEdge),
+ ) {
+ Row(
+ modifier = Modifier.padding(dimensions.sectionGap),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Box(
+ modifier =
+ Modifier
+ .size(InfoBadgeSize)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.tertiary)
+ .padding(dimensions.space50),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ painter = painterResource(R.drawable.ic_done),
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.onTertiary,
+ )
+ }
+ Text(
+ text = stringResource(CommonString.deck_download_progress_message),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.padding(start = dimensions.sectionGap),
+ )
+ }
+ }
+}
+
+/** Flat card shared by the stat cards and the info card. */
+@Composable
+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,
+ )
+}
+
+/** Warning shown while the download waits for a network connection. */
+@Composable
+private fun NetworkWarning() {
+ val dimensions = MaterialTheme.dimensions
+ Text(
+ text = stringResource(CommonString.check_network),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.error,
+ textAlign = TextAlign.Center,
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = dimensions.sectionGap, vertical = dimensions.space100)
+ .semantics { liveRegion = LiveRegionMode.Polite },
+ )
+}
+
+/** Buttons for the current [DownloadPhase]. */
+@Composable
+private fun Actions(
+ phase: DownloadPhase,
+ onCancelClick: () -> Unit,
+ onImportClick: () -> Unit,
+ onTryAgainClick: () -> Unit,
+ onOpenInBrowserClick: () -> Unit,
+) {
+ val dimensions = MaterialTheme.dimensions
+ FlowRow(
+ modifier =
+ Modifier
+ .fillMaxWidth()
+ .padding(start = dimensions.screenEdge, end = dimensions.screenEdge, bottom = dimensions.screenEdge),
+ horizontalArrangement = Arrangement.spacedBy(dimensions.sectionGap, Alignment.CenterHorizontally),
+ verticalArrangement = Arrangement.spacedBy(dimensions.space100),
+ ) {
+ when (phase) {
+ DownloadPhase.Downloading ->
+ FilledTonalButton(
+ onClick = onCancelClick,
+ colors =
+ ButtonDefaults.filledTonalButtonColors(
+ containerColor = MaterialTheme.colorScheme.errorContainer,
+ contentColor = MaterialTheme.colorScheme.onErrorContainer,
+ ),
+ ) {
+ Text(stringResource(CommonString.cancel_download))
+ }
+ DownloadPhase.Complete ->
+ Button(onClick = onImportClick) {
+ Text(stringResource(CommonString.import_deck))
+ }
+ DownloadPhase.Failed -> {
+ FilledTonalButton(onClick = onTryAgainClick) {
+ Text(stringResource(CommonString.try_again))
+ }
+ TextButton(onClick = onOpenInBrowserClick) {
+ Text(stringResource(CommonString.open_in_browser))
+ }
+ }
+ }
+ }
+}
+
+private class DownloadStateProvider : PreviewParameterProvider {
+ private val downloading =
+ SharedDecksDownloadUiState(
+ fileName = "Japanese Core 2000.apkg",
+ percent = 42.7f,
+ downloadedBytes = 19_200_000,
+ totalBytes = 44_900_000,
+ speedBytesPerSecond = 1_200_000,
+ secondsRemaining = 21,
+ )
+
+ override val values =
+ sequenceOf(
+ downloading,
+ downloading.copy(isWaitingForNetwork = true),
+ downloading.copy(phase = DownloadPhase.Complete, percent = 100f, downloadedBytes = 44_900_000),
+ downloading.copy(phase = DownloadPhase.Failed, percent = 0f),
+ )
+}
+
+@ThemePreviews
+@Composable
+private fun SharedDecksDownloadScreenPreview(
+ @PreviewParameter(DownloadStateProvider::class) state: SharedDecksDownloadUiState,
+) {
+ AnkiDroidTheme {
+ SharedDecksDownloadScreen(
+ state = state,
+ onCancelClick = {},
+ onImportClick = {},
+ onTryAgainClick = {},
+ onOpenInBrowserClick = {},
+ )
+ }
+}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksDownloadViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksDownloadViewModel.kt
index 379d3a9f32e4..b754e58d0b6f 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksDownloadViewModel.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksDownloadViewModel.kt
@@ -15,19 +15,33 @@ class SharedDecksDownloadViewModel : ViewModel() {
val uiState: StateFlow
field = MutableStateFlow(SharedDecksDownloadUiState())
+ private val speedCalculator = DownloadSpeedCalculator()
+
/** A download was enqueued. Also called on a retry, which clears the failed state. */
fun onDownloadStarted(fileName: String) {
Timber.i("download started")
+ speedCalculator.reset()
uiState.value = SharedDecksDownloadUiState(fileName = fileName)
}
fun onProgress(
downloadedBytes: Long,
totalBytes: Long,
+ timeMillis: Long,
) {
// DownloadManager reports -1 for a download whose size it does not know yet
val percent = if (totalBytes > 0) abs(downloadedBytes * 100f / totalBytes) else 0f
- uiState.update { it.copy(percent = percent) }
+ val speed = speedCalculator.update(downloadedBytes, timeMillis).toLong()
+ val secondsRemaining = if (speed > 0 && totalBytes > 0) (totalBytes - downloadedBytes) / speed else null
+ uiState.update {
+ it.copy(
+ percent = percent,
+ downloadedBytes = downloadedBytes,
+ totalBytes = totalBytes,
+ speedBytesPerSecond = speed,
+ secondsRemaining = secondsRemaining,
+ )
+ }
}
/** The [android.app.DownloadManager] query failed, so progress cannot be reported. */
@@ -41,12 +55,12 @@ class SharedDecksDownloadViewModel : ViewModel() {
fun onDownloadComplete() {
Timber.i("download complete")
- uiState.update { it.copy(phase = DownloadPhase.Complete, percent = 100f) }
+ uiState.update { it.copy(phase = DownloadPhase.Complete, percent = 100f, secondsRemaining = 0) }
}
fun onDownloadFailed() {
Timber.i("download failed")
- uiState.update { it.copy(phase = DownloadPhase.Failed, percent = 0f) }
+ uiState.update { it.copy(phase = DownloadPhase.Failed, percent = 0f, speedBytesPerSecond = 0, secondsRemaining = null) }
}
}
@@ -55,6 +69,12 @@ data class SharedDecksDownloadUiState(
val fileName: String? = null,
/** `null` when progress could not be read, in which case the screen says only that a download is running. */
val percent: Float? = 0f,
+ val downloadedBytes: Long = 0,
+ /** -1 until [android.app.DownloadManager] knows the size of the file. */
+ val totalBytes: Long = -1,
+ val speedBytesPerSecond: Long = 0,
+ /** `null` until there is enough data to estimate it. */
+ val secondsRemaining: Long? = null,
val phase: DownloadPhase = DownloadPhase.Downloading,
val isWaitingForNetwork: Boolean = false,
)
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/startup/BackendSetup.kt b/AnkiDroid/src/main/java/com/ichi2/anki/startup/BackendSetup.kt
new file mode 100644
index 000000000000..8382ce0cd4c7
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/startup/BackendSetup.kt
@@ -0,0 +1,32 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.startup
+
+import android.content.Context
+import android.system.Os
+import timber.log.Timber
+import java.io.File
+import java.io.IOException
+
+/** Ensures SQLite and Rust have a usable TMPDIR, falling back to the app cache (#19545). */
+internal fun configureBackendTemporaryDirectory(context: Context) {
+ val inheritedTmpDir = Os.getenv("TMPDIR")
+
+ // handle a TMPDIR which is unusable
+ val overwriteExistingValue =
+ inheritedTmpDir.isNullOrEmpty() || !isUsableTemporaryDirectory(inheritedTmpDir)
+
+ Os.setenv("TMPDIR", context.cacheDir.path, overwriteExistingValue)
+}
+
+/** Checks that temporary files can be created and removed in [path]. */
+private fun isUsableTemporaryDirectory(path: String): Boolean =
+ try {
+ File.createTempFile("ankidroid-tmpdir-", null, File(path)).delete()
+ } catch (e: IOException) {
+ Timber.w(e, "Unable to use inherited TMPDIR: %s", path)
+ false
+ } catch (e: SecurityException) {
+ Timber.w(e, "Unable to access inherited TMPDIR: %s", path)
+ false
+ }
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 0753a0ba9ea7..7f5f82d09f61 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
@@ -6,7 +6,6 @@ import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
-import android.text.InputType
import android.view.KeyEvent
import android.view.MenuItem
import android.view.View
@@ -14,7 +13,6 @@ import android.view.WindowManager
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputMethodManager
import android.webkit.WebView
-import android.widget.EditText
import android.widget.FrameLayout
import android.widget.LinearLayout
import androidx.annotation.VisibleForTesting
@@ -82,6 +80,7 @@ import com.ichi2.anki.utils.ext.showDialogFragment
import com.ichi2.anki.utils.ext.window
import com.ichi2.anki.workarounds.SafeWebViewLayout
import com.ichi2.themes.Themes
+import com.ichi2.ui.TypeAnswerEditText
import com.ichi2.utils.ViewGroupUtils.setRenderWorkaround
import com.ichi2.utils.dp
import com.ichi2.utils.show
@@ -279,22 +278,17 @@ class ReviewerFragment :
val isHtmlTypeAnswerEnabled = Prefs.isHtmlTypeAnswerEnabled
lifecycleScope.launch {
val autoFocusTypeAnswer = Prefs.autoFocusTypeAnswer
- // Default `inputType` from the layout, restored when `{{nosuggest}}` is unused (#10352)
- val defaultInputType = binding.typeAnswerEditText.inputType
/**
- * Sync `inputType` and `imeHintLocales` on the answer `EditText` to match
+ * Sync keyboard suggestions and `imeHintLocales` on the answer field to match
* [typeInAnswer]. Returns `true` if anything changed (caller should `restartInput()`).
+ *
+ * @see TypeAnswerEditText.noSuggest
*/
- fun EditText.syncTypeAnswerProperties(typeInAnswer: TypeAnswer): Boolean {
- // #10352: TYPE_NULL is used by 'Reword' to remove all suggestions. This works better
- // than a password as the keyboard won't suggest to open the password manager
- // other methods did not work for GBoard
- val targetInputType =
- if (typeInAnswer.noSuggest) InputType.TYPE_NULL else defaultInputType
+ fun TypeAnswerEditText.syncTypeAnswerProperties(typeInAnswer: TypeAnswer): Boolean {
var changed = false
- if (inputType != targetInputType) {
- inputType = targetInputType
+ if (noSuggest != typeInAnswer.noSuggest) {
+ noSuggest = typeInAnswer.noSuggest
changed = true
}
if (imeHintLocales != typeInAnswer.imeHintLocales) {
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/BrushAdapter.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/BrushAdapter.kt
index 8ce3947d3eae..b4dfbc49723e 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/BrushAdapter.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/BrushAdapter.kt
@@ -22,12 +22,12 @@ class BrushAdapter(
private val onBrushClick: (View, Int) -> Unit,
private val onBrushLongClick: (Int) -> Unit,
) : RecyclerView.Adapter() {
- private var brushes: List = emptyList()
+ private var brushes: List = emptyList()
private var activeIndex: Int = -1
private var isEraserActive: Boolean = false
fun updateData(
- newBrushes: List,
+ newBrushes: List,
newActiveIndex: Int,
eraserActive: Boolean,
) {
@@ -41,12 +41,15 @@ class BrushAdapter(
newActiveIndex: Int,
eraserActive: Boolean,
) {
+ if (activeIndex == newActiveIndex && isEraserActive == eraserActive) return
val oldIndex = activeIndex
activeIndex = newActiveIndex
isEraserActive = eraserActive
if (oldIndex in brushes.indices) notifyItemChanged(oldIndex)
- if (newActiveIndex in brushes.indices) notifyItemChanged(newActiveIndex)
+ if (newActiveIndex in brushes.indices && newActiveIndex != oldIndex) {
+ notifyItemChanged(newActiveIndex)
+ }
}
override fun getItemCount(): Int = brushes.size
@@ -74,7 +77,7 @@ class BrushAdapter(
private val button: MaterialButton = itemView as MaterialButton
fun bind(
- brush: BrushInfo,
+ brush: WhiteboardTool.Brush,
isSelected: Boolean,
) = button.apply {
isCheckable = true
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardFragment.kt
index 4315da56bc67..51913866d04c 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardFragment.kt
@@ -86,10 +86,19 @@ class WhiteboardFragment :
observeViewModel(binding.whiteboardView)
setupDoubleBackPress()
+ binding.whiteboardView.isStylusButtonOverriding = viewModel.isStylusOverriding
binding.whiteboardView.onNewPath = viewModel::addPath
binding.whiteboardView.onEraseGestureStart = viewModel::startPathEraseGesture
binding.whiteboardView.onEraseGestureMove = viewModel::erasePathsToPoint
binding.whiteboardView.onEraseGestureEnd = viewModel::endPathEraseGesture
+ binding.whiteboardView.onStylusButtonStateChanged = { isPressed ->
+ viewModel.setStylusButtonPressed(isPressed)
+ binding.whiteboardView.activeTool = viewModel.activeTool.value
+ binding.whiteboardToolbar.updateSelection(
+ viewModel.activeBrushIndex.value,
+ viewModel.activeTool.value is WhiteboardTool.Eraser,
+ )
+ }
}
private fun setupDoubleBackPress() {
@@ -153,7 +162,7 @@ class WhiteboardFragment :
toolbar.undoButton.setOnClickListener { viewModel.undo() }
toolbar.redoButton.setOnClickListener { viewModel.redo() }
toolbar.eraserButton.setOnClickListener {
- if (viewModel.isEraserActive.value) {
+ if (viewModel.activeTool.value is WhiteboardTool.Eraser) {
toolbar.eraserButton.isChecked = true
if (eraserPopup?.isShowing == true) {
eraserPopup?.dismiss()
@@ -166,7 +175,7 @@ class WhiteboardFragment :
}
toolbar.onBrushClick = { view, index ->
- if (viewModel.activeBrushIndex.value == index && !viewModel.isEraserActive.value) {
+ if (viewModel.activeBrushIndex.value == index && viewModel.activeTool.value !is WhiteboardTool.Eraser) {
showBrushConfigurationPopup(view, index)
} else {
viewModel.setActiveBrush(index)
@@ -223,39 +232,25 @@ class WhiteboardFragment :
viewModel.paths.onEach(whiteboardView::setHistory).launchIn(viewLifecycleOwner.lifecycleScope)
- combine(
- viewModel.brushColor,
- viewModel.activeStrokeWidth,
- ) { color, width ->
- whiteboardView.setCurrentBrush(color, width)
- }.launchIn(viewLifecycleOwner.lifecycleScope)
-
- combine(
- viewModel.isEraserActive,
- viewModel.eraserMode,
- viewModel.eraserDisplayWidth,
- ) { isActive, mode, width ->
- whiteboardView.isEraserActive = isActive
- toolbar.eraserButton.updateState(isActive, mode, width)
- whiteboardView.eraserMode = mode
- if (!isActive) {
- eraserPopup?.dismiss()
- }
- }.launchIn(viewLifecycleOwner.lifecycleScope)
-
- viewModel.brushes
- .onEach { brushesInfo ->
- toolbar.setBrushes(brushesInfo, viewModel.activeBrushIndex.value, viewModel.isEraserActive.value)
+ viewModel.activeTool
+ .onEach { tool ->
+ whiteboardView.activeTool = tool
+ val isEraser = tool is WhiteboardTool.Eraser
+ toolbar.updateSelection(viewModel.activeBrushIndex.value, isEraser)
+ toolbar.eraserButton.updateState(isEraser, viewModel.eraser.mode, viewModel.eraser.width)
+ if (!isEraser) {
+ eraserPopup?.dismiss()
+ }
}.launchIn(viewLifecycleOwner.lifecycleScope)
viewModel.activeBrushIndex
- .onEach {
- toolbar.updateSelection(it, viewModel.isEraserActive.value)
+ .onEach { index ->
+ toolbar.updateSelection(index, viewModel.activeTool.value is WhiteboardTool.Eraser)
}.launchIn(viewLifecycleOwner.lifecycleScope)
- viewModel.isEraserActive
- .onEach {
- toolbar.updateSelection(viewModel.activeBrushIndex.value, it)
+ viewModel.brushes
+ .onEach { brushesInfo ->
+ toolbar.setBrushes(brushesInfo, viewModel.activeBrushIndex.value, viewModel.activeTool.value is WhiteboardTool.Eraser)
}.launchIn(viewLifecycleOwner.lifecycleScope)
viewModel.isStylusOnlyMode
@@ -284,7 +279,7 @@ class WhiteboardFragment :
*/
private fun showAddColorDialog() {
requireContext()
- .showColorPickerDialog(viewModel.brushColor.value) { color ->
+ .showColorPickerDialog(viewModel.currentBrushColor) { color ->
Timber.i("Added brush with color ${color.toRGBAHex()}")
viewModel.addBrush(color)
}
@@ -375,7 +370,7 @@ class WhiteboardFragment :
*/
private fun showChangeColorDialog() {
requireContext()
- .showColorPickerDialog(viewModel.brushColor.value) { color ->
+ .showColorPickerDialog(viewModel.currentBrushColor) { color ->
viewModel.updateBrushColor(color)
brushConfigPopup?.dismiss()
}
@@ -388,16 +383,16 @@ class WhiteboardFragment :
val inflater = LayoutInflater.from(requireContext())
val eraserWidthBinding = PopupEraserOptionsBinding.inflate(inflater)
- eraserWidthBinding.eraserWidthSlider.value = viewModel.eraserDisplayWidth.value
+ eraserWidthBinding.eraserWidthSlider.value = viewModel.eraser.width
eraserWidthBinding.eraserWidthSlider.addOnChangeListener { _, value, fromUser ->
- if (fromUser) viewModel.setActiveStrokeWidth(value)
+ if (fromUser) viewModel.setEraserStrokeWidth(value)
}
eraserWidthBinding.eraserWidthSlider.setLabelFormatter { value: Float ->
value.roundToInt().toString()
}
eraserWidthBinding.eraserModeToggleGroup.clearOnButtonCheckedListeners()
- when (viewModel.eraserMode.value) {
+ when (viewModel.eraser.mode) {
EraserMode.STROKE -> eraserWidthBinding.eraserModeToggleGroup.check(R.id.eraser_mode_stroke)
EraserMode.INK -> eraserWidthBinding.eraserModeToggleGroup.check(R.id.eraser_mode_ink)
}
@@ -406,14 +401,11 @@ class WhiteboardFragment :
when (checkedId) {
R.id.eraser_mode_stroke -> {
viewModel.setEraserMode(EraserMode.STROKE)
- eraserWidthBinding.eraserWidthSlider.value =
- viewModel.strokeEraserStrokeWidth.value
+ eraserWidthBinding.eraserWidthSlider.value = viewModel.eraser.strokeEraserWidth
}
-
R.id.eraser_mode_ink -> {
viewModel.setEraserMode(EraserMode.INK)
- eraserWidthBinding.eraserWidthSlider.value =
- viewModel.inkEraserStrokeWidth.value
+ eraserWidthBinding.eraserWidthSlider.value = viewModel.eraser.inkWidth
}
}
}
@@ -422,7 +414,7 @@ class WhiteboardFragment :
eraserPopup = PopupWindow(eraserWidthBinding.root, 280.dp.toPx(requireContext()), ViewGroup.LayoutParams.WRAP_CONTENT, true)
eraserPopup?.elevation = 8f
eraserPopup?.setOnDismissListener {
- binding.whiteboardToolbar.updateSelection(viewModel.activeBrushIndex.value, viewModel.isEraserActive.value)
+ binding.whiteboardToolbar.updateSelection(viewModel.activeBrushIndex.value, viewModel.activeTool.value is WhiteboardTool.Eraser)
eraserPopup = null
}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardRepository.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardRepository.kt
index 472d5f619f6d..d809a06c41fb 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardRepository.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardRepository.kt
@@ -5,17 +5,6 @@ package com.ichi2.anki.ui.windows.reviewer.whiteboard
import android.content.SharedPreferences
import android.graphics.Color
import androidx.core.content.edit
-import com.ichi2.utils.toRGBAHex
-
-/**
- * Holds the configuration for a single brush.
- */
-data class BrushInfo(
- val color: Int,
- val width: Float,
-) {
- override fun toString(): String = "BrushInfo(color=${color.toRGBAHex()}, width=${"%.1f".format(width)})"
-}
/**
* Repository for handling data operations, specifically for saving and loading
@@ -25,14 +14,14 @@ class WhiteboardRepository(
private val sharedPreferences: SharedPreferences,
) {
fun saveBrushes(
- brushes: List,
+ brushes: List,
isDarkMode: Boolean,
) {
val key = if (isDarkMode) KEY_BRUSHES_DARK else KEY_BRUSHES_LIGHT
sharedPreferences.edit { putString(key, brushes.toPreferenceString()) }
}
- fun loadBrushes(isDarkMode: Boolean): List {
+ fun loadBrushes(isDarkMode: Boolean): List {
val key = if (isDarkMode) KEY_BRUSHES_DARK else KEY_BRUSHES_LIGHT
val saved = sharedPreferences.getString(key, null)
return if (saved.isNullOrEmpty()) {
@@ -85,14 +74,14 @@ class WhiteboardRepository(
get() = sharedPreferences.getBoolean(KEY_IS_TOOLBAR_SHOWN, true)
set(value) = sharedPreferences.edit { putBoolean(KEY_IS_TOOLBAR_SHOWN, value) }
- private fun List.toPreferenceString(): String = this.joinToString(",") { "${it.color}|${it.width}" }
+ private fun List.toPreferenceString(): String = this.joinToString(",") { "${it.color}|${it.width}" }
- private fun String.fromPreferenceString(): List =
+ private fun String.fromPreferenceString(): List =
this.split(',').mapNotNull {
val parts = it.split('|')
if (parts.size == 2) {
try {
- BrushInfo(color = parts[0].toInt(), width = parts[1].toFloat())
+ WhiteboardTool.Brush(color = parts[0].toInt(), width = parts[1].toFloat())
} catch (_: NumberFormatException) {
null
}
@@ -115,14 +104,14 @@ class WhiteboardRepository(
const val DEFAULT_STROKE_WIDTH = 10f
const val DEFAULT_ERASER_WIDTH = 30f
- fun getDefaultBrushes(isDarkMode: Boolean): List =
+ fun getDefaultBrushes(isDarkMode: Boolean): List =
if (isDarkMode) {
listOf(
- BrushInfo(color = Color.WHITE, width = DEFAULT_STROKE_WIDTH),
+ WhiteboardTool.Brush(color = Color.WHITE, width = DEFAULT_STROKE_WIDTH),
)
} else {
listOf(
- BrushInfo(color = Color.BLACK, width = DEFAULT_STROKE_WIDTH),
+ WhiteboardTool.Brush(color = Color.BLACK, width = DEFAULT_STROKE_WIDTH),
)
}
}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardToolbar.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardToolbar.kt
index b4e33a8c8d8e..042d2d7eaf16 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardToolbar.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardToolbar.kt
@@ -142,7 +142,7 @@ class WhiteboardToolbar : LinearLayout {
* Updates the data in the RecyclerView adapter.
*/
fun setBrushes(
- brushes: List,
+ brushes: List,
activeIndex: Int,
isEraserActive: Boolean,
) {
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardView.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardView.kt
index 3ffd73d5ced7..9bdd06431f74 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardView.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardView.kt
@@ -30,10 +30,35 @@ class WhiteboardView : View {
var onEraseGestureStart: ((Float, Float) -> Unit)? = null
var onEraseGestureMove: ((Float, Float) -> Unit)? = null
var onEraseGestureEnd: (() -> Unit)? = null
- var isEraserActive: Boolean = false
- var eraserMode: EraserMode = EraserMode.INK
+ var onStylusButtonStateChanged: ((Boolean) -> Unit)? = null
+
+ var activeTool: WhiteboardTool = WhiteboardTool.Brush(Color.BLACK, WhiteboardRepository.DEFAULT_STROKE_WIDTH)
+ set(value) {
+ field = value
+ if (value is WhiteboardTool.Brush) {
+ isStylusButtonOverriding = false
+ }
+ when (value) {
+ is WhiteboardTool.Brush -> {
+ currentPaint.color = value.color
+ currentPaint.strokeWidth = value.width
+ }
+ is WhiteboardTool.Eraser -> {
+ eraserPreviewPaint.strokeWidth = value.width
+ }
+ }
+ invalidate()
+ }
+
var isStylusOnlyMode: Boolean = false
+ /**
+ * Whether the stylus button is currently overriding the active [WhiteboardTool.Brush]
+ * with the eraser tool.
+ */
+ var isStylusButtonOverriding = false
+ internal set
+
private val currentPath = SmoothPath()
private val currentPaint =
Paint().apply {
@@ -97,11 +122,9 @@ class WhiteboardView : View {
// Draw the committed history
canvas.drawBitmap(bufferBitmap, 0f, 0f, canvasPaint)
- // Draw the live preview path for the current gesture
- if (isEraserActive) {
+ if (activeTool is WhiteboardTool.Eraser) {
canvas.drawPath(currentPath, eraserPreviewPaint)
} else {
- // Draw the normal brush or pixel eraser preview
canvas.drawPath(currentPath, currentPaint)
}
}
@@ -119,20 +142,20 @@ class WhiteboardView : View {
return multiTouchDetector.onTouchEvent(event)
}
- if (isStylusOnlyMode && event.getToolType(0) != MotionEvent.TOOL_TYPE_STYLUS) {
+ if (isStylusOnlyMode && !event.isStylus) {
return false
}
val touchX = event.x
val touchY = event.y
- val isPathEraser = isEraserActive && eraserMode == EraserMode.STROKE
- when (event.action) {
+ when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
+ updateStylusButtonState(event)
isDrawing = true
hasMoved = false
currentPath.moveTo(touchX, touchY)
- if (isPathEraser) {
+ if (isStrokeEraser) {
onEraseGestureStart?.invoke(touchX, touchY)
}
invalidate()
@@ -140,29 +163,48 @@ class WhiteboardView : View {
MotionEvent.ACTION_MOVE -> {
if (!isDrawing) return false
- hasMoved = true
- currentPath.drawAlong(event)
- if (isPathEraser) {
- onEraseGestureMove?.invoke(touchX, touchY)
+ val isButtonPressed = event.hasStylusButtonPressed()
+ if (isButtonPressed != isStylusButtonOverriding && isToolChange(isButtonPressed)) {
+ hasMoved = true
+ currentPath.drawAlong(event)
+ if (isStrokeEraser) {
+ onEraseGestureMove?.invoke(touchX, touchY)
+ }
+ handleMidStrokeToolChange(touchX, touchY, isButtonPressed)
+ } else {
+ hasMoved = true
+ currentPath.drawAlong(event)
+ if (isStrokeEraser) {
+ onEraseGestureMove?.invoke(touchX, touchY)
+ }
+ invalidate()
+ }
+ }
+ MotionEvent.ACTION_BUTTON_PRESS,
+ MotionEvent.ACTION_BUTTON_RELEASE,
+ -> {
+ if (!isDrawing) {
+ updateStylusButtonState(event)
+ return true
+ }
+ val isButtonPressed = event.hasStylusButtonPressed()
+ if (isButtonPressed != isStylusButtonOverriding && isToolChange(isButtonPressed)) {
+ handleMidStrokeToolChange(touchX, touchY, isButtonPressed)
}
- invalidate()
}
MotionEvent.ACTION_UP -> {
if (!isDrawing) return false
- if (isPathEraser) {
- onEraseGestureEnd?.invoke()
- } else {
- if (!hasMoved) {
- // A single tap. Add a tiny line segment to ensure it has a non-zero length,
- // which makes it more robust for path operations.
- currentPath.lineTo(touchX + 0.2f, touchY + 0.2f)
- }
- onNewPath?.invoke(currentPath.clone())
- }
- // Reset the path for the next gesture
+ commitCurrentStroke(touchX, touchY)
currentPath.reset()
isDrawing = false
+ updateStylusButtonState(event)
+ invalidate()
+ }
+ MotionEvent.ACTION_CANCEL -> {
+ currentPath.reset()
+ isDrawing = false
+ updateStylusButtonState(event)
invalidate()
}
else -> return false
@@ -170,27 +212,92 @@ class WhiteboardView : View {
return true
}
- /**
- * Replaces the current drawing history with a new set of actions and redraws the buffer.
- */
- fun setHistory(actions: List) {
- history = actions
- redrawHistory()
+ override fun onHoverEvent(event: MotionEvent): Boolean {
+ updateStylusButtonState(event)
+ return super.onHoverEvent(event)
}
+ private val isStrokeEraser: Boolean
+ get() = activeTool is WhiteboardTool.Eraser && (activeTool as WhiteboardTool.Eraser).mode == EraserMode.STROKE
+
/**
- * Configures the paint for the live drawing preview based on the current tool.
+ * Determines whether [isButtonPressed] represents a tool change.
+ *
+ * A button press is a tool change only if the active tool is a [WhiteboardTool.Brush].
+ * A button release is a tool change only if the button was actively overriding a brush.
*/
- fun setCurrentBrush(
- color: Int,
- strokeWidth: Float,
+ private fun isToolChange(isButtonPressed: Boolean): Boolean =
+ if (isButtonPressed) {
+ activeTool is WhiteboardTool.Brush
+ } else {
+ isStylusButtonOverriding
+ }
+
+ private fun commitCurrentStroke(
+ touchX: Float,
+ touchY: Float,
) {
- currentPaint.strokeWidth = strokeWidth
- currentPaint.xfermode = null
- currentPaint.color = color
+ if (isStrokeEraser) {
+ onEraseGestureEnd?.invoke()
+ } else {
+ if (!hasMoved) {
+ // A single tap. Add a tiny line segment to ensure it has a non-zero length,
+ // which makes it more robust for path operations.
+ currentPath.lineTo(touchX + 0.2f, touchY + 0.2f)
+ }
+ onNewPath?.invoke(currentPath.clone())
+ }
+ }
+
+ private fun handleMidStrokeToolChange(
+ touchX: Float,
+ touchY: Float,
+ isButtonPressed: Boolean,
+ ) {
+ commitCurrentStroke(touchX, touchY)
+ currentPath.reset()
+ hasMoved = false
+ currentPath.moveTo(touchX, touchY)
+
+ isStylusButtonOverriding = isButtonPressed
+ onStylusButtonStateChanged?.invoke(isButtonPressed)
- // Configure the stroke eraser's preview paint separately
- eraserPreviewPaint.strokeWidth = strokeWidth
+ if (isStrokeEraser) {
+ onEraseGestureStart?.invoke(touchX, touchY)
+ }
+ invalidate()
+ }
+
+ private val MotionEvent.isStylus: Boolean
+ get() =
+ (0 until pointerCount).any {
+ val toolType = getToolType(it)
+ toolType == MotionEvent.TOOL_TYPE_STYLUS || toolType == MotionEvent.TOOL_TYPE_ERASER
+ }
+
+ private fun MotionEvent.hasStylusButtonPressed(): Boolean {
+ if (!isStylus) return false
+ val stylusButtons =
+ MotionEvent.BUTTON_STYLUS_PRIMARY or
+ MotionEvent.BUTTON_STYLUS_SECONDARY or
+ MotionEvent.BUTTON_SECONDARY
+ return (buttonState and stylusButtons) != 0
+ }
+
+ private fun updateStylusButtonState(event: MotionEvent) {
+ val isButtonPressed = event.hasStylusButtonPressed()
+ if (isButtonPressed != isStylusButtonOverriding && isToolChange(isButtonPressed)) {
+ isStylusButtonOverriding = isButtonPressed
+ onStylusButtonStateChanged?.invoke(isButtonPressed)
+ }
+ }
+
+ /**
+ * Replaces the current drawing history with a new set of actions and redraws the buffer.
+ */
+ fun setHistory(actions: List) {
+ history = actions
+ redrawHistory()
}
/**
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardViewModel.kt
index a20a4c7448ce..846ee84c67ef 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardViewModel.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardViewModel.kt
@@ -15,9 +15,9 @@ import com.ichi2.anki.common.utils.ext.indexOfOrNull
import com.ichi2.anki.preferences.reviewer.WhiteboardAction
import com.ichi2.anki.reviewer.BindingProcessor
import com.ichi2.anki.reviewer.ReviewerBinding
+import com.ichi2.utils.toRGBAHex
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
-import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
@@ -43,6 +43,26 @@ enum class EraserMode { INK, STROKE }
*/
enum class ToolbarAlignment { LEFT, RIGHT, BOTTOM }
+sealed interface WhiteboardTool {
+ val width: Float
+
+ data class Brush(
+ val color: Int,
+ override val width: Float,
+ ) : WhiteboardTool {
+ override fun toString(): String = "Brush(color=${color.toRGBAHex()}, width=${"%.1f".format(width)})"
+ }
+
+ data class Eraser(
+ val mode: EraserMode = EraserMode.INK,
+ val inkWidth: Float = WhiteboardRepository.DEFAULT_ERASER_WIDTH,
+ val strokeEraserWidth: Float = WhiteboardRepository.DEFAULT_ERASER_WIDTH,
+ ) : WhiteboardTool {
+ override val width: Float
+ get() = if (mode == EraserMode.INK) inkWidth else strokeEraserWidth
+ }
+}
+
/**
* Represents a command that can be undone and redone.
*/
@@ -76,22 +96,28 @@ class WhiteboardViewModel(
val canUndo = undoStack.map { it.isNotEmpty() }.stateIn(viewModelScope, SharingStarted.Eagerly, false)
val canRedo = redoStack.map { it.isNotEmpty() }.stateIn(viewModelScope, SharingStarted.Eagerly, false)
- // State for brushes
- val brushes = MutableStateFlow>(emptyList())
+ val brushes = MutableStateFlow>(emptyList())
val activeBrushIndex = MutableStateFlow(0)
+ var eraser: WhiteboardTool.Eraser = WhiteboardTool.Eraser()
+ private set
+ val activeTool =
+ MutableStateFlow(
+ WhiteboardTool.Brush(Color.BLACK, WhiteboardRepository.DEFAULT_STROKE_WIDTH),
+ )
+
+ val currentBrushColor: Int
+ get() =
+ (activeTool.value as? WhiteboardTool.Brush)?.color
+ ?: brushes.value.getOrNull(activeBrushIndex.value)?.color
+ ?: Color.BLACK
- // State for the currently active tool's properties
- val inkEraserStrokeWidth = MutableStateFlow(WhiteboardRepository.DEFAULT_ERASER_WIDTH)
- val strokeEraserStrokeWidth = MutableStateFlow(WhiteboardRepository.DEFAULT_ERASER_WIDTH)
-
- val brushColor = MutableStateFlow(Color.BLACK)
- val activeStrokeWidth = MutableStateFlow(WhiteboardRepository.DEFAULT_STROKE_WIDTH)
- val isEraserActive = MutableStateFlow(false)
- val eraserMode = MutableStateFlow(EraserMode.INK)
val isStylusOnlyMode = MutableStateFlow(false)
val toolbarAlignment = MutableStateFlow(ToolbarAlignment.BOTTOM)
val isToolbarShown = MutableStateFlow(true)
+ var isStylusOverriding = false
+ internal set
+
/**
* Whether the whiteboard is hosted in a "drawing" flow (e.g. multimedia drawing
* capture) rather than the reviewer. In drawing mode the host owns the back
@@ -99,11 +125,6 @@ class WhiteboardViewModel(
*/
val isDrawing = MutableStateFlow(false)
- val eraserDisplayWidth =
- combine(eraserMode, inkEraserStrokeWidth, strokeEraserStrokeWidth) { mode, inkWidth, strokeWidth ->
- if (mode == EraserMode.INK) inkWidth else strokeWidth
- }.stateIn(viewModelScope, SharingStarted.Eagerly, WhiteboardRepository.DEFAULT_ERASER_WIDTH)
-
private var eraserLastX = 0.0f
private var eraserLastY = 0.0f
private val pathsErasedInCurrentGesture = mutableListOf()
@@ -116,17 +137,21 @@ class WhiteboardViewModel(
fun loadState(isDarkMode: Boolean) {
this.isDarkMode = isDarkMode
brushes.value = repository.loadBrushes(isDarkMode)
- inkEraserStrokeWidth.value = repository.inkEraserWidth
- strokeEraserStrokeWidth.value = repository.strokeEraserWidth
- eraserMode.value = repository.eraserMode
+ eraser =
+ WhiteboardTool.Eraser(
+ mode = repository.eraserMode,
+ inkWidth = repository.inkEraserWidth,
+ strokeEraserWidth = repository.strokeEraserWidth,
+ )
isStylusOnlyMode.value = repository.stylusOnlyMode
toolbarAlignment.value = repository.toolbarAlignment
isToolbarShown.value = repository.isToolbarShown
- val lastActiveIndex = repository.loadLastActiveBrushIndex(isDarkMode)
+ val savedBrushIndex = repository.loadLastActiveBrushIndex(isDarkMode)
+ activeBrushIndex.value = savedBrushIndex
- if (!isEraserActive.value) {
- setActiveBrush(lastActiveIndex)
+ if (activeTool.value !is WhiteboardTool.Eraser) {
+ setActiveBrush(savedBrushIndex)
}
}
@@ -134,12 +159,19 @@ class WhiteboardViewModel(
* Adds a new completed path to the drawing history.
*/
fun addPath(path: Path) {
- val isPixelEraser = isEraserActive.value && eraserMode.value == EraserMode.INK
+ val tool = activeTool.value
+ val isPixelEraser = tool is WhiteboardTool.Eraser && tool.mode == EraserMode.INK
+ val strokeWidth = tool.width
+ val color =
+ when (tool) {
+ is WhiteboardTool.Brush -> tool.color
+ is WhiteboardTool.Eraser -> Color.BLACK
+ }
val newAction =
DrawingAction(
path,
- brushColor.value,
- activeStrokeWidth.value,
+ color,
+ strokeWidth,
isPixelEraser,
)
paths.update { it + newAction }
@@ -175,9 +207,11 @@ class WhiteboardViewModel(
var pathWasErased = false
val pathsToEvaluate = remainingPaths.filter { it !in pathsErasedInCurrentGesture && !it.isEraser }
+ val eraserRadius =
+ ((activeTool.value as? WhiteboardTool.Eraser)?.width ?: eraser.width) / 2
for (action in pathsToEvaluate) {
- if (isPathIntersectingWithSegment(action, x, y, eraserLastX, eraserLastY, activeStrokeWidth.value / 2)) {
+ if (isPathIntersectingWithSegment(action, x, y, eraserLastX, eraserLastY, eraserRadius)) {
remainingPaths.remove(action)
pathsErasedInCurrentGesture.add(action)
pathWasErased = true
@@ -335,46 +369,61 @@ class WhiteboardViewModel(
fun setActiveBrush(index: Int) {
val brush = brushes.value.getOrNull(index) ?: return
- isEraserActive.value = false
+ isStylusOverriding = false
activeBrushIndex.value = index
repository.saveLastActiveBrushIndex(index, isDarkMode)
- brushColor.value = brush.color
- activeStrokeWidth.value = brush.width
+ activeTool.value = brush
}
/**
* Toggles the eraser tool on or off.
*/
fun enableEraser() {
- isEraserActive.value = true
- activeStrokeWidth.value = eraserDisplayWidth.value
+ isStylusOverriding = false
+ activeTool.value = eraser
}
/**
* Toggles between the eraser and the last active brush.
*/
fun toggleEraser() {
- if (isEraserActive.value) {
+ if (activeTool.value is WhiteboardTool.Eraser) {
setActiveBrush(activeBrushIndex.value)
} else {
enableEraser()
}
}
+ /**
+ * Updates the eraser state and keeps the active tool in sync if it is currently an eraser.
+ */
+ private fun updateEraser(transform: (WhiteboardTool.Eraser) -> WhiteboardTool.Eraser) {
+ val updated = transform(eraser)
+ eraser = updated
+ if (activeTool.value is WhiteboardTool.Eraser) {
+ activeTool.value = updated
+ }
+ }
+
/**
* Sets the eraser mode (pixel or path).
*/
fun setEraserMode(mode: EraserMode) {
- eraserMode.value = mode
repository.eraserMode = mode
- if (isEraserActive.value) {
- activeStrokeWidth.value =
- if (mode == EraserMode.INK) {
- inkEraserStrokeWidth.value
- } else {
- strokeEraserStrokeWidth.value
- }
+ updateEraser { it.copy(mode = mode) }
+ }
+
+ /**
+ * Sets the stroke width for the eraser tool.
+ */
+ fun setEraserStrokeWidth(newWidth: Float) {
+ if (eraser.mode == EraserMode.INK) {
+ repository.inkEraserWidth = newWidth
+ updateEraser { it.copy(inkWidth = newWidth) }
+ } else {
+ repository.strokeEraserWidth = newWidth
+ updateEraser { it.copy(strokeEraserWidth = newWidth) }
}
}
@@ -382,26 +431,20 @@ class WhiteboardViewModel(
* Sets the stroke width for the currently active tool (brush or eraser).
*/
fun setActiveStrokeWidth(newWidth: Float) {
- if (isEraserActive.value) {
- if (eraserMode.value == EraserMode.INK) {
- inkEraserStrokeWidth.value = newWidth
- repository.inkEraserWidth = newWidth
- } else {
- strokeEraserStrokeWidth.value = newWidth
- repository.strokeEraserWidth = newWidth
+ when (activeTool.value) {
+ is WhiteboardTool.Eraser -> setEraserStrokeWidth(newWidth)
+ is WhiteboardTool.Brush -> {
+ val index = activeBrushIndex.value
+ val updatedBrushes = brushes.value.replaceAt(index) { it.copy(width = newWidth) }
+ brushes.value = updatedBrushes
+ repository.saveBrushes(updatedBrushes, isDarkMode)
+ activeTool.value = updatedBrushes[index]
}
- } else {
- // Update the width of the active brush
- val activeIndex = activeBrushIndex.value
- val updatedBrushes = brushes.value.replaceAt(activeIndex) { it.copy(width = newWidth) }
- brushes.value = updatedBrushes
- repository.saveBrushes(updatedBrushes, isDarkMode)
}
- activeStrokeWidth.value = newWidth
}
@CheckResult
- fun List.replaceAt(
+ private fun List.replaceAt(
index: Int,
replace: (T) -> T,
): List = this.mapIndexed { i, value -> if (i == index) replace(value) else value }
@@ -411,19 +454,21 @@ class WhiteboardViewModel(
*/
fun updateBrushColor(newColor: Int) {
Timber.i("Updating brush color to %d", newColor)
- val activeIndex = activeBrushIndex.value
- val updatedBrushes = brushes.value.replaceAt(activeIndex) { it.copy(color = newColor) }
+ val index = activeBrushIndex.value
+ val updatedBrushes = brushes.value.replaceAt(index) { it.copy(color = newColor) }
brushes.value = updatedBrushes
repository.saveBrushes(brushes.value, isDarkMode)
- brushColor.value = newColor
+ if (activeTool.value is WhiteboardTool.Brush) {
+ activeTool.value = updatedBrushes[index]
+ }
}
/**
* Adds a new brush color to the user's palette.
*/
fun addBrush(color: Int) {
- val newBrush = BrushInfo(color = color, width = WhiteboardRepository.DEFAULT_STROKE_WIDTH)
+ val newBrush = WhiteboardTool.Brush(color = color, width = WhiteboardRepository.DEFAULT_STROKE_WIDTH)
val updatedBrushes = brushes.value + newBrush
brushes.value = updatedBrushes
repository.saveBrushes(brushes.value, isDarkMode)
@@ -441,13 +486,34 @@ class WhiteboardViewModel(
brushes.value = updatedBrushes
repository.saveBrushes(brushes.value, isDarkMode)
- when {
- oldActiveIndex == indexToRemove -> {
- val newIndex = (oldActiveIndex - 1).coerceAtLeast(0)
- setActiveBrush(newIndex)
+ val newIndex =
+ when {
+ oldActiveIndex == indexToRemove -> (oldActiveIndex - 1).coerceAtLeast(0)
+ oldActiveIndex > indexToRemove -> oldActiveIndex - 1
+ else -> oldActiveIndex
}
- oldActiveIndex > indexToRemove -> {
- setActiveBrush(oldActiveIndex - 1)
+
+ if (activeTool.value is WhiteboardTool.Brush) {
+ setActiveBrush(newIndex)
+ } else {
+ activeBrushIndex.value = newIndex
+ repository.saveLastActiveBrushIndex(newIndex, isDarkMode)
+ }
+ }
+
+ /**
+ * Enables or disables the eraser based on whether the stylus button is pressed.
+ */
+ fun setStylusButtonPressed(isPressed: Boolean) {
+ if (isPressed) {
+ if (activeTool.value is WhiteboardTool.Brush) {
+ isStylusOverriding = true
+ activeTool.value = eraser
+ }
+ } else {
+ if (isStylusOverriding) {
+ isStylusOverriding = false
+ setActiveBrush(activeBrushIndex.value)
}
}
}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ConfigAwareSingleFragmentActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ConfigAwareSingleFragmentActivity.kt
index ebb9181c79ad..dc52171f5721 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ConfigAwareSingleFragmentActivity.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ConfigAwareSingleFragmentActivity.kt
@@ -1,5 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
-// SPDX-FileCopyrightText: Copyright (c) 2025 lukstbit <52494258+lukstbit@users.noreply.github.com>
package com.ichi2.anki.utils
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/FragmentResult.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/FragmentResult.kt
index a5c4c3620334..327972a35bd3 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/FragmentResult.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/FragmentResult.kt
@@ -1,18 +1,5 @@
-/*
- * Copyright (c) 2025 lukstbit <52494258+lukstbit@users.noreply.github.com>
- *
- * This program is free software; you can redistribute it and/or modify it under
- * the terms of the GNU General Public License as published by the Free Software
- * Foundation; either version 3 of the License, or (at your option) any later
- * version.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT ANY
- * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
- * PARTICULAR PURPOSE. See the GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program. If not, see .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
+
package com.ichi2.anki.utils.ext
import android.os.Bundle
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Translations.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Translations.kt
index 92b54fd95bc5..530d6d61f37e 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Translations.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Translations.kt
@@ -1,5 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
-// SPDX-FileCopyrightText: Copyright (c) 2025 lukstbit <52494258+lukstbit@users.noreply.github.com>
package com.ichi2.anki.utils.ext
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncMediaWorker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncMediaWorker.kt
index 549d8d0d9fb3..54c2e7d95df7 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncMediaWorker.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncMediaWorker.kt
@@ -22,7 +22,6 @@ import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import anki.sync.MediaSyncProgress
-import anki.sync.SyncAuth
import anki.sync.syncAuth
import com.ichi2.anki.CollectionManager
import com.ichi2.anki.CollectionManager.TR
@@ -33,6 +32,8 @@ import com.ichi2.anki.cancelMediaSync
import com.ichi2.anki.common.permissions.canPostNotifications
import com.ichi2.anki.notifications.NotificationId
import com.ichi2.anki.receiver.CopyToClipboardReceiver
+import com.ichi2.anki.sync.SyncAuth
+import com.ichi2.anki.sync.syncMedia
import com.ichi2.anki.ui.internationalization.sentenceCase
import com.ichi2.anki.utils.ext.trySetForeground
import com.ichi2.utils.TruncatedString
@@ -63,12 +64,12 @@ class SyncMediaWorker(
inputData.getString(ENDPOINT_KEY)?.let {
endpoint = it
}
- }
+ }.let(::SyncAuth)
// The collection must be open, but we should not block collection operations while
// `syncMedia` is executing, the app should be usable during a background media sync
val backend = CollectionManager.getColUnsafe().backend
- backend.syncMedia(input = auth)
+ backend.syncMedia(auth)
delay(1000) // avoid notifications if sync occurs too quickly
if (backend.mediaSyncStatus().active) {
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 97eaa8a521ea..6cae7ab73c93 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncWorker.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncWorker.kt
@@ -19,7 +19,6 @@ import androidx.work.OutOfQuotaPolicy
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import anki.collection.Progress
-import anki.sync.SyncAuth
import anki.sync.SyncCollectionResponse
import anki.sync.syncAuth
import com.ichi2.anki.CollectionManager
@@ -33,6 +32,8 @@ import com.ichi2.anki.common.permissions.canPostNotifications
import com.ichi2.anki.notifications.NotificationId
import com.ichi2.anki.setLastSyncTimeToNow
import com.ichi2.anki.settings.Prefs
+import com.ichi2.anki.sync.SyncAuth
+import com.ichi2.anki.sync.syncCollection
import com.ichi2.anki.utils.ext.trySetForeground
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.coroutineScope
@@ -80,7 +81,7 @@ class SyncWorker(
inputData.getString(ENDPOINT_KEY)?.let {
endpoint = it
}
- }
+ }.let(::SyncAuth)
val shouldSyncMedia = inputData.getBoolean(SYNC_MEDIA_KEY, false)
try {
@@ -150,10 +151,7 @@ class SyncWorker(
val syncAuth =
if (response.hasNewEndpoint() && response.newEndpoint.isNotEmpty()) {
Prefs.currentSyncUri = response.newEndpoint
- syncAuth {
- hkey = auth.hkey
- endpoint = response.newEndpoint
- }
+ auth.withEndpoint(response.newEndpoint)
} else {
auth
}
diff --git a/AnkiDroid/src/main/java/com/ichi2/imagecropper/ImageCropper.kt b/AnkiDroid/src/main/java/com/ichi2/imagecropper/ImageCropper.kt
index d5cc191f50df..38f683c269c4 100644
--- a/AnkiDroid/src/main/java/com/ichi2/imagecropper/ImageCropper.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/imagecropper/ImageCropper.kt
@@ -179,6 +179,7 @@ class ImageCropper :
if (Build.VERSION.SDK_INT >= 30) {
Bitmap.CompressFormat.WEBP_LOSSLESS
} else {
+ @Suppress("DEPRECATION") // WEBP: deprecated in API 30, the only option below it
Bitmap.CompressFormat.WEBP
}
}
diff --git a/AnkiDroid/src/main/java/com/ichi2/ui/FixedEditText.kt b/AnkiDroid/src/main/java/com/ichi2/ui/FixedEditText.kt
index aa634888b25b..3a26b6754bbe 100644
--- a/AnkiDroid/src/main/java/com/ichi2/ui/FixedEditText.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/ui/FixedEditText.kt
@@ -39,6 +39,9 @@ import android.content.Context
import android.graphics.Canvas
import android.util.AttributeSet
import android.view.MotionEvent
+import android.view.inputmethod.EditorInfo
+import android.view.inputmethod.InputConnection
+import android.view.inputmethod.InputMethodManager
import androidx.appcompat.widget.AppCompatEditText
import androidx.core.graphics.toColorInt
import com.google.android.material.color.MaterialColors
@@ -51,6 +54,19 @@ open class FixedEditText : AppCompatEditText {
constructor(context: Context, attrs: AttributeSet?) : super(context, attrs)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr)
+ /**
+ * Request raw keyboard input without disabling this view's cursor or input connection.
+ *
+ * Call [InputMethodManager.restartInput] after changing this flag if the keyboard is
+ * already connected.
+ *
+ * @see applyNoSuggest
+ */
+ var noSuggest: Boolean = false
+
+ override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? =
+ super.onCreateInputConnection(outAttrs).applyNoSuggest(outAttrs, noSuggest)
+
init {
val highlightColor =
MaterialColors.getColor(
diff --git a/AnkiDroid/src/main/java/com/ichi2/ui/NoSuggest.kt b/AnkiDroid/src/main/java/com/ichi2/ui/NoSuggest.kt
new file mode 100644
index 000000000000..a3b4c254c090
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/ui/NoSuggest.kt
@@ -0,0 +1,23 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.ui
+
+import android.text.InputType
+import android.view.inputmethod.EditorInfo
+import android.view.inputmethod.InputConnection
+
+/**
+ * Disable suggestions using TYPE_NULL, following the Reword app's approach (Issue 10352).
+ * Other suggestion settings did not work with Gboard, and password input types could
+ * prompt the password manager.
+ *
+ * Apply this after creating the input connection. Only [outAttrs] is changed, preserving the
+ * view's cursor, input connection, language hints and Done action.
+ */
+internal fun InputConnection?.applyNoSuggest(
+ outAttrs: EditorInfo,
+ noSuggest: Boolean,
+): InputConnection? =
+ also { connection ->
+ if (connection != null && noSuggest) outAttrs.inputType = InputType.TYPE_NULL
+ }
diff --git a/AnkiDroid/src/main/java/com/ichi2/ui/TypeAnswerEditText.kt b/AnkiDroid/src/main/java/com/ichi2/ui/TypeAnswerEditText.kt
new file mode 100644
index 000000000000..19b44e75e6a4
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/ui/TypeAnswerEditText.kt
@@ -0,0 +1,29 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.ui
+
+import android.content.Context
+import android.util.AttributeSet
+import android.view.inputmethod.EditorInfo
+import android.view.inputmethod.InputConnection
+import android.view.inputmethod.InputMethodManager
+import com.google.android.material.textfield.TextInputEditText
+
+/** The Material answer field, including support for `{{nosuggest:type:}}`. */
+class TypeAnswerEditText(
+ context: Context,
+ attrs: AttributeSet?,
+) : TextInputEditText(context, attrs) {
+ /**
+ * Request raw keyboard input without disabling this view's cursor or input connection.
+ *
+ * Call [InputMethodManager.restartInput] after changing this flag if the keyboard is
+ * already connected.
+ *
+ * @see applyNoSuggest
+ */
+ var noSuggest: Boolean = false
+
+ override fun onCreateInputConnection(outAttrs: EditorInfo): InputConnection? =
+ super.onCreateInputConnection(outAttrs).applyNoSuggest(outAttrs, noSuggest)
+}
diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/TagsUtil.kt b/AnkiDroid/src/main/java/com/ichi2/utils/TagsUtil.kt
index 10e056526dab..b8c5edbda662 100644
--- a/AnkiDroid/src/main/java/com/ichi2/utils/TagsUtil.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/utils/TagsUtil.kt
@@ -95,18 +95,19 @@ object TagsUtil {
fun compareTag(
lhs: String,
rhs: String,
+ ): Int = compareTag(lhs.split("::"), rhs.split("::"))
+
+ /** Compare already split tags without allocating their parts for each comparison. */
+ internal fun compareTag(
+ lhs: List,
+ rhs: List,
): Int {
- val lhsIt = lhs.split("::").asSequence().iterator()
- val rhsIt = rhs.split("::").asSequence().iterator()
- while (lhsIt.hasNext() && rhsIt.hasNext()) {
- val cmp = lhsIt.next().compareTo(rhsIt.next(), true)
+ for (index in 0 until minOf(lhs.size, rhs.size)) {
+ val cmp = lhs[index].compareTo(rhs[index], true)
if (cmp != 0) {
return cmp
}
}
- if (!lhsIt.hasNext() && !rhsIt.hasNext()) {
- return 0
- }
- return if (lhsIt.hasNext()) 1 else -1
+ return lhs.size.compareTo(rhs.size)
}
}
diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/UniqueArrayList.kt b/AnkiDroid/src/main/java/com/ichi2/utils/UniqueArrayList.kt
index 7803de234486..80d940541704 100644
--- a/AnkiDroid/src/main/java/com/ichi2/utils/UniqueArrayList.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/utils/UniqueArrayList.kt
@@ -22,7 +22,6 @@
package com.ichi2.utils
-import com.ichi2.anki.common.utils.annotation.KotlinCleanup
import org.apache.commons.collections4.list.SetUniqueList
import java.util.Arrays
import java.util.Spliterator
@@ -68,7 +67,7 @@ class UniqueArrayList /**
* and [List] don't get out of sync, and [SetUniqueList] cannot be sorted via [java.util.Collections.sort]
* or [SetUniqueList.sort] both will throw an exception, due to a limitation on this class [java.util.ListIterator]
*
- * Sorting can be only done via [UniqueArrayList.sort] or [UniqueArrayList.sort].
+ * Sorting can be only done via [UniqueArrayList.sort] or [UniqueArrayList.sortWith].
*
* Modification to this list reference should be done with cautious to avoid having the internal [Set] out of sync
*/
@@ -91,11 +90,10 @@ class UniqueArrayList /**
* must not throw a `ClassCastException` for any elements
* `e1` and `e2` in the list).
*
- * @see .sort
+ * @see sortWith
*/
- @KotlinCleanup("sortWith")
fun sort() {
- sortOverride(null)
+ sortWith(null)
}
/**
@@ -121,15 +119,17 @@ class UniqueArrayList /**
*
* @see java.util.Collections.sort
*/
- @KotlinCleanup("sortWith")
override fun sort(c: Comparator?) {
- sortOverride(c)
+ sortWith(c)
}
- /** Exists temporarily: [sort] has been defined as invalid in kotlin so it can't be called internally */
+ /**
+ * Sorts the backing list while preserving the set used for uniqueness.
+ *
+ * @param c the comparator, or `null` to use the elements' natural ordering.
+ */
@Suppress("UNCHECKED_CAST")
- @KotlinCleanup("use sortWith")
- fun sortOverride(c: Comparator?) {
+ fun sortWith(c: Comparator?) {
val elements = ArrayList(list).toArray() as Array
Arrays.sort(elements, c)
val i = list.listIterator()
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 1f4722ecadec..5afa41f95a1a 100644
--- a/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidgetConfig.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidgetConfig.kt
@@ -1,6 +1,5 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// SPDX-FileCopyrightText: Copyright (c) 2024 Anoop
-// SPDX-FileCopyrightText: Copyright (c) 2025 lukstbit <52494258+lukstbit@users.noreply.github.com>
package com.ichi2.widget.cardanalysis
diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/deckpicker/DeckPickerWidget.kt b/AnkiDroid/src/main/java/com/ichi2/widget/deckpicker/DeckPickerWidget.kt
index a5dcb0910079..c45e67b3e13d 100644
--- a/AnkiDroid/src/main/java/com/ichi2/widget/deckpicker/DeckPickerWidget.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/widget/deckpicker/DeckPickerWidget.kt
@@ -9,8 +9,15 @@ import android.appwidget.AppWidgetManager.ACTION_APPWIDGET_UPDATE
import android.content.ComponentName
import android.content.Context
import android.content.Intent
+import android.os.Build
+import android.os.Bundle
+import android.util.SizeF
+import android.util.TypedValue
import android.view.View
+import android.view.View.MeasureSpec
import android.widget.RemoteViews
+import android.widget.TextView
+import androidx.core.os.BundleCompat
import com.ichi2.anki.CollectionManager.withCol
import com.ichi2.anki.CommonString
import com.ichi2.anki.R
@@ -35,6 +42,7 @@ import com.ichi2.widget.setRecurringAlarm
import com.ichi2.widget.updateAppWidget
import kotlinx.coroutines.launch
import timber.log.Timber
+import kotlin.math.roundToInt
/**
* Data class representing the data for a deck displayed in the widget.
@@ -70,16 +78,14 @@ class DeckPickerWidget : AnalyticsWidgetProvider() {
/**
* Updates the widget with the deck data.
*
- * This method replaces the entire view content with entries for each deck ID
- * provided in the `deckIds` array. If any decks are deleted,
- * they will be ignored, and only the rest of the decks will be displayed.
+ * This method replaces the view content with as many complete deck rows as fit
+ * in the widget. Deleted decks are ignored.
*
* @param context the context of the application
* @param appWidgetManager the AppWidgetManager instance
* @param appWidgetId the ID of the app widget
* @param deckIds the array of deck IDs to be displayed in the widget.
- * Each ID corresponds to a specific deck, and the view will
- * contain exactly the decks whose IDs are in this list.
+ * The order is preserved when choosing which decks fit.
*
*/
fun updateWidget(
@@ -107,19 +113,59 @@ class DeckPickerWidget : AnalyticsWidgetProvider() {
return@launch
}
- showDeck(context, appWidgetManager, appWidgetId, remoteViews, deckIds)
+ val options = appWidgetManager.getAppWidgetOptions(appWidgetId.id)
+ appWidgetManager.updateAppWidget(appWidgetId, createSizedDeckViews(context, deckData, options))
}
}
- private suspend fun showDeck(
+ private suspend fun createSizedDeckViews(
context: Context,
- appWidgetManager: AppWidgetManager,
- appWidgetId: AppWidgetId,
- remoteViews: RemoteViews,
- deckIds: LongArray,
- ) {
+ deckData: List,
+ options: Bundle,
+ ): RemoteViews {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ val sizes = BundleCompat.getParcelableArrayList(options, AppWidgetManager.OPTION_APPWIDGET_SIZES, SizeF::class.java)
+ if (!sizes.isNullOrEmpty()) {
+ return RemoteViews(sizes.associateWith { createDeckViews(context, deckData, it) })
+ }
+ }
+ // Older launchers report a size range: landscape is shorter, portrait is taller.
+ return RemoteViews(
+ createDeckViews(
+ context,
+ deckData,
+ SizeF(
+ options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_WIDTH).toFloat(),
+ options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT).toFloat(),
+ ),
+ ),
+ createDeckViews(
+ context,
+ deckData,
+ SizeF(
+ options.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_WIDTH).toFloat(),
+ options.getInt(AppWidgetManager.OPTION_APPWIDGET_MAX_HEIGHT).toFloat(),
+ ),
+ ),
+ )
+ }
+
+ private suspend fun createDeckViews(
+ context: Context,
+ deckData: List,
+ size: SizeF,
+ ): RemoteViews {
+ val remoteViews = RemoteViews(context.packageName, R.layout.widget_deck_picker_large)
+ val density = context.resources.displayMetrics.density
+ val widthSpec =
+ MeasureSpec.makeMeasureSpec(
+ (size.width * density).roundToInt(),
+ if (size.width > 0) MeasureSpec.EXACTLY else MeasureSpec.UNSPECIFIED,
+ )
+ // Some hosts do not supply dimensions until the first resize.
+ val availableHeight = if (size.height > 0) size.height * density else Float.POSITIVE_INFINITY
+ var usedHeight = 0
remoteViews.removeAllViews(R.id.deckCollection)
- val deckData = getDeckNamesAndStats(deckIds.toList())
for (deck in deckData) {
val deckView = RemoteViews(context.packageName, R.layout.widget_item_deck_main)
@@ -130,6 +176,17 @@ class DeckPickerWidget : AnalyticsWidgetProvider() {
deckView.setTextViewText(R.id.deckDue, deck.reviewCount.toString())
deckView.setTextViewText(R.id.deckLearn, deck.learnCount.toString())
+ // Measure the same content and layout the launcher will render, including
+ // font scaling, fallback fonts (such as emoji), and vertical padding.
+ val row = deckView.apply(context, null)
+ row.measure(widthSpec, MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED))
+ if (usedHeight + row.measuredHeight > availableHeight) {
+ if (usedHeight > 0) break
+ // Keep one usable row when a large font cannot fit at the minimum widget height.
+ deckView.fitSingleRow(row, widthSpec, availableHeight.toInt())
+ }
+ usedHeight += row.measuredHeight
+
val isEmptyDeck = deck.newCount == 0 && deck.reviewCount == 0 && deck.learnCount == 0
val intent =
@@ -147,11 +204,42 @@ class DeckPickerWidget : AnalyticsWidgetProvider() {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
- deckView.setOnClickPendingIntent(R.id.deckName, pendingIntent)
+ deckView.setOnClickPendingIntent(R.id.widget_deck_row, pendingIntent)
remoteViews.addView(R.id.deckCollection, deckView)
}
- appWidgetManager.updateAppWidget(appWidgetId, remoteViews)
+ return remoteViews
+ }
+
+ /** Shrink the first row only when its configured text size cannot fit in the widget. */
+ private fun RemoteViews.fitSingleRow(
+ row: View,
+ widthSpec: Int,
+ height: Int,
+ ) {
+ val textViews = listOf(R.id.deckName, R.id.deckNew, R.id.deckLearn, R.id.deckDue).map { row.findViewById(it) }
+ val originalSizes = textViews.map { it.textSize }
+ val largestTextSize = originalSizes.max()
+
+ fun measureAtScale(scale: Float) {
+ textViews.zip(originalSizes).forEach { (view, textSize) ->
+ view.setTextSize(TypedValue.COMPLEX_UNIT_PX, textSize * scale)
+ }
+ row.measure(widthSpec, MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED))
+ }
+
+ // Find the largest fitting scale to within one pixel, preserving the relative text sizes.
+ var minScale = 0f
+ var maxScale = 1f
+ while ((maxScale - minScale) * largestTextSize > 1f) {
+ val scale = (minScale + maxScale) / 2
+ measureAtScale(scale)
+ if (row.measuredHeight <= height) minScale = scale else maxScale = scale
+ }
+ measureAtScale(minScale)
+ for (view in textViews) {
+ setTextViewTextSize(view.id, TypedValue.COMPLEX_UNIT_PX, view.textSize)
+ }
}
private fun showEmptyCollection(
@@ -229,6 +317,16 @@ class DeckPickerWidget : AnalyticsWidgetProvider() {
}
}
+ override fun onAppWidgetOptionsChanged(
+ context: Context,
+ appWidgetManager: AppWidgetManager,
+ appWidgetId: Int,
+ newOptions: Bundle,
+ ) {
+ super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions)
+ onUpdate(context, appWidgetManager, intArrayOf(appWidgetId))
+ }
+
override fun onEnabled(context: Context) {
super.onEnabled(context)
DayRolloverAlarm.scheduleNext(context)
diff --git a/AnkiDroid/src/main/res/drawable-hdpi/drawer_shadow.9.png b/AnkiDroid/src/main/res/drawable-hdpi/drawer_shadow.9.png
deleted file mode 100644
index 236bff558af0..000000000000
Binary files a/AnkiDroid/src/main/res/drawable-hdpi/drawer_shadow.9.png and /dev/null differ
diff --git a/AnkiDroid/src/main/res/drawable-mdpi/drawer_shadow.9.png b/AnkiDroid/src/main/res/drawable-mdpi/drawer_shadow.9.png
deleted file mode 100644
index ffe3a28d77c7..000000000000
Binary files a/AnkiDroid/src/main/res/drawable-mdpi/drawer_shadow.9.png and /dev/null differ
diff --git a/AnkiDroid/src/main/res/drawable-xhdpi/drawer_shadow.9.png b/AnkiDroid/src/main/res/drawable-xhdpi/drawer_shadow.9.png
deleted file mode 100644
index fabe9d965637..000000000000
Binary files a/AnkiDroid/src/main/res/drawable-xhdpi/drawer_shadow.9.png and /dev/null differ
diff --git a/AnkiDroid/src/main/res/drawable-xxhdpi/drawer_shadow.9.png b/AnkiDroid/src/main/res/drawable-xxhdpi/drawer_shadow.9.png
deleted file mode 100644
index b91e9d7f285e..000000000000
Binary files a/AnkiDroid/src/main/res/drawable-xxhdpi/drawer_shadow.9.png and /dev/null differ
diff --git a/AnkiDroid/src/main/res/layout-land/fragment_tag_limit.xml b/AnkiDroid/src/main/res/layout-land/fragment_tag_limit.xml
index 61933ccd7a03..10d4169d8205 100644
--- a/AnkiDroid/src/main/res/layout-land/fragment_tag_limit.xml
+++ b/AnkiDroid/src/main/res/layout-land/fragment_tag_limit.xml
@@ -1,7 +1,6 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/AnkiDroid/src/main/res/layout/fragment_tag_limit.xml b/AnkiDroid/src/main/res/layout/fragment_tag_limit.xml
index 1ed3f5ec0544..78d45435d1e4 100644
--- a/AnkiDroid/src/main/res/layout/fragment_tag_limit.xml
+++ b/AnkiDroid/src/main/res/layout/fragment_tag_limit.xml
@@ -1,7 +1,6 @@
diff --git a/AnkiDroid/src/main/res/layout/widget_item_deck_main.xml b/AnkiDroid/src/main/res/layout/widget_item_deck_main.xml
index 855a55c4eb01..c3301e94855e 100644
--- a/AnkiDroid/src/main/res/layout/widget_item_deck_main.xml
+++ b/AnkiDroid/src/main/res/layout/widget_item_deck_main.xml
@@ -1,21 +1,23 @@
Edit toolbar itemVoer HTML in om voor en na die geselekteerde teks ingevoeg te word\n\nDruk lank op \'n nutsbalk-item om dit te wysig of te verwyderRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredDie beeld is te groot, voeg asseblief die beeld handmatig inDie videolêer is te groot, voeg asseblief die video handmatig inDie klanklêer is te groot, voeg asseblief die klank handmatig in
@@ -270,6 +272,11 @@
Import deckSomething went wrong, please try againAflaai het misluk
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftJy kan ander toepassings gebruik terwyl die aflaai loopKontroleer asseblief jou netwerkverbindingAflaai gestaak, die eksterne berging is nie beskikbaar nie
diff --git a/AnkiDroid/src/main/res/values-af/10-preferences.xml b/AnkiDroid/src/main/res/values-af/10-preferences.xml
index 51a5078f78ae..97fef688fee2 100644
--- a/AnkiDroid/src/main/res/values-af/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-af/10-preferences.xml
@@ -312,6 +312,7 @@
AboutContributorscontributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LicenseGPL-3.0 and AGPL-3.0 licenses, with the source code available on GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-am/02-strings.xml b/AnkiDroid/src/main/res/values-am/02-strings.xml
index 7cd1529bac88..e07cedd7eb06 100644
--- a/AnkiDroid/src/main/res/values-am/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-am/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar itemEnter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove itRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredThe image is too large, please insert the image manuallyThe video file is too large, please insert the video manuallyThe audio file is too large, please insert the audio manually
@@ -270,6 +272,11 @@
Import deckSomething went wrong, please try againDownload failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftYou can use other apps while the download is runningPlease check your network connectionDownload aborted, the external storage is not available
diff --git a/AnkiDroid/src/main/res/values-am/10-preferences.xml b/AnkiDroid/src/main/res/values-am/10-preferences.xml
index 88ffb716551e..e2dc63311162 100644
--- a/AnkiDroid/src/main/res/values-am/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-am/10-preferences.xml
@@ -312,6 +312,7 @@
AboutContributorscontributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LicenseGPL-3.0 and AGPL-3.0 licenses, with the source code available on GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-ar/02-strings.xml b/AnkiDroid/src/main/res/values-ar/02-strings.xml
index bf13435d2bed..f512c524a0f0 100644
--- a/AnkiDroid/src/main/res/values-ar/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-ar/02-strings.xml
@@ -248,6 +248,8 @@
تعديل الأداةأدخل الـ«HTML» الذي سيُدرج قبل النص المحدد وبعده\n\nإضغط ضغطة مطولة على هذه الأداة في شريط الأدوات لتعديلها أو حذفهاأتود حذف هذه الأداة؟
+ Button text is required
+ At least one HTML field is requiredالصورة كبيرة جدًا للصق، يرجى إدخال الصورة يدويًامِلَفّ الفيديو كبير جدًا، يرجى إدخال الفيديو يدويًاالمِلَفّ الصوتي كبير جدًا، يرجى إدخال الصوت يدويًا
@@ -282,6 +284,11 @@
استيراد رزمةحدث خطأ ما، حاول مجددًافشل التنزيل
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftتستطيع استخدام التطبيقات الأخرى أثناء استمرار التنزيلتحقق من اتصالك بالشبكةتم إلغاء التنزيل لأن التخزين الخارجي غير متوفر
diff --git a/AnkiDroid/src/main/res/values-ar/10-preferences.xml b/AnkiDroid/src/main/res/values-ar/10-preferences.xml
index d9f172739ca1..aacc729c79e5 100644
--- a/AnkiDroid/src/main/res/values-ar/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-ar/10-preferences.xml
@@ -323,6 +323,7 @@
حولمساهمونمتطوعونا الرائعون. تستطيع مساعدة آنكي درويد أيضًا بالبرمجة والترجمة والتبرع والمزيد!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>الرخصةGPL-3.0 وAGPL-3.0، وتتوفر الشفرة المصدرية على GitHub]]>رخص متنوعة مفتوحة المصدر.]]>
diff --git a/AnkiDroid/src/main/res/values-az/02-strings.xml b/AnkiDroid/src/main/res/values-az/02-strings.xml
index 1db0a8126b84..36d16689c7bb 100644
--- a/AnkiDroid/src/main/res/values-az/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-az/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar itemEnter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove itRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredThe image is too large, please insert the image manuallyThe video file is too large, please insert the video manuallyThe audio file is too large, please insert the audio manually
@@ -270,6 +272,11 @@
Import deckSomething went wrong, please try againDownload failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftYou can use other apps while the download is runningPlease check your network connectionDownload aborted, the external storage is not available
diff --git a/AnkiDroid/src/main/res/values-az/10-preferences.xml b/AnkiDroid/src/main/res/values-az/10-preferences.xml
index 061eec00420d..fb0f556a916f 100644
--- a/AnkiDroid/src/main/res/values-az/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-az/10-preferences.xml
@@ -312,6 +312,7 @@
AboutContributorscontributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LicenseGPL-3.0 and AGPL-3.0 licenses, with the source code available on GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-be/02-strings.xml b/AnkiDroid/src/main/res/values-be/02-strings.xml
index 8c0904fe2553..833133d981f2 100644
--- a/AnkiDroid/src/main/res/values-be/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-be/02-strings.xml
@@ -240,6 +240,8 @@
Рэдагаваць элемент панэлі інструментаўУвядзіце HTML, які будзе ўстаўлены да і пасля вылучанага тэксту\n\nДоўгі націск на элемент панэлі інструментаў дазволіць адрэдагаваць або выдаліць ягоВыдаліць элемент панэлі інструментаў?
+ Button text is required
+ At least one HTML field is requiredЗанадта вялікі відарыс, паспрабуйце ўставіць відарыс уручнуюЗанадта вялікі відэафайл, паспрабуйце ўставіць відэа ўручнуюЗанадта вялікі аўдыяфайл, паспрабуйце ўставіць аўдыя ўручную
@@ -274,6 +276,11 @@
Імпартаваць калодуНешта пайшло не так, калі ласка, паспрабуйце яшчэ разЗбой спампоўвання
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftМожаце карыстацца іншымі праграмамі падчас спампоўванняКалі ласка, праверце падключэнне да інтэрнэтуСпампоўванне перарвана. Прычына: знешняе сховішча недаступна
diff --git a/AnkiDroid/src/main/res/values-be/10-preferences.xml b/AnkiDroid/src/main/res/values-be/10-preferences.xml
index 1238b9e006c6..63cb0ac7e72e 100644
--- a/AnkiDroid/src/main/res/values-be/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-be/10-preferences.xml
@@ -318,6 +318,7 @@
Аб праграмеУдзельнікіўдзельнікі. Вы таксама можаце дапамагчы AnkiDroid з праграмаваннем, перакладамі, ахвяраваннямі і іншымі рэчамі!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>ЛіцэнзіяGPL-3.0 і AGPL-3.0, а таксама зыходным кодам, які даступны на GitHub]]>ліцэнзіямі з адкрытым зыходным кодам]]>
diff --git a/AnkiDroid/src/main/res/values-bg/02-strings.xml b/AnkiDroid/src/main/res/values-bg/02-strings.xml
index b37a2379eb2c..9c8975d1856e 100644
--- a/AnkiDroid/src/main/res/values-bg/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-bg/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar itemEnter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove itRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredThe image is too large, please insert the image manuallyThe video file is too large, please insert the video manuallyThe audio file is too large, please insert the audio manually
@@ -270,6 +272,11 @@
Import deckНещо се обърка, опитайте пакНеуспешно изтегляне
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftМоже да употребявате други приложения, теглейки от мрежатаПроверете връзката си с мрежатаDownload aborted, the external storage is not available
diff --git a/AnkiDroid/src/main/res/values-bg/10-preferences.xml b/AnkiDroid/src/main/res/values-bg/10-preferences.xml
index 8d42ad08ecb5..f79b98e92c90 100644
--- a/AnkiDroid/src/main/res/values-bg/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-bg/10-preferences.xml
@@ -311,6 +311,7 @@
AboutСътруднициcontributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>ЛицензGPL-3.0 and AGPL-3.0 licenses, with the source code available on GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-bn/02-strings.xml b/AnkiDroid/src/main/res/values-bn/02-strings.xml
index f565bc9d97bc..1ebf3aeb151d 100644
--- a/AnkiDroid/src/main/res/values-bn/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-bn/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar itemনির্বাচিত পাঠ্যের আগে এবং পরে সন্নিবেশ করতে HTML লিখুন\n\nএকটি টুলবার আইটেম সম্পাদনা বা সরাতে দীর্ঘক্ষণ টিপুনRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredছবি খুবই বড়, অনুগ্রহ করে ম্যানুয়ালি ছবি ঢোকানভিডিও ফাইলটি খুব বড়, দয়া করে ভিডিওটি ম্যানুয়ালি ঢোকান৷অডিও ফাইলটি খুব বড়, অনুগ্রহ করে ম্যানুয়ালি অডিও ঢোকান৷
@@ -270,6 +272,11 @@
Import deckকিছু ভুল হয়েছে, আবার চেষ্টা করুনডাউনলোড ব্যর্থ হয়েছে৷
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftডাউনলোড করার সময় আপনি অন্যান্য অ্যাপ ব্যবহার করতে পারেনআপনার নেটওয়ার্ক সংযোগ পরীক্ষা করুনডাউনলোড বাতিল করা হয়েছে, কোনো বাহ্যিক সঞ্চয়স্থান উপলব্ধ নেই
diff --git a/AnkiDroid/src/main/res/values-bn/10-preferences.xml b/AnkiDroid/src/main/res/values-bn/10-preferences.xml
index 64a295369ca6..f870652f72c2 100644
--- a/AnkiDroid/src/main/res/values-bn/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-bn/10-preferences.xml
@@ -312,6 +312,7 @@
সম্পর্কেঅবদানকারীcontributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>লাইসেন্সGPL-3.0 and AGPL-3.0 licenses, with the source code available on GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-ca/02-strings.xml b/AnkiDroid/src/main/res/values-ca/02-strings.xml
index 165d72ef7e86..ae063a86638c 100644
--- a/AnkiDroid/src/main/res/values-ca/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-ca/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar itemEntreu el codi HTML que voleu inserir abans i després del text seleccionat\n\nFeu una pressió prolongada a un element de la barra d\'eines per editar-ho o eliminar-hpRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredLa imatge és massa gran per enganxar-la. Inseriu-la manualment si us plauEl vídeo és massa gran per enganxar-lo. Inseriu-lo manualment si us plauL\'àudio és massa gran per enganxar-lo. Inseriu-lo manualment si us plau
@@ -270,6 +272,11 @@
Import deckAlguna cosa ha anat malament. Torneu a provar-ho si us plauError al baixar
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftEs poden usar altres aplicacions mentre es fa la baixadaComproveu la vostra connexió de xarxa si us plauDownload aborted, the external storage is not available
diff --git a/AnkiDroid/src/main/res/values-ca/10-preferences.xml b/AnkiDroid/src/main/res/values-ca/10-preferences.xml
index 8e197c080c83..048bc63d2104 100644
--- a/AnkiDroid/src/main/res/values-ca/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-ca/10-preferences.xml
@@ -311,6 +311,7 @@
Quant aContribuïdorscol·laboradors. També podeu ajudar l\'AnkiDroid programant, traduint, donant i d\'altres formes!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LlicènciaGPL-3.0 i AGPL-3.0, i el codi font està disponible a GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-ckb/02-strings.xml b/AnkiDroid/src/main/res/values-ckb/02-strings.xml
index 33f5940d12b4..4477f1687c5a 100644
--- a/AnkiDroid/src/main/res/values-ckb/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-ckb/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar itemEnter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove itRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredThe image is too large, please insert the image manuallyThe video file is too large, please insert the video manuallyThe audio file is too large, please insert the audio manually
@@ -270,6 +272,11 @@
Import deckSomething went wrong, please try againDownload failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftYou can use other apps while the download is runningPlease check your network connectionDownload aborted, the external storage is not available
diff --git a/AnkiDroid/src/main/res/values-ckb/10-preferences.xml b/AnkiDroid/src/main/res/values-ckb/10-preferences.xml
index fe965ce1afde..cbeb8e721571 100644
--- a/AnkiDroid/src/main/res/values-ckb/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-ckb/10-preferences.xml
@@ -312,6 +312,7 @@
AboutContributorscontributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LicenseGPL-3.0 and AGPL-3.0 licenses, with the source code available on GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-cs/02-strings.xml b/AnkiDroid/src/main/res/values-cs/02-strings.xml
index 5b8e11d5cfce..20856d99ea04 100644
--- a/AnkiDroid/src/main/res/values-cs/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-cs/02-strings.xml
@@ -242,6 +242,8 @@
Upravit položku nástrojové lištyZadejte HTML, které se vloží před a za vybraný text\n\nDlouze stiskněte položku nástrojové lišty, abyste ji upravili nebo odstraniliOdstranit položku nástrojové lišty?
+ Button text is required
+ At least one HTML field is requiredObrázek je příliš velký, vložte ho prosím ručněVideo je příliš velké, vložte ho prosím ručněZvukový soubor je příliš velký, vložte ho prosím ručně
@@ -276,6 +278,11 @@
Importovat balíčekNěco se pokazilo, zkuste to prosím znovuStahování se nezdařilo
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftBěhem stahování můžete používat jiné aplikaceZkontrolujte prosím připojení k sítiStahování přerušeno, externí úložiště není k dispozici
diff --git a/AnkiDroid/src/main/res/values-cs/10-preferences.xml b/AnkiDroid/src/main/res/values-cs/10-preferences.xml
index 47145a42a25f..b7160625d362 100644
--- a/AnkiDroid/src/main/res/values-cs/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-cs/10-preferences.xml
@@ -317,6 +317,7 @@
O aplikaciPřispěvatelépřispěvatelé. Také můžete pomoci AnkiDroidu programováním, překladem, příspěvky a dalším!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LicenceGPL-3.0 a AGPL-3.0, se zdrojovým kódem dostupným na GitHubu.]]>licencemi]]>
diff --git a/AnkiDroid/src/main/res/values-da/02-strings.xml b/AnkiDroid/src/main/res/values-da/02-strings.xml
index 936a6155699c..5ef39d56ff02 100644
--- a/AnkiDroid/src/main/res/values-da/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-da/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar itemEnter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove itRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredThe image is too large, please insert the image manuallyThe video file is too large, please insert the video manuallyThe audio file is too large, please insert the audio manually
@@ -270,6 +272,11 @@
Import deckSomething went wrong, please try againDownload failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftYou can use other apps while the download is runningPlease check your network connectionDownload aborted, the external storage is not available
diff --git a/AnkiDroid/src/main/res/values-da/10-preferences.xml b/AnkiDroid/src/main/res/values-da/10-preferences.xml
index e3828c0c7e80..79db1009527e 100644
--- a/AnkiDroid/src/main/res/values-da/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-da/10-preferences.xml
@@ -312,6 +312,7 @@
OmContributorsbidragsydere. Du kan også hjælpe AnkiDroid ved at programmere, oversætte, donere og mere!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LicenseGPL-3.0 og AGPL-3.0 licenser med kildekoden tilgængelig på GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-de/02-strings.xml b/AnkiDroid/src/main/res/values-de/02-strings.xml
index 811b0f258d0b..6c88eef47f1d 100644
--- a/AnkiDroid/src/main/res/values-de/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-de/02-strings.xml
@@ -236,6 +236,8 @@
Werkzeugleisten Element bearbeitenGeben Sie den HTML-Code ein, der vor und nach dem ausgewählten Text eingefügt werden soll\n\nDrücken Sie lange auf ein Symbol in der Symbolleiste, um es zu entfernenWerkzeugleisten Element entfernen?
+ Button text is required
+ At least one HTML field is requiredDas Bild ist zu groß, bitte fügen Sie das Bild manuell einDie Videodatei ist zu groß, bitte fügen Sie das Video manuell einDie Audiodatei ist zu groß, bitte fügen Sie das Audio manuell ein
@@ -270,6 +272,11 @@
Deck importierenEin Fehler ist aufgetreten, bitte versuchen Sie es erneutHerunterladen gescheitert
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftWährend des Herunterladens können Sie andere Apps verwendenBitte überprüfen Sie die NetzverbindungDownload abgebrochen, externer Speicher nicht verfügbar
diff --git a/AnkiDroid/src/main/res/values-de/10-preferences.xml b/AnkiDroid/src/main/res/values-de/10-preferences.xml
index 117249b4678e..84c2bd441d5b 100644
--- a/AnkiDroid/src/main/res/values-de/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-de/10-preferences.xml
@@ -311,6 +311,7 @@
ÜberMitwirkendeMitwirkenden. Sie können AnkiDroid auch durch programmieren, übersetzen, spenden und mehr helfen!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LizenzGPL-3.0 und AGPL-3.0 Lizenzen veröffentlicht, wobei der Quellcode auf GitHub verfügbar ist]]>-Lizenzen lizenziert sind.]]>
diff --git a/AnkiDroid/src/main/res/values-el/02-strings.xml b/AnkiDroid/src/main/res/values-el/02-strings.xml
index 6553ae9f7e50..30ff32f77fb7 100644
--- a/AnkiDroid/src/main/res/values-el/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-el/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar itemΕισάγετε το HTML που θα εισαχθεί πριν και μετά το επιλεγμένο κείμενο\n\nΠατήστε παρατεταμένα ένα αντικείμενο γραμμής εργαλείων για να το επεξεργαστείτε ή να το αφαιρέσετεRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredΗ εικόνα είναι πολύ μεγάλη, παρακαλώ εισάγετε την εικόνα χειροκίνηταΤο αρχείο βίντεο είναι πολύ μεγάλο, παρακαλώ εισάγετε το βίντεο χειροκίνηταΤο αρχείο ήχου είναι πολύ μεγάλο, παρακαλώ εισάγετε ήχο χειροκίνητα
@@ -270,6 +272,11 @@
Import deckΚάτι πήγε στραβά, παρακαλώ προσπαθήστε ξανάΑποτυχία λήψης
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftΜπορείτε να χρησιμοποιήσετε άλλες εφαρμογές ενώ εκτελείται η λήψηΠαρακαλώ ελέγξτε τη σύνδεσή σας στο δίκτυοΗ λήψη ματαιώθηκε, ο εξωτερικός χώρος αποθήκευσης δεν είναι διαθέσιμος
diff --git a/AnkiDroid/src/main/res/values-el/10-preferences.xml b/AnkiDroid/src/main/res/values-el/10-preferences.xml
index dafb01db0920..f5a9ab02cdc2 100644
--- a/AnkiDroid/src/main/res/values-el/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-el/10-preferences.xml
@@ -312,6 +312,7 @@
ΣχετικάΣυνεισφέροντεςεθελοντές μας. Μπορείτε να συνεισφέρετε κι εσείς στο AnkiDroid προγραμματίζοντας, μεταφράζοντας, δωρίζοντας και πολλά άλλα!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>ΆδειαGPL-3.0 και AGPL-3.0, ενώ ο πηγαίος κώδικάς του είναι διαθέσιμος στο GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-eo/02-strings.xml b/AnkiDroid/src/main/res/values-eo/02-strings.xml
index e869fb40e474..e4b607204fed 100644
--- a/AnkiDroid/src/main/res/values-eo/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-eo/02-strings.xml
@@ -236,6 +236,8 @@
Redakti elementon de ilarstrioEnigu HTML-kodon, kiu estos aldonita antaŭ kaj post la elektita teksto\n\nFrapetadu elementon de ilarstrio por redakti aŭ forigi ĝinĈu forigi elementon de ilarstrio?
+ Button text is required
+ At least one HTML field is requiredLa bildo estas tro granda, enmetu la bildon permaneLa filmeto estas tro granda enmetu la filmeton permaneLa sonaĵo estas tro granda, enmetu la sonaĵon permane
@@ -270,6 +272,11 @@
Import deckProblemo okazis, reprovuElŝuto malsukcesis
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftVi povas uzi aliajn aplikaĵojn kiam la dosiero(j) estas elŝutata(j)Kontrolu vian retkonektonElŝutado ĉesigita, la ekstera konservejo estas neailrebla
diff --git a/AnkiDroid/src/main/res/values-eo/10-preferences.xml b/AnkiDroid/src/main/res/values-eo/10-preferences.xml
index 2f5bce5dd633..e5c61c311bc3 100644
--- a/AnkiDroid/src/main/res/values-eo/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-eo/10-preferences.xml
@@ -306,6 +306,7 @@
PriKontribuantojkontribuantoj. Vi ankaŭ povas helpi al AnkiDroid per programi, traduki, donaci kaj pli!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>PermesiloGPL-3.0 kaj AGPL-3.0, ĝia fontkodo estas disponebla en GitHub.]]>permesiloj.]]>
diff --git a/AnkiDroid/src/main/res/values-es-rAR/02-strings.xml b/AnkiDroid/src/main/res/values-es-rAR/02-strings.xml
index 11f86b3de489..3f87ccda87c5 100644
--- a/AnkiDroid/src/main/res/values-es-rAR/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-es-rAR/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar itemIntroduce HTML para insertarse antes y después del texto seleccionado\n\nManten presionado un elemento de la barra de herramientas para eliminarloRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredLa imagen es demasiado grande, por favor inserta la imagen manualmenteEl archivo de vídeo es demasiado grande, por favor inserta el vídeo manualmenteEl archivo de audio es demasiado grande, por favor inserta el audio manualmente
@@ -270,6 +272,11 @@
Import deckHa ocurrido un error, por favor inténtalo de nuevoDescarga fallida
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftPuedes usar otras aplicaciones mientras se ejecuta la descargaPor favor revisa tu conexión de redDescarga abortada, el almacenamiento externo no está disponible
diff --git a/AnkiDroid/src/main/res/values-es-rAR/10-preferences.xml b/AnkiDroid/src/main/res/values-es-rAR/10-preferences.xml
index e83617bbe8f5..37d757387266 100644
--- a/AnkiDroid/src/main/res/values-es-rAR/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-es-rAR/10-preferences.xml
@@ -311,6 +311,7 @@
Acerca deColaboradorescolaboradores. ¡También puedes ayudar a AnkiDroid programando, traduciendo, donando y más!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LicenciaGPL-3.0 y AGPL-3.0 con el código fuente disponible en GitHub]]>licencias de código abierto.]]>
diff --git a/AnkiDroid/src/main/res/values-es-rES/02-strings.xml b/AnkiDroid/src/main/res/values-es-rES/02-strings.xml
index 627a810f8c43..03711c3cadac 100644
--- a/AnkiDroid/src/main/res/values-es-rES/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-es-rES/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar itemIntroduce HTML para insertarse antes y después del texto seleccionado\n\nManten presionado un elemento de la barra de herramientas para eliminarloRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredLa imagen es demasiado grande, por favor inserta la imagen manualmenteEl archivo de vídeo es demasiado grande, por favor inserta el vídeo manualmenteEl archivo de audio es demasiado grande, por favor inserta el audio manualmente
@@ -270,6 +272,11 @@
Import deckHa ocurrido un error, por favor inténtalo de nuevoDescarga fallida
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftPuedes usar otras aplicaciones mientras se ejecuta la descargaPor favor revisa tu conexión de redDescarga abortada, el almacenamiento externo no está disponible
diff --git a/AnkiDroid/src/main/res/values-es-rES/10-preferences.xml b/AnkiDroid/src/main/res/values-es-rES/10-preferences.xml
index 1e6bbbbae5c1..70a77c3e15c2 100644
--- a/AnkiDroid/src/main/res/values-es-rES/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-es-rES/10-preferences.xml
@@ -311,6 +311,7 @@
Acerca deColaboradorescolaboradores. ¡También puedes ayudar a AnkiDroid programando, traduciendo, donando y más!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LicenciaGPL-3.0 y AGPL-3.0 con el código fuente disponible en GitHub]]>licencias de código abierto.]]>
diff --git a/AnkiDroid/src/main/res/values-et/02-strings.xml b/AnkiDroid/src/main/res/values-et/02-strings.xml
index 889cd3fcacee..bb2c4c321405 100644
--- a/AnkiDroid/src/main/res/values-et/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-et/02-strings.xml
@@ -236,6 +236,8 @@
Muuda tööriista kirjetEnter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove itKas eemaldada tööriista kirje?
+ Button text is required
+ At least one HTML field is requiredPildifail on liiga suur. Palun sisesta pilt käsitsiVideofail on liiga suur. Palun sisesta video käsitsiHelifail on liiga suur. Palun sisesta heli käsitsi
@@ -270,6 +272,11 @@
Impordi kaardipakkMidagi läks valesti. Palun proovi uuestiAllalaadimine ebaõnnestus
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftYou can use other apps while the download is runningPalun kontrolli oma internetiühendustAllalaadimine tühistati. Väline salvestusruum pole saadaval
diff --git a/AnkiDroid/src/main/res/values-et/10-preferences.xml b/AnkiDroid/src/main/res/values-et/10-preferences.xml
index a99985a413e5..b9310b027cb2 100644
--- a/AnkiDroid/src/main/res/values-et/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-et/10-preferences.xml
@@ -312,6 +312,7 @@
TeavePanustajadkaastöölised. Ka sina võid AnkiDroid aidata programmeerimise, tõlkimise, annetamise ja palju muuga!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LitsentsGPL-3.0 and AGPL-3.0 licenses, with the source code available on GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-eu/02-strings.xml b/AnkiDroid/src/main/res/values-eu/02-strings.xml
index bc4e7ee9a0ef..433fb1d03b28 100644
--- a/AnkiDroid/src/main/res/values-eu/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-eu/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar itemEnter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove itRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredThe image is too large, please insert the image manuallyThe video file is too large, please insert the video manuallyThe audio file is too large, please insert the audio manually
@@ -270,6 +272,11 @@
Import deckSomething went wrong, please try againDownload failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftYou can use other apps while the download is runningPlease check your network connectionDownload aborted, the external storage is not available
diff --git a/AnkiDroid/src/main/res/values-eu/10-preferences.xml b/AnkiDroid/src/main/res/values-eu/10-preferences.xml
index 2b0dcd3e2348..8e6ac4a91535 100644
--- a/AnkiDroid/src/main/res/values-eu/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-eu/10-preferences.xml
@@ -312,6 +312,7 @@
AboutContributorscontributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LicenseGPL-3.0 and AGPL-3.0 licenses, with the source code available on GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-fa/02-strings.xml b/AnkiDroid/src/main/res/values-fa/02-strings.xml
index 4e84ddf873e1..4df37a8583fa 100644
--- a/AnkiDroid/src/main/res/values-fa/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-fa/02-strings.xml
@@ -238,6 +238,8 @@
ویرایش مورد در نوار ابزارکد HTMLای که میخواهید قبل و بعد نوشتهتان قرار بگیرد را وارد کنید\n\nبرای ویرایش یا حذف هریک از دکمههای نوارابزار، آنرا نگه داریدآیا گزینه در نوار ابزار حذف شود؟
+ Button text is required
+ At least one HTML field is requiredعکس زیادی بزرگ است، لطفا عکس را دستی وارد کنیدفایل ویدیو زیادی بزرگ است، لطفا ویدیو را دستی وارد کنیدفایل صدا زیادی بزرگ است، لطفا صدا را دستی وارد کنید
@@ -272,6 +274,11 @@
بارگذاری مجموعه کارتمشکلی پیش آمد، لطفا دوباره تلاش کنیددانلود نشد
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftشما میتوانید زمانی که این در حال دانلود است از برنامههای دیگر استفاده کنیدلطفا اتصال اینترنت خود را چک کنیدبارگیری توقف شد، حافظه خارجی در دسترس نیست
diff --git a/AnkiDroid/src/main/res/values-fa/10-preferences.xml b/AnkiDroid/src/main/res/values-fa/10-preferences.xml
index 65bd881d64ec..5916273c4d34 100644
--- a/AnkiDroid/src/main/res/values-fa/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-fa/10-preferences.xml
@@ -310,6 +310,7 @@
درباره مامشارکت کنندگانمشارکت کنندگان عزیزمان هستند. شما هم می توانید با برنامه ریزی، ترجمه، کمک مالی و کارهای دیگر به ما کمک کنید!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>مجوّزGPL-3.0 و AGPL-3.0 منتشر شده است و منبع کد های آن در GitHub قابل دسترسی است]]>مجوزهای متنباز متنوعی منتشر شدهاند.]]>
diff --git a/AnkiDroid/src/main/res/values-fi/02-strings.xml b/AnkiDroid/src/main/res/values-fi/02-strings.xml
index 3772ce4a602e..98559e68d842 100644
--- a/AnkiDroid/src/main/res/values-fi/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-fi/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar itemKirjoita HTML, joka lisätään ennen ja jälkeen valitun tekstin\n\nPaina työkalupalkin kohdetta pitkään muokataksesi sitä tai poistaaksesi senRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredKuva on liian suuri, aseta kuva käsinVideotiedosto on liian suuri, aseta video käsinÄänitiedosto on liian suuri, aseta ääni käsin
@@ -270,6 +272,11 @@
Import deckJokin meni pieleen, yritä uudelleenLataus epäonnistui
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftVoit käyttää muita sovelluksia latauksen aikanaTarkista verkkoyhteytesiLataus keskeytetty, ulkoinen tallennustila ei ole käytettävissä
diff --git a/AnkiDroid/src/main/res/values-fi/10-preferences.xml b/AnkiDroid/src/main/res/values-fi/10-preferences.xml
index f102bb8aa88a..d959bf8c9ed5 100644
--- a/AnkiDroid/src/main/res/values-fi/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-fi/10-preferences.xml
@@ -310,6 +310,7 @@
TietojaKehittäjät ja avustajatkehittäjämme ja avustajamme. Sinäkin voit auttaa AnkiDroidia ohjelmoimalla, kääntämällä, lahjoittamalla ja monin muin tavoin!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LisenssiGPL-3.0 and AGPL-3.0 -lisensseillä, ja lähdekoodi on saatavilla GitHubissa]]>lisensseillä]]>
diff --git a/AnkiDroid/src/main/res/values-fil/02-strings.xml b/AnkiDroid/src/main/res/values-fil/02-strings.xml
index ef347bf4a06a..d34e395646b5 100644
--- a/AnkiDroid/src/main/res/values-fil/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-fil/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar itemMagtala ng HTML na ilalagay bago at pagkatapos ng piniling teksto\n\nIdiin ang toolbar item upang baguhin o tanggalin itoRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredAng larawan ay masyadong malaki, pakilagay ang larawan nang mano-manoAng video file ay masyadong malaki, pakilagay ang video nang mano-manoAng audio file ay masyadong malaki, pakilagay ang audio nang mano-mano
@@ -270,6 +272,11 @@
Import deckMayroong nangyaring mali. Mangyaring subukan muliBigo ang pag-download
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftMaari kang kumamit ng iba pang apps habang tumatakbo ang downloadMangyaring suriin ang iyong koneksyon sa network.Naudlot ang pag-download, wala nang natirang lugar sa external storage
diff --git a/AnkiDroid/src/main/res/values-fil/10-preferences.xml b/AnkiDroid/src/main/res/values-fil/10-preferences.xml
index f94d3d31f21b..1f8df89dca10 100644
--- a/AnkiDroid/src/main/res/values-fil/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-fil/10-preferences.xml
@@ -311,6 +311,7 @@
Tungkol sa AminMga Nag-Ambagnag-ambag. Maaari ka ring tumulong sa AnkiDroid sa pag-program, pagsalin, paghandog at iba pa!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LisensyaGPL-3.0 at AGPL-3.0 na mga lisensya, kung saan ang mga source code nito ay mahahanap sa GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-fr/02-strings.xml b/AnkiDroid/src/main/res/values-fr/02-strings.xml
index 01efc1fe3024..97ad3df7d38e 100644
--- a/AnkiDroid/src/main/res/values-fr/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-fr/02-strings.xml
@@ -236,6 +236,8 @@
Modifier un élément de la barre d’outilsEntrez le code HTML à insérer avant et après le texte sélectionné\n\nAppuyez longuement sur un élément de la barre d\'outils pour le modifier ou le supprimerSupprimer l\'élément de la barre d\'outils ?
+ Button text is required
+ At least one HTML field is requiredL’image est trop grande, veuillez insérer l’image manuellementLe fichier vidéo est trop volumineux, insérez la vidéo manuellement.Le fichier audio est trop volumineux, insérez l’audio manuellement.
@@ -270,6 +272,11 @@
Importer le paquetQuelque chose s\'est mal passé, veuillez réessayerLe téléchargement a échoué
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftVous pouvez utiliser d\'autres applications pendant que le téléchargement est en cours d\'exécutionVeuillez vérifier votre connexion réseauTéléchargement interrompu, le stockage externe n\'est pas disponible
diff --git a/AnkiDroid/src/main/res/values-fr/10-preferences.xml b/AnkiDroid/src/main/res/values-fr/10-preferences.xml
index 43d5fa8d51bb..ce3b39667abc 100644
--- a/AnkiDroid/src/main/res/values-fr/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-fr/10-preferences.xml
@@ -311,6 +311,7 @@
À proposContributeurscontributeurs géniaux. Vous pouvez aussi aider AnkiDroid en programmant, traduisant, donnant et plus !]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LicenceGPL-3.0 et AGPL-3.0, avec le code source disponible sur GitHub]]>licences open source. ]]>
diff --git a/AnkiDroid/src/main/res/values-fy/02-strings.xml b/AnkiDroid/src/main/res/values-fy/02-strings.xml
index 890ff67b9ce0..92d4960d6d6a 100644
--- a/AnkiDroid/src/main/res/values-fy/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-fy/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar itemEnter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove itRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredThe image is too large, please insert the image manuallyThe video file is too large, please insert the video manuallyThe audio file is too large, please insert the audio manually
@@ -270,6 +272,11 @@
Import deckSomething went wrong, please try againDownload failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftYou can use other apps while the download is runningPlease check your network connectionDownload aborted, the external storage is not available
diff --git a/AnkiDroid/src/main/res/values-fy/10-preferences.xml b/AnkiDroid/src/main/res/values-fy/10-preferences.xml
index 8a8d9d27a880..afbbad215f94 100644
--- a/AnkiDroid/src/main/res/values-fy/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-fy/10-preferences.xml
@@ -312,6 +312,7 @@
AboutContributorscontributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LicenseGPL-3.0 and AGPL-3.0 licenses, with the source code available on GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-ga/02-strings.xml b/AnkiDroid/src/main/res/values-ga/02-strings.xml
index 29de2baca9ba..661e8287fdfe 100644
--- a/AnkiDroid/src/main/res/values-ga/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-ga/02-strings.xml
@@ -245,6 +245,8 @@
Edit toolbar itemEnter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove itRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredThe image is too large, please insert the image manuallyThe video file is too large, please insert the video manuallyThe audio file is too large, please insert the audio manually
@@ -279,6 +281,11 @@
Import deckSomething went wrong, please try againDownload failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftYou can use other apps while the download is runningPlease check your network connectionDownload aborted, the external storage is not available
diff --git a/AnkiDroid/src/main/res/values-ga/10-preferences.xml b/AnkiDroid/src/main/res/values-ga/10-preferences.xml
index 1b3e23bab601..8b1262904605 100644
--- a/AnkiDroid/src/main/res/values-ga/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-ga/10-preferences.xml
@@ -321,6 +321,7 @@
AboutContributorscontributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LicenseGPL-3.0 and AGPL-3.0 licenses, with the source code available on GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-gl/02-strings.xml b/AnkiDroid/src/main/res/values-gl/02-strings.xml
index 5d09b14d71f7..4f5d2994416d 100644
--- a/AnkiDroid/src/main/res/values-gl/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-gl/02-strings.xml
@@ -236,6 +236,8 @@
Editar elemento da barra de ferramentasInsire o HTML para introducir antes e despois do texto seleccionado\n\nMantén premido un botón da barra de ferramentas para editalo ou eliminaloEliminar elemento da barra de ferramentas?
+ Button text is required
+ At least one HTML field is requiredA imaxe é moi grande. Insire a imaxe manualmenteO ficheiro de vídeo é moi grande. Insire o vídeo manualmenteO ficheiro de son é moi grande. Insire o son manualmente
@@ -270,6 +272,11 @@
Importar barallaAlgo saíu mal. Téntao de novoFallo ao descargar
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftPodes utilizar outras aplicacións mentres a descarga está en cursoComproba a túa conexión de redeDescarga interrompida, o almacenamento externo non está dispoñíbel
diff --git a/AnkiDroid/src/main/res/values-gl/03-dialogs.xml b/AnkiDroid/src/main/res/values-gl/03-dialogs.xml
index a092794e11dc..8852bc95a179 100644
--- a/AnkiDroid/src/main/res/values-gl/03-dialogs.xml
+++ b/AnkiDroid/src/main/res/values-gl/03-dialogs.xml
@@ -49,7 +49,7 @@
Aumentar/diminuír (\"-3\") o límite de repasos actual enRepasar as tarxetas esquecidas nos últimos x días:Repasar as tarxetas pendentes nos vindeiros:
- Preview new cards added in the last:
+ Previsualizar as tarxetas novas engadidas nos últimos:Seleccionar x tarxetas da baralla:Almacenamento cheoAlmacenamento case cheo
@@ -87,7 +87,7 @@
Isto pode levar moito tempo con coleccións multimedia grandes. Queres continuar?Verificando multimedia…Engadindo etiquetas…
- Tags added
+ Etiquetas engadidasEliminando multimedia…Resultado da eliminación
diff --git a/AnkiDroid/src/main/res/values-gl/10-preferences.xml b/AnkiDroid/src/main/res/values-gl/10-preferences.xml
index f2e587a8560d..0874d75518ee 100644
--- a/AnkiDroid/src/main/res/values-gl/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-gl/10-preferences.xml
@@ -312,6 +312,7 @@
SobreContribuíntescontribuíntes. Ti tamén podes colaborar co AnkiDroid programando, traducindo, doando e máis!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LicenzaGPL-3.0 e AGPL-3.0, co código fonte dispoñíbel no GitHub]]>licenzas de código aberto]]>
@@ -443,8 +444,8 @@
Amosar explicación da respostaPantallaAlso assign to ‘%1$s’?
- Skip
- Assign
+ Omitir
+ AtribuírAbrir configuraciónConfiguracións relacionadas
diff --git a/AnkiDroid/src/main/res/values-got/02-strings.xml b/AnkiDroid/src/main/res/values-got/02-strings.xml
index a971340f88ea..7a12f4847ebc 100644
--- a/AnkiDroid/src/main/res/values-got/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-got/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar itemEnter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove itRemove toolbar item?
+ Button text is required
+ At least one HTML field is required𐍆𐍂𐌹𐍃𐌰𐌷𐍄𐍃 𐌼𐌹𐌺𐌹𐌻𐌰 𐌹𐌽 𐌿𐍆𐌰𐍂𐌰𐍃𐍃𐌰𐌿 𐌹𐍃𐍄, 𐌱𐌹𐌳𐌾𐌰𐌼 𐌸𐌿𐌺 𐌹𐌽𐌻𐌰𐌲𐌴𐌹 𐌸𐍉 𐍆𐍂𐌹𐍃𐌰𐌷𐍄 𐌼𐌰𐌽𐌽𐌰𐍅𐌰𐌿𐍂𐌷𐍄𐌰𐌱𐌰𐌸𐌰𐍄𐌰 𐍆𐌰𐌾𐌻 𐌵𐌹𐍅𐌰𐍆𐍂𐌹𐍃𐌰𐌷𐍄𐌰𐌹𐍃 𐌿𐍆𐌰𐍂𐌼𐌹𐌺𐌹𐌻 𐌹𐍃𐍄, 𐌱𐌹𐌳𐌾𐌰𐌼 𐌸𐌿𐌺 𐌹𐌽𐌻𐌰𐌲𐌾𐌰𐌽 𐌵𐌹𐍅𐌰𐍆𐍂𐌹𐍃𐌰𐌷𐍄 𐌷𐌰𐌽𐌳𐌿𐍅𐌰𐌿𐍂𐌷𐍄𐌰𐌱𐌰𐌸𐌰𐍄𐌰 𐍆𐌰𐌾𐌻 𐌰𐍅𐌳𐌾𐍉𐌽𐍃 𐌿𐍆𐌰𐍂𐌼𐌹𐌺𐌹𐌻 𐌹𐍃𐍄, 𐌱𐌹𐌳𐌾𐌰𐌼 𐌸𐌿𐌺 𐌹𐌽𐌻𐌰𐌲𐌾𐌰𐌽 𐌰𐍅𐌳𐌾𐍉𐌽 𐌷𐌰𐌽𐌳𐌿𐍅𐌰𐌿𐍂𐌷𐍄𐌰𐌱𐌰
@@ -270,6 +272,11 @@
Import deckSomething went wrong, please try again𐌽𐌹 𐌼𐌰𐌷𐍄𐌰 𐌰𐍄𐌳𐍂𐌰𐌲𐌰𐌽
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left𐌼𐌰𐌲𐍄 𐌰𐌽𐌸𐌰𐍂𐌰𐌹𐌶𐌴 𐌰𐍀𐍀𐌴 𐌱𐍂𐌿𐌺𐌾𐌰𐌽 𐌼𐌹𐌸𐌸𐌰𐌽𐌴𐌹 𐌰𐍄𐌳𐍂𐌰𐌲𐌰𐌳𐌰𐌱𐌹𐌳𐌾𐌰𐌼 𐌸𐌿𐌺, 𐍃𐌰𐌹 𐌲𐌰𐍅𐌹𐍃𐍃 𐌽𐌰𐍄𐌾𐌰𐍅𐌰𐌿𐍂𐌺𐌹𐍃 𐌸𐌴𐌹𐌽𐌹𐍃Download aborted, the external storage is not available
diff --git a/AnkiDroid/src/main/res/values-got/10-preferences.xml b/AnkiDroid/src/main/res/values-got/10-preferences.xml
index d7eabfde978e..776a7cfb9815 100644
--- a/AnkiDroid/src/main/res/values-got/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-got/10-preferences.xml
@@ -312,6 +312,7 @@
𐌱𐌹𐌷𐌹𐌻𐍀𐌰𐌽𐌳𐌰𐌽𐍃𐌷𐌹𐌻𐍀𐌰𐌽𐌳𐌰𐌽𐍃 𐌲𐍉𐌳𐌰𐌹 𐍆𐌹𐌻𐌿 𐌿𐌽𐍃𐌰𐍂𐌰𐌹. 𐌾𐌰𐌷 𐌸𐌿 𐌼𐌰𐌲𐍄 𐌷𐌹𐌻𐍀𐌰𐌽 𐌰𐌽𐌺𐌴𐌹𐌳𐍂𐌰𐌿𐌳𐌹𐍃 𐍆𐌰𐌿𐍂𐌰𐌼𐌴𐌻𐌴𐌹𐌽𐌰𐌹, 𐍃𐌺𐌴𐌹𐍂𐌴𐌹𐌽𐌰𐌹, 𐌰𐌽𐌳𐍃𐍄𐌰𐌻𐌳𐌰𐌽 𐌾𐌰𐌷 𐌼𐌰𐌹𐍃! ]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>𐌰𐌽𐌳𐌻𐌴𐍄GPL-3.0 𐌾𐌰𐌷 AGPL-3.0 𐌰𐌽𐌳𐌻𐌴𐍄𐌰𐌼, 𐌼𐌹𐌸 𐌺𐍉𐌳𐌰𐌿 𐌱𐍂𐌿𐌽𐌽𐌹𐌽𐍃 𐍅𐌹𐍃𐌰𐌽𐌳𐌹𐌽 𐌰𐍄 𐌲𐌹𐍄𐌷𐌰𐌱]]>Edit toolbar itemપસંદ કરેલ ટેક્સ્ટ પહેલાં અને પછી દાખલ કરવા માટે HTML દાખલ કરો\n\nટૂલબાર આઇટમને સંપાદિત કરવા અથવા દૂર કરવા માટે તેને લાંબા સમય સુધી દબાવોRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredபடம் மிகவும் பெரிதாக உள்ளது, படத்தை கைமுறையாக செருகவும்வீடியோ கோப்பு மிகவும் பெரியதாக உள்ளது, வீடியோவை கைமுறையாக செருகவும்ઓડિયો ફાઇલ ખૂબ મોટી છે, મહેરબાની કરીને ઓડિયો ફાઇલ જાતે ઉમેરો
@@ -270,6 +272,11 @@
Import deckકંઈક ખોટું થયું, કૃપા કરીને ફરી પ્રયાસ કરોડાઉનલોડ ફેઇલ ગયું છે
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftડાઉનલોડ ચાલુ હોય ત્યારે તમે અન્ય એપ્સનો ઉપયોગ કરી શકો છોકૃપા કરીને તમારું નેટવર્ક કનેક્શન તપાસોડાઉનલોડ રદ કર્યું, બાહ્ય સ્ટોરેજ ઉપલબ્ધ નથી
diff --git a/AnkiDroid/src/main/res/values-gu/10-preferences.xml b/AnkiDroid/src/main/res/values-gu/10-preferences.xml
index 200a0fe5574a..46c50bd437a8 100644
--- a/AnkiDroid/src/main/res/values-gu/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-gu/10-preferences.xml
@@ -311,6 +311,7 @@
વિશેફાળો આપનારcontributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>લાઇસન્સGPL-3.0 and AGPL-3.0 licenses, with the source code available on GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-heb/02-strings.xml b/AnkiDroid/src/main/res/values-heb/02-strings.xml
index eb9130b791b0..8c3751a1aa11 100644
--- a/AnkiDroid/src/main/res/values-heb/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-heb/02-strings.xml
@@ -239,6 +239,8 @@
ערוך פריט מסרגל הכליםהזן HTML שיוכנס לפני ואחרי הטקסט שנבחר \n\n לחץ לחיצה ארוכה על פריט בסרגל הכלים כדי לערוך או להסיר אותומחק פריו מסרגל הכלים
+ Button text is required
+ At least one HTML field is requiredהתמונה גדולה מדי, אנא הכנס אותה באופן ידניקובץ הווידאו גדול מדי, אנא הכנס אותו באופן ידניקובץ השמע גדול מדי, אנא הכנס אותו באופן ידני
@@ -273,6 +275,11 @@
Import deckמשהו השתבש, אנא נסה שובההורדה נכשלה
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftניתן לעבור ליישומים אחרים בזמן ההורדהנא בדוק את החיבור לרשתההורדה בוטלה, האחסון החיצוני אינו זמין
diff --git a/AnkiDroid/src/main/res/values-heb/10-preferences.xml b/AnkiDroid/src/main/res/values-heb/10-preferences.xml
index d6dae079d1e8..35aa7d460762 100644
--- a/AnkiDroid/src/main/res/values-heb/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-heb/10-preferences.xml
@@ -313,6 +313,7 @@
אודותתורמיםתורמים המדהימים שלנו. אתה יכול לעזור גם ל-AnkiDroid על ידי תכנות, תרגום, תרומה ועוד!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>רישיוןGPL-3.0 ו-AGPL-3.0, עם קוד המקור זמין ב-GitHub]]>קוד פתוח]]>
diff --git a/AnkiDroid/src/main/res/values-hi/02-strings.xml b/AnkiDroid/src/main/res/values-hi/02-strings.xml
index a974dbadff0f..bdb796a8cef7 100644
--- a/AnkiDroid/src/main/res/values-hi/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-hi/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar itemचयनित टेक्स्ट के पहले और बाद में डालने के लिए हटम्ल दर्ज करें\n\nकिसी टूलबार आइटम को संपादित करने या हटाने के लिए उसे देर तक दबाएँ.Remove toolbar item?
+ Button text is required
+ At least one HTML field is requiredछवि बहुत बड़ी है, कृपया छवि को मैन्युअल रूप से डालेंवीडियो फ़ाइल बहुत बड़ी है, कृपया वीडियो को मैन्युअल रूप से डालेंऑडियो फ़ाइल बहुत बड़ी है, कृपया ऑडियो मैन्युअल रूप से डालें
@@ -270,6 +272,11 @@
Import deckकुछ गलत हो गया, कृपया फिर से प्रयास करेंडाउनलोड विफल हुआ
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftडाउनलोड चलने के दौरान आप अन्य ऐप्स का उपयोग कर सकते हैंकृपया अपने नेटवर्क कनेक्शन की जांच करेंडाउनलोड निरस्त हो गया, बाह्य स्टोरेज उपलब्ध नहीं है
diff --git a/AnkiDroid/src/main/res/values-hi/10-preferences.xml b/AnkiDroid/src/main/res/values-hi/10-preferences.xml
index 56728e551b10..8a0d05ac84bc 100644
--- a/AnkiDroid/src/main/res/values-hi/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-hi/10-preferences.xml
@@ -307,6 +307,7 @@
इसके बारे मेंयोगदान देने वालायोगदानकर्ता हैं। आप प्रोग्रामिंग, अनुवाद, दान और और करके एंड्रॉइड की भी मदद कर सकते हैं!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>लाइसेंसGPL-3.0 और AGPL-3.0 लाइसेंस के तहत जारी किया गया है। स्रोत कोड GitHub पर उपलब्ध है]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-hr/02-strings.xml b/AnkiDroid/src/main/res/values-hr/02-strings.xml
index 5386f5e9dd6d..100c2e5734ef 100644
--- a/AnkiDroid/src/main/res/values-hr/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-hr/02-strings.xml
@@ -239,6 +239,8 @@
Edit toolbar itemUnesi HTML koji će biti umetnut ispred i iza odabranog teksta\n\nPritisnute dugo na stavku alatne trake kako biste je uredili ili ukloniliRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredSlika je prevelika, umetnite sliku ručnoVideo datoteka je prevelika, umetnite video ručnoZvučna datoteka je prevelika, umetnite zvuk ručno
@@ -273,6 +275,11 @@
Import deckNešto je krenulo po krivu, pokušajte ponovnoPreuzimanje neuspješno
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftMožete koristi druge aplikacije za vrijeme preuzimanjaProvjerite svoju vezu s internetomPreuzimanje prekinuto, vanjska pohrana nije dostupna
diff --git a/AnkiDroid/src/main/res/values-hr/10-preferences.xml b/AnkiDroid/src/main/res/values-hr/10-preferences.xml
index f50e8cc955a6..9b688ddf4237 100644
--- a/AnkiDroid/src/main/res/values-hr/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-hr/10-preferences.xml
@@ -314,6 +314,7 @@
O programuContributorscontributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LicenseGPL-3.0 and AGPL-3.0 licenses, with the source code available on GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-hu/02-strings.xml b/AnkiDroid/src/main/res/values-hu/02-strings.xml
index 774f21451d64..0623d201e9b5 100644
--- a/AnkiDroid/src/main/res/values-hu/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-hu/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar itemAdd meg a HTML-t, amit a kiválasztott szöveg elé és mögé beszúr. \n\nHosszan nyomd az eszköztár elemét szerkesztéshez vagy törléshezRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredThe image is too large, please insert the image manuallyThe video file is too large, please insert the video manuallyThe audio file is too large, please insert the audio manually
@@ -270,6 +272,11 @@
Import deckValami hiba történt, kérjük, próbáld újra.Letöltés sikertelen
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftMás alkalmazásokat használhatsz, amíg a letöltés folyik.Ellenőrizd a hálózati kapcsolatodat!A letöltés megszakadt, a külső tároló nem elérhető
diff --git a/AnkiDroid/src/main/res/values-hu/10-preferences.xml b/AnkiDroid/src/main/res/values-hu/10-preferences.xml
index eecf9875eae1..b1cfc703e556 100644
--- a/AnkiDroid/src/main/res/values-hu/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-hu/10-preferences.xml
@@ -310,6 +310,7 @@
NévjegyKözreműködők közreműködőink . Te is segíthethetsz az AnkiDroidnak programozással, fordítással, adományozással és sok mással!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LicenszGPL-3. és az AGPL-3.0 licenc alatt lett kiadva. A forráskód GitHubon érhető el.]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-hy/02-strings.xml b/AnkiDroid/src/main/res/values-hy/02-strings.xml
index 98bda81b1161..49af27315c83 100644
--- a/AnkiDroid/src/main/res/values-hy/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-hy/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar itemEnter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove itRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredThe image is too large, please insert the image manuallyThe video file is too large, please insert the video manuallyThe audio file is too large, please insert the audio manually
@@ -270,6 +272,11 @@
Import deckSomething went wrong, please try againDownload failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftYou can use other apps while the download is runningPlease check your network connectionDownload aborted, the external storage is not available
diff --git a/AnkiDroid/src/main/res/values-hy/10-preferences.xml b/AnkiDroid/src/main/res/values-hy/10-preferences.xml
index 9f3b545373f7..e56be6ce7147 100644
--- a/AnkiDroid/src/main/res/values-hy/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-hy/10-preferences.xml
@@ -312,6 +312,7 @@
AboutContributorscontributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LicenseGPL-3.0 and AGPL-3.0 licenses, with the source code available on GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-ind/02-strings.xml b/AnkiDroid/src/main/res/values-ind/02-strings.xml
index 9b807cbe8f90..97121fc0834b 100644
--- a/AnkiDroid/src/main/res/values-ind/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-ind/02-strings.xml
@@ -233,6 +233,8 @@
Edit toolbar itemMasukkan HTML untuk dimasukkan sebelum dan sesudah teks\n\n yang telah dipilih. Tekan toolbar item beberapa saat untuk mencopotnya.Remove toolbar item?
+ Button text is required
+ At least one HTML field is requiredGambar ini terlalu besar, mohon masukkan gambar secara manualFile video terlalu besar, mohon masukkan video secara manualFile audio terlalu besar, mohon masukkan audio secara manual
@@ -267,6 +269,11 @@
Import deckAda yang tidak beres, coba lagiGagal mengunduh
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftAnda dapat menggunakan aplikasi lain selagi unduhan berjalanHarap periksa koneksi jaringan AndaUnduhan dibatalkan, penyimpanan eksternal tidak tersedia
diff --git a/AnkiDroid/src/main/res/values-ind/10-preferences.xml b/AnkiDroid/src/main/res/values-ind/10-preferences.xml
index 5686dfd2553a..028c96262fac 100644
--- a/AnkiDroid/src/main/res/values-ind/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-ind/10-preferences.xml
@@ -308,6 +308,7 @@
TentangKontributorpara kontributor keren kami. Anda dapat membantu AnkiDroid juga dengan pemrograman, penerjemahan, donasi, dan masih banyak lagi!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LisensiGPL-3.0 dan AGPL-3.0, dengan kode sumbernya tersedia di GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-it/02-strings.xml b/AnkiDroid/src/main/res/values-it/02-strings.xml
index 273546ac4ee7..304e5cab3bad 100644
--- a/AnkiDroid/src/main/res/values-it/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-it/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar itemInserisci l\'HTML da inserire prima e dopo il testo selezionato\n\nTieni premuto un elemento della barra per modificarlo o rimuoverloRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredL\'immagine è troppo grande, per favore inserisci l\'immagine manualmenteIl file video è troppo grande, per favore inserisci il video manualmente.Il file audio è troppo grande, inserisci manualmente il file audio.
@@ -270,6 +272,11 @@
Import deckQualcosa è andato storto, si prega di riprovareScaricamento non riuscito
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftÈ possibile utilizzare altre applicazioni mentre lo scaricamento è in esecuzioneControlla la tua connessione di reteDownload interrotto, l\'archiviazione esterna non è disponibile
diff --git a/AnkiDroid/src/main/res/values-it/10-preferences.xml b/AnkiDroid/src/main/res/values-it/10-preferences.xml
index efb83a4f9902..8740b74d6aaa 100644
--- a/AnkiDroid/src/main/res/values-it/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-it/10-preferences.xml
@@ -311,6 +311,7 @@
InformazioniContributoricontributori. Anche tu puoi aiutare AnkiDroid programmando, traducendo e altro!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LicenzaGPL-3.0 e AGPL-3.0 , con il codice sorgente disponibile su GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-iw/02-strings.xml b/AnkiDroid/src/main/res/values-iw/02-strings.xml
index eb9130b791b0..8c3751a1aa11 100644
--- a/AnkiDroid/src/main/res/values-iw/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-iw/02-strings.xml
@@ -239,6 +239,8 @@
ערוך פריט מסרגל הכליםהזן HTML שיוכנס לפני ואחרי הטקסט שנבחר \n\n לחץ לחיצה ארוכה על פריט בסרגל הכלים כדי לערוך או להסיר אותומחק פריו מסרגל הכלים
+ Button text is required
+ At least one HTML field is requiredהתמונה גדולה מדי, אנא הכנס אותה באופן ידניקובץ הווידאו גדול מדי, אנא הכנס אותו באופן ידניקובץ השמע גדול מדי, אנא הכנס אותו באופן ידני
@@ -273,6 +275,11 @@
Import deckמשהו השתבש, אנא נסה שובההורדה נכשלה
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftניתן לעבור ליישומים אחרים בזמן ההורדהנא בדוק את החיבור לרשתההורדה בוטלה, האחסון החיצוני אינו זמין
diff --git a/AnkiDroid/src/main/res/values-iw/10-preferences.xml b/AnkiDroid/src/main/res/values-iw/10-preferences.xml
index d6dae079d1e8..35aa7d460762 100644
--- a/AnkiDroid/src/main/res/values-iw/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-iw/10-preferences.xml
@@ -313,6 +313,7 @@
אודותתורמיםתורמים המדהימים שלנו. אתה יכול לעזור גם ל-AnkiDroid על ידי תכנות, תרגום, תרומה ועוד!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>רישיוןGPL-3.0 ו-AGPL-3.0, עם קוד המקור זמין ב-GitHub]]>קוד פתוח]]>
diff --git a/AnkiDroid/src/main/res/values-ja/01-core.xml b/AnkiDroid/src/main/res/values-ja/01-core.xml
index 5513eb7a7693..f10927cc3592 100644
--- a/AnkiDroid/src/main/res/values-ja/01-core.xml
+++ b/AnkiDroid/src/main/res/values-ja/01-core.xml
@@ -162,8 +162,8 @@
穴埋めクイック作成インターネット
- Required for the app to work
- Please grant AnkiDroid the ‘%s’ permission to continue
+ アプリが動作するために必要です
+ 続行するには、AnkiDroidに「%s」の権限を許可してくださいプロファイルを追加
diff --git a/AnkiDroid/src/main/res/values-ja/02-strings.xml b/AnkiDroid/src/main/res/values-ja/02-strings.xml
index 8d809ce29318..471be7134a5e 100644
--- a/AnkiDroid/src/main/res/values-ja/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-ja/02-strings.xml
@@ -69,7 +69,7 @@
このカードに \"leech\" (苦戦、忘却多発) というタグを付けました原因不明のエラーこれはWebViewの内部エラーによって引き起こされました。
- Out of memory
+ メモリが不足していますWebViewはシステムによって終了されました。これは通常、アプリケーションがメモリ不足時に発生します。多くの場合、データ量の多いフォントやメディアが原因です。WebViewレンダラーがクラッシュしました。原因: %sFatal error: WebView renderer crashed. Cause: %s
@@ -233,6 +233,8 @@
アイテムを編集選択した文字列の前後に挿入するHTMLをそれぞれ入力してください\n\n作成したツールバーアイテムを編集または削除したい場合は、そのアイテムのボタンを長押ししてくださいこのツールバーアイテムを削除しますか?
+ Button text is required
+ At least one HTML field is required画像が大きすぎます。手動で画像を挿入してください動画ファイルが大きすぎます。手動で動画を挿入してください音声ファイルが大きすぎます。手動で音声を挿入してください
@@ -267,6 +269,11 @@
デッキをインポート問題が発生しました。もう一度やり直してください。ダウンロードに失敗しました
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftダウンロード中、他のアプリを使用できますネットワーク接続を確認してくださいダウンロードが中止されました。この外部ストレージは利用できません。
@@ -325,7 +332,7 @@
さらにデッキをダウンロードするにはログインしてくださいデッキ説明文説明を更新しました
- Format as Markdown
+ Markdown形式でフォーマット「%s」のヘルプを開くコピーに失敗しました
diff --git a/AnkiDroid/src/main/res/values-ja/03-dialogs.xml b/AnkiDroid/src/main/res/values-ja/03-dialogs.xml
index b6c6c3e856c4..7e6e8ba75faa 100644
--- a/AnkiDroid/src/main/res/values-ja/03-dialogs.xml
+++ b/AnkiDroid/src/main/res/values-ja/03-dialogs.xml
@@ -85,7 +85,7 @@
メディア(画像・音声・動画)ファイルが多いと、チェックに長い時間がかかる場合があります。続行しますか?メディアをチェックしています…タグを追加しています…
- Tags added
+ タグを追加しましたメディアを削除中…削除結果
diff --git a/AnkiDroid/src/main/res/values-ja/10-preferences.xml b/AnkiDroid/src/main/res/values-ja/10-preferences.xml
index 172d7986baff..bf04846c3b7f 100644
--- a/AnkiDroid/src/main/res/values-ja/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-ja/10-preferences.xml
@@ -107,7 +107,7 @@
3点同時タップ4点同時タップデバイスを振る
- ‘%s’ menu
+ メニュー項目に「%s」を追加AnkiDroidおよび他のアプリ(ウェブブラウザなど)で、文字列を選択した際に表示されるコンテキストメニューに「%s」という項目を追加しますダブルスクロールeReader とのスクロールのギャップを 2 倍にする場合、これを有効にします
@@ -235,9 +235,9 @@
Deck not foundリマインダーが機能していません設定変更
- 新しい学習リマインダーが正常に追加されました
- Successfully deleted review reminder
- Successfully edited review reminder
+ 新しい学習リマインダーを追加しました
+ 学習リマインダーを削除しました
+ 学習リマインダーを編集しました学習リマインダーを追加学習リマインダーを編集
@@ -308,6 +308,7 @@
AnkiDroidについてコントリビューターコントリビューターです。誰もが、プログラミング、翻訳、寄付、その他の方法によってAnkiDroidの開発に貢献するコントリビューターになることができます。]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>ライセンスGPL-3.0およびAGPL-3.0のライセンスの下でリリースされています。ソースコードはGitHubで公開されています。]]>ライセンスに基づき、サードパーティのライブラリやアイコンを使用しています。]]>
@@ -437,7 +438,7 @@
新しい学習画面ご意見・ご感想をお寄せください。不具合がある場合もお知らせいただければ幸いです。]]>回答選択時にフィードバック(目印)を表示
- 画面枠
+ 画面Also assign to ‘%1$s’?SkipAssign
@@ -448,5 +449,5 @@
Not setCustom pathEnter custom path
- Directory is not writable
+ ディレクトリが書き込み可能ではありません
diff --git a/AnkiDroid/src/main/res/values-ja/11-arrays.xml b/AnkiDroid/src/main/res/values-ja/11-arrays.xml
index 48cb1d3467cc..3905197aad0c 100644
--- a/AnkiDroid/src/main/res/values-ja/11-arrays.xml
+++ b/AnkiDroid/src/main/res/values-ja/11-arrays.xml
@@ -35,7 +35,7 @@
ライトブループレーン
- E-Ink
+ 電子ペーパーブラックダークグレー
diff --git a/AnkiDroid/src/main/res/values-ka/02-strings.xml b/AnkiDroid/src/main/res/values-ka/02-strings.xml
index 93d4595cf4dd..e6dae9339a83 100644
--- a/AnkiDroid/src/main/res/values-ka/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-ka/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar itemშეიყვანეთ მონიშნული ტექსტის ორივე ბოლოს ჩასასმელი HTML-კოდი\n\nხელსაწყოთა ზოლს დიდი ხნით დააჭირეთ, რათა დაარედაქტიროთ ან წაშალოთRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredსურათი ზედმეტად დიდია. გთხოვთ, სურათი ხელით ჩაამატოთThe video file is too large, please insert the video manuallyThe audio file is too large, please insert the audio manually
@@ -270,6 +272,11 @@
Import deckშეცდომა მოხდა, გთხოვთ თავიდან სცადოთჩამოტვირთვა წარუმატებელია
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftჩამოტვირთვისას შეგიძლიათ სხვა აპლიკაციებიც გამოიყენოთგთხოვთ შეამოწმოთ ქსელთან კავშირიჩამოტვირთვა გაუქმდა, გარე მეხსიერება ხელმიუწვდომელია
diff --git a/AnkiDroid/src/main/res/values-ka/10-preferences.xml b/AnkiDroid/src/main/res/values-ka/10-preferences.xml
index 8fc83b991684..f130108c6f3e 100644
--- a/AnkiDroid/src/main/res/values-ka/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-ka/10-preferences.xml
@@ -311,6 +311,7 @@
შესახებმონაწილეებიcontributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>ლიცენზიაGPL-3.0 და AGPL-3.0 ლიცენზიებით. საწყისი კოდის სანახავად ეწვიეთ GitHub-ს]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-kk/02-strings.xml b/AnkiDroid/src/main/res/values-kk/02-strings.xml
index 7cd1529bac88..e07cedd7eb06 100644
--- a/AnkiDroid/src/main/res/values-kk/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-kk/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar itemEnter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove itRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredThe image is too large, please insert the image manuallyThe video file is too large, please insert the video manuallyThe audio file is too large, please insert the audio manually
@@ -270,6 +272,11 @@
Import deckSomething went wrong, please try againDownload failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftYou can use other apps while the download is runningPlease check your network connectionDownload aborted, the external storage is not available
diff --git a/AnkiDroid/src/main/res/values-kk/10-preferences.xml b/AnkiDroid/src/main/res/values-kk/10-preferences.xml
index e4052f7f9b26..fdb3c24b7644 100644
--- a/AnkiDroid/src/main/res/values-kk/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-kk/10-preferences.xml
@@ -312,6 +312,7 @@
AboutContributorscontributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LicenseGPL-3.0 and AGPL-3.0 licenses, with the source code available on GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-km/02-strings.xml b/AnkiDroid/src/main/res/values-km/02-strings.xml
index 4b89245b4767..5814f8b8c865 100644
--- a/AnkiDroid/src/main/res/values-km/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-km/02-strings.xml
@@ -233,6 +233,8 @@
Edit toolbar itemEnter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove itRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredThe image is too large, please insert the image manuallyThe video file is too large, please insert the video manuallyThe audio file is too large, please insert the audio manually
@@ -267,6 +269,11 @@
Import deckSomething went wrong, please try againការទាញយកបានបរាជ័យ
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftអ្នកអាចប្រើកម្មវិធីដទៃទៀតអំឡុងពេលទាញយកសូមពិនិត្យការភ្ជាប់បណ្ដាញរបស់អ្នកDownload aborted, the external storage is not available
diff --git a/AnkiDroid/src/main/res/values-km/10-preferences.xml b/AnkiDroid/src/main/res/values-km/10-preferences.xml
index 20070b189ec5..d7023aaf500b 100644
--- a/AnkiDroid/src/main/res/values-km/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-km/10-preferences.xml
@@ -309,6 +309,7 @@
អំពីContributorscontributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LicenseGPL-3.0 and AGPL-3.0 licenses, with the source code available on GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-kn/02-strings.xml b/AnkiDroid/src/main/res/values-kn/02-strings.xml
index 21ffb6088742..81f1b887022d 100644
--- a/AnkiDroid/src/main/res/values-kn/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-kn/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar itemಆಯ್ಕೆಮಾಡಿದ ಪಠ್ಯದ ಮೊದಲು ಮತ್ತು ನಂತರ ಸೇರಿಸಲು HTML ಅನ್ನು ನಮೂದಿಸಿ\n\nಎಡಿಟ್ ಮಾಡಲು ಅಥವಾ ತೆಗೆದುಹಾಕಲು ಟೂಲ್ಬಾರ್ ಐಟಂ ಅನ್ನು ದೀರ್ಘವಾಗಿ ಒತ್ತಿರಿRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredThe image is too large, please insert the image manuallyThe video file is too large, please insert the video manuallyThe audio file is too large, please insert the audio manually
@@ -270,6 +272,11 @@
Import deckಏನೋ ತಪ್ಪಾಗಿದೆ, ದಯವಿಟ್ಟು ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿಡೌನ್ಲೋಡ್ ವಿಫಲವಾಗಿದೆ
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftಡೌನ್ಲೋಡ್ ಚಾಲನೆಯಲ್ಲಿರುವಾಗ ನೀವು ಇತರ ಅಪ್ಲಿಕೇಶನ್ಗಳನ್ನು ಬಳಸಬಹುದುದಯವಿಟ್ಟು ನಿಮ್ಮ ನೆಟ್ವರ್ಕ್ ಸಂಪರ್ಕವನ್ನು ಪರಿಶೀಲಿಸಿಡೌನ್ಲೋಡ್ ನಿಲ್ಲಿಸಲಾಗಿದೆ, ಯಾವುದೇ ಬಾಹ್ಯ ಸಂಗ್ರಹಣೆ ಲಭ್ಯವಿಲ್ಲ
diff --git a/AnkiDroid/src/main/res/values-kn/10-preferences.xml b/AnkiDroid/src/main/res/values-kn/10-preferences.xml
index 249f6af98a7b..8e34827515dd 100644
--- a/AnkiDroid/src/main/res/values-kn/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-kn/10-preferences.xml
@@ -310,6 +310,7 @@
ಕೊಡುಗೆದಾರರುಕೊಡುಗೆದಾರರು. ಪ್ರೋಗ್ರಾಮಿಂಗ್, ಅನುವಾದ, ದೇಣಿಗೆ ಮತ್ತು ಇನ್ನಷ್ಟು ಮೂಲಕ ನೀವು AnkiDroid ಗೆ ಸಹಾಯ ಮಾಡಬಹುದು!
]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>ಪರವಾನಗಿGPL-3.0 ಮತ್ತು AGPL-3.0 ಪರವಾನಗಿಗಳ ಮೂಲ ಕೋಡ್ ಅಡಿಯಲ್ಲಿ ಬಿಡುಗಡೆ ಮಾಡಲಾಗಿದೆ < href=\"%3$s\">GitHub ನಲ್ಲಿ ಲಭ್ಯವಿದೆ
]]>
diff --git a/AnkiDroid/src/main/res/values-ko/02-strings.xml b/AnkiDroid/src/main/res/values-ko/02-strings.xml
index b338bd892886..bdd971f16bae 100644
--- a/AnkiDroid/src/main/res/values-ko/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-ko/02-strings.xml
@@ -234,6 +234,8 @@
도구 모음 항목 편집선택된 텍스트 앞뒤로 삽입할 HTML을 입력해주십시오\n\n도구 모음 항목을 길게 눌러 편집 또는 제거합니다.도구 모음에서 제거할까요?
+ Button text is required
+ At least one HTML field is required이미지가 너무 큽니다. 수동으로 넣어 주세요.영상 파일이 너무 큽니다. 수동으로 넣어 주세요.오디오 파일이 너무 큽니다. 수동으로 넣어 주세요.
@@ -268,6 +270,11 @@
덱 가져오기오류가 발생했습니다. 다시 시도해 주세요.다운로드 실패
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left다운로드하는 동안 다른 앱을 이용하실 수 있습니다.네트워크 연결 상태를 확인해 주세요.다운로드가 중단되었습니다. 외부 저장 공간을 사용할 수 없습니다.
diff --git a/AnkiDroid/src/main/res/values-ko/10-preferences.xml b/AnkiDroid/src/main/res/values-ko/10-preferences.xml
index 8295db083ae6..14eaa1cfe130 100644
--- a/AnkiDroid/src/main/res/values-ko/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-ko/10-preferences.xml
@@ -307,6 +307,7 @@
정보기여자들기여자들입니다. 프로그래밍, 번역 기부와 그밖의 다양한 방법으로 AnkiDroid를 도와주실 수 있습니다!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>라이센스GPL-3.0과 AGPL-3.0 라이선스 하에 배포됩니다. 소스코드는 GitHub에서 확인할 수 있습니다.]]>라이선스를 따릅니다.]]>
diff --git a/AnkiDroid/src/main/res/values-ku/02-strings.xml b/AnkiDroid/src/main/res/values-ku/02-strings.xml
index 12c2a8eabfe9..acc90e531169 100644
--- a/AnkiDroid/src/main/res/values-ku/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-ku/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar itemEnter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove itRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredThe image is too large, please insert the image manuallyThe video file is too large, please insert the video manuallyThe audio file is too large, please insert the audio manually
@@ -270,6 +272,11 @@
Import deckSomething went wrong, please try againDownload failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftYou can use other apps while the download is runningPlease check your network connectionDownload aborted, the external storage is not available
diff --git a/AnkiDroid/src/main/res/values-ku/10-preferences.xml b/AnkiDroid/src/main/res/values-ku/10-preferences.xml
index fe965ce1afde..cbeb8e721571 100644
--- a/AnkiDroid/src/main/res/values-ku/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-ku/10-preferences.xml
@@ -312,6 +312,7 @@
AboutContributorscontributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LicenseGPL-3.0 and AGPL-3.0 licenses, with the source code available on GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-ky/02-strings.xml b/AnkiDroid/src/main/res/values-ky/02-strings.xml
index 7cd1529bac88..e07cedd7eb06 100644
--- a/AnkiDroid/src/main/res/values-ky/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-ky/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar itemEnter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove itRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredThe image is too large, please insert the image manuallyThe video file is too large, please insert the video manuallyThe audio file is too large, please insert the audio manually
@@ -270,6 +272,11 @@
Import deckSomething went wrong, please try againDownload failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftYou can use other apps while the download is runningPlease check your network connectionDownload aborted, the external storage is not available
diff --git a/AnkiDroid/src/main/res/values-ky/10-preferences.xml b/AnkiDroid/src/main/res/values-ky/10-preferences.xml
index e4052f7f9b26..fdb3c24b7644 100644
--- a/AnkiDroid/src/main/res/values-ky/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-ky/10-preferences.xml
@@ -312,6 +312,7 @@
AboutContributorscontributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LicenseGPL-3.0 and AGPL-3.0 licenses, with the source code available on GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-lt/02-strings.xml b/AnkiDroid/src/main/res/values-lt/02-strings.xml
index aed5c19b0ea6..a2a67e3d8239 100644
--- a/AnkiDroid/src/main/res/values-lt/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-lt/02-strings.xml
@@ -242,6 +242,8 @@
Edit toolbar itemĮveskite HTML, kuris bus įterptas prieš ir po pasirinkto teksto\n\nSpauskite įrankių juostos elementą, kad jį redaguotumėte arba pašalintumėteRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredVaizdas yra per didelis, įdėkite jį rankiniu būduVaizdo failas yra per didelis, įdėkite vaizdo įrašą rankiniu būduGarso failas yra per didelis, įdėkite garso įrašą rankiniu būdu
@@ -276,6 +278,11 @@
Import deckKažkas nepavyko, pabandykite dar kartąAtsisiųsti nepavyko
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftAtsisiuntimo metu galite naudoti kitas programasPatikrinkite tinklo ryšįAtsisiuntimas nutrauktas, išorinė saugykla nepasiekiama
diff --git a/AnkiDroid/src/main/res/values-lt/10-preferences.xml b/AnkiDroid/src/main/res/values-lt/10-preferences.xml
index 0cd44fdac642..9a83d4779b07 100644
--- a/AnkiDroid/src/main/res/values-lt/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-lt/10-preferences.xml
@@ -318,6 +318,7 @@
ApieBendraautoriaicontributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LicencijaGPL-3.0 and AGPL-3.0 licenses, with the source code available on GitHub]]>licencijas.]]>
diff --git a/AnkiDroid/src/main/res/values-lv/02-strings.xml b/AnkiDroid/src/main/res/values-lv/02-strings.xml
index 02408933b3e0..1274c4afaadd 100644
--- a/AnkiDroid/src/main/res/values-lv/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-lv/02-strings.xml
@@ -239,6 +239,8 @@
Edit toolbar itemJāievada HTML, ko ievietot pirms un pēc iezīmētā teksta\n\nRīkjoslas vienums ilgi jāpiespiež, lai to labotu vai noņemtuRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredAttēls ir pārāk liels, lūgums pašrocīgi ievietot attēluVideo datne ir pārāk liela, lūgums pašrocīgi ievietot videoSkaņu datne ir pārāk liela, lūgums ievietot skaņu pašrocīgi
@@ -273,6 +275,11 @@
Import deckKaut kas nogāja greizi, lūgums mēģināt vēlreizLejupielāde neizdevās
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftVar izmantot citas lietotnes, kamēr notiek lejupielādeLūgums pārbaudīt savu tīkla savienojumuLejupielāde priekšlaicīgi pārtraukta, ārējā krātuve nav pieejama
diff --git a/AnkiDroid/src/main/res/values-lv/10-preferences.xml b/AnkiDroid/src/main/res/values-lv/10-preferences.xml
index 1e86558d900f..050354862f6e 100644
--- a/AnkiDroid/src/main/res/values-lv/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-lv/10-preferences.xml
@@ -314,6 +314,7 @@
ParLīdzdalībniekilīdzdalībnieki. Tu arī vari palīdzēt AnkiDroid ar programmēšanu, tulkošanu, ziedojumiem un vēl.]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LicenceGPL-3.0 un AGPL-3.0 licencēm, un pirmkods ir pieejams GitHub]]>licencēm]]>
diff --git a/AnkiDroid/src/main/res/values-mk/02-strings.xml b/AnkiDroid/src/main/res/values-mk/02-strings.xml
index 1a4017fd3ea6..44672c9d88cf 100644
--- a/AnkiDroid/src/main/res/values-mk/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-mk/02-strings.xml
@@ -237,6 +237,8 @@
Edit toolbar itemEnter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove itRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredThe image is too large, please insert the image manuallyThe video file is too large, please insert the video manuallyThe audio file is too large, please insert the audio manually
@@ -271,6 +273,11 @@
Import deckSomething went wrong, please try againDownload failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftYou can use other apps while the download is runningPlease check your network connectionDownload aborted, the external storage is not available
diff --git a/AnkiDroid/src/main/res/values-mk/10-preferences.xml b/AnkiDroid/src/main/res/values-mk/10-preferences.xml
index 68bb71fbafd3..27d982363615 100644
--- a/AnkiDroid/src/main/res/values-mk/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-mk/10-preferences.xml
@@ -312,6 +312,7 @@
AboutContributorscontributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LicenseGPL-3.0 and AGPL-3.0 licenses, with the source code available on GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-ml/02-strings.xml b/AnkiDroid/src/main/res/values-ml/02-strings.xml
index e002ffe7a571..28cf14f2ed70 100644
--- a/AnkiDroid/src/main/res/values-ml/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-ml/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar itemതിരഞ്ഞെടുത്ത വാചകത്തിന് മുമ്പും ശേഷവും ചേർക്കുന്നതിന് HTML നൽകുക\n\nഒരു ടൂൾബാർ ഇനം എഡിറ്റ് ചെയ്യാനോ നീക്കം ചെയ്യാനോ ദീർഘനേരം അമർത്തുകRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredചിത്രം വളരെ വലുതാണ്, ചിത്രം നേരിട്ട് ചേർക്കുകവീഡിയോ ഫയൽ വളരെ വലുതാണ്, വീഡിയോ നേരിട്ട് ചേർക്കുകഓഡിയോ ഫയൽ വളരെ വലുതാണ്, ഓഡിയോ നേരിട്ട് ചേർക്കുക
@@ -270,6 +272,11 @@
Import deckഎന്തോ കുഴപ്പം സംഭവിച്ചു, വീണ്ടും ശ്രമിക്കുകഡൗൺലോഡ് പരാജയപ്പെട്ടു
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftഡൗൺലോഡ് പ്രവർത്തിക്കുമ്പോൾ നിങ്ങൾക്ക് മറ്റ് ആപ്പുകൾ ഉപയോഗിക്കാംനിങ്ങളുടെ നെറ്റ്വർക്ക് കണക്ഷൻ പരിശോധിക്കുകഡൗൺലോഡ് നിർത്തലാക്കി, ബാഹ്യ സംഭരണം ലഭ്യമല്ല
diff --git a/AnkiDroid/src/main/res/values-ml/10-preferences.xml b/AnkiDroid/src/main/res/values-ml/10-preferences.xml
index 352ab300d7fa..f34937a88d04 100644
--- a/AnkiDroid/src/main/res/values-ml/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-ml/10-preferences.xml
@@ -311,6 +311,7 @@
കുറിച്ച്സംഭാവന ചെയ്യുന്നവർcontributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>ലൈസൻസ്GPL-3.0 and AGPL-3.0 licenses, with the source code available on GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-mn/02-strings.xml b/AnkiDroid/src/main/res/values-mn/02-strings.xml
index 09f15e4e5b40..48b6e5c33816 100644
--- a/AnkiDroid/src/main/res/values-mn/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-mn/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar itemEnter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove itRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredThe image is too large, please insert the image manuallyThe video file is too large, please insert the video manuallyThe audio file is too large, please insert the audio manually
@@ -270,6 +272,11 @@
Import deckАлдаа гарлаа, та дахин оролдоно ууТатан авалт амжилтгүй
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftЭнэ татан авалт явагдаж байхад та өөр зүйл хийж байж болно.Та интэрнэт холболтоо шалгана ууDownload aborted, the external storage is not available
diff --git a/AnkiDroid/src/main/res/values-mn/10-preferences.xml b/AnkiDroid/src/main/res/values-mn/10-preferences.xml
index 857ec034a0ef..25da17c616a3 100644
--- a/AnkiDroid/src/main/res/values-mn/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-mn/10-preferences.xml
@@ -312,6 +312,7 @@
AboutContributorscontributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LicenseGPL-3.0 and AGPL-3.0 licenses, with the source code available on GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-mr/02-strings.xml b/AnkiDroid/src/main/res/values-mr/02-strings.xml
index 1090a2965493..cfad2958fe10 100644
--- a/AnkiDroid/src/main/res/values-mr/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-mr/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar itemनिवडलेल्या मजकुराच्या आधी आणि नंतर घालण्यासाठी HTML प्रविष्ट करा\n\nटूलबार आयटम संपादित करण्यासाठी किंवा काढण्यासाठी बराच वेळ दाबाRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredThe image is too large, please insert the image manuallyThe video file is too large, please insert the video manuallyThe audio file is too large, please insert the audio manually
@@ -270,6 +272,11 @@
Import deckकाहीतरी चूक झाली, कृपया पुन्हा प्रयत्न कराडाउनलोड अयशस्वी
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftडाउनलोड सुरू असताना तुम्ही इतर ॲप्स वापरू शकताकृपया तुमचे नेटवर्क कनेक्शन तपासाडाउनलोड रद्द केले, बाह्य संचयन उपलब्ध नाही
diff --git a/AnkiDroid/src/main/res/values-mr/10-preferences.xml b/AnkiDroid/src/main/res/values-mr/10-preferences.xml
index c9a0ca29c13e..0b89634533ab 100644
--- a/AnkiDroid/src/main/res/values-mr/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-mr/10-preferences.xml
@@ -309,6 +309,7 @@
बद्दलयोगदानकर्तेयोगदानकर्ते आहेत. तुम्ही प्रोग्रामिंग, भाषांतर, देणगी आणि अधिक करून देखील AnkiDroid ला मदत करू शकता!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>परवानाGPL-3.0 आणि AGPL-3.0 परवान्याखाली रिलीझ केले आहे. स्रोत कोड GitHub वर उपलब्ध आहे]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-ms/02-strings.xml b/AnkiDroid/src/main/res/values-ms/02-strings.xml
index 4706a2b4a32b..ab2c4e2a65f5 100644
--- a/AnkiDroid/src/main/res/values-ms/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-ms/02-strings.xml
@@ -233,6 +233,8 @@
Edit toolbar itemMasukkan HTML sebelum dan selepas pilihan teks\n\nTekan lama item bar alat untuk sunting atau buangnyaRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredThe image is too large, please insert the image manuallyThe video file is too large, please insert the video manuallyThe audio file is too large, please insert the audio manually
@@ -267,6 +269,11 @@
Import deckkesulitan berlaku, sila cuba lagiMemuat turun gagal
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftAnda boleh guna aplikasi lain sementara memuat turunSila semak sambungan rangkaian andaMuat turun batal, tiada storan luaran tersedia
diff --git a/AnkiDroid/src/main/res/values-ms/10-preferences.xml b/AnkiDroid/src/main/res/values-ms/10-preferences.xml
index c788f92981ba..2555b02600be 100644
--- a/AnkiDroid/src/main/res/values-ms/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-ms/10-preferences.xml
@@ -309,6 +309,7 @@
PerihalPara Penyumbangpara penyumbang hebat. Anda juga boleh bantu AnkiDroid melalui pengaturcaraan, penterjemahan, pendermaan dan banyak lagi!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LesenGPL-3.0 dan AGPL-3.0, dengan kod sumber tersedia di GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-my/02-strings.xml b/AnkiDroid/src/main/res/values-my/02-strings.xml
index 18d3e307047a..ac9d1a29c432 100644
--- a/AnkiDroid/src/main/res/values-my/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-my/02-strings.xml
@@ -233,6 +233,8 @@
Edit toolbar itemEnter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove itRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredThe image is too large, please insert the image manuallyThe video file is too large, please insert the video manuallyThe audio file is too large, please insert the audio manually
@@ -267,6 +269,11 @@
Import deckSomething went wrong, please try againDownload failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftYou can use other apps while the download is runningPlease check your network connectionDownload aborted, the external storage is not available
diff --git a/AnkiDroid/src/main/res/values-my/10-preferences.xml b/AnkiDroid/src/main/res/values-my/10-preferences.xml
index 047d011700d0..f99256881487 100644
--- a/AnkiDroid/src/main/res/values-my/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-my/10-preferences.xml
@@ -309,6 +309,7 @@
AboutContributorscontributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LicenseGPL-3.0 and AGPL-3.0 licenses, with the source code available on GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-nl/02-strings.xml b/AnkiDroid/src/main/res/values-nl/02-strings.xml
index 33edd2304344..c5f79a5669fc 100644
--- a/AnkiDroid/src/main/res/values-nl/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-nl/02-strings.xml
@@ -236,6 +236,8 @@
Pas toolbar item aanVoer HTML in die voor en na de geselecteerde tekst moet worden ingevoegd.\n\nDruk lang op een item op de werkbalk om het te bewerken of te verwijderen.Toolbar item verwijderen?
+ Button text is required
+ At least one HTML field is requiredDe afbeelding is te groot, voeg de afbeelding handmatig inHet videobestand is te groot, voeg de video handmatig inHet audiobestand is te groot, voeg de audio handmatig in
@@ -270,6 +272,11 @@
Importeer leersetEr is iets mis gegaan, probeer het alstublief opnieuw.Download mislukt
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftU kunt andere apps gebruiken terwijl de download bezig isControleer alsrublief uw internetconnectieDownload afgebroken, externe opslag is niet beschikbaar
diff --git a/AnkiDroid/src/main/res/values-nl/10-preferences.xml b/AnkiDroid/src/main/res/values-nl/10-preferences.xml
index c9183f74dd7b..fc138adbe311 100644
--- a/AnkiDroid/src/main/res/values-nl/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-nl/10-preferences.xml
@@ -310,6 +310,7 @@
OverBijdragersmedewerkers. U kunt AnkiDroid ook helpen door te programmeren, te vertalen, te doneren en meer!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LicentieGPL-3.0 en AGPL-3.0 licenties, met de broncode beschikbaar op GitHub]]>licenties]]>
diff --git a/AnkiDroid/src/main/res/values-nn/02-strings.xml b/AnkiDroid/src/main/res/values-nn/02-strings.xml
index c2318ca8d5f2..389c45d0e877 100644
--- a/AnkiDroid/src/main/res/values-nn/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-nn/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar itemEnter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove itRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredThe image is too large, please insert the image manuallyThe video file is too large, please insert the video manuallyThe audio file is too large, please insert the audio manually
@@ -270,6 +272,11 @@
Import deckNoko gjekk gale. Prøv på nytt.Nedlasting feilet
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftYou can use other apps while the download is runningPlease check your network connectionDownload aborted, the external storage is not available
diff --git a/AnkiDroid/src/main/res/values-nn/10-preferences.xml b/AnkiDroid/src/main/res/values-nn/10-preferences.xml
index f7ef199c2573..893d3b47aee2 100644
--- a/AnkiDroid/src/main/res/values-nn/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-nn/10-preferences.xml
@@ -312,6 +312,7 @@
OmMedvirkendecontributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LisensGPL-3.0 and AGPL-3.0 licenses, with the source code available on GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-no/02-strings.xml b/AnkiDroid/src/main/res/values-no/02-strings.xml
index 7a46131dc929..ffe60c19320a 100644
--- a/AnkiDroid/src/main/res/values-no/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-no/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar itemEnter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove itRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredThe image is too large, please insert the image manuallyThe video file is too large, please insert the video manuallyThe audio file is too large, please insert the audio manually
@@ -270,6 +272,11 @@
Import deckNoe gikk galt. Vennligst prøv igjenNedlasting feilet
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftYou can use other apps while the download is runningPlease check your network connectionDownload aborted, the external storage is not available
diff --git a/AnkiDroid/src/main/res/values-no/10-preferences.xml b/AnkiDroid/src/main/res/values-no/10-preferences.xml
index 35cd2ef3b2ab..17bc467004f5 100644
--- a/AnkiDroid/src/main/res/values-no/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-no/10-preferences.xml
@@ -312,6 +312,7 @@
OmMedvirkendecontributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LisensGPL-3.0 and AGPL-3.0 licenses, with the source code available on GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-or/02-strings.xml b/AnkiDroid/src/main/res/values-or/02-strings.xml
index 0616e0566827..0b92f969cc9f 100644
--- a/AnkiDroid/src/main/res/values-or/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-or/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar itemମନୋନୀତ ପାଠ୍ୟ ପୂର୍ବରୁ ଏବଂ ପରେ ସନ୍ନିବେଶ କରିବା ଲାଗି HTML ପ୍ରବେଶ କରନ୍ତୁ\n\nଗୋଟିଏ ଉପକରଣ-ଦଣ୍ଡିକାର ଆଇଟମ୍ କୁ ସମ୍ପାଦନ ବା ହଟାଇବା ପାଇଁ ତା\' ଉପରେ ଲମ୍ବା ସ୍ପର୍ଶ କରନ୍ତୁRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredThe image is too large, please insert the image manuallyଵିଡ଼ିଓ ଫାଇଲ୍ଟି ଅତ୍ୟଧିକ ବଡ଼ ଅଟେ, ଦୟାକରି ହସ୍ତକୃତ ଭାବେ ଵିଡ଼ିଓଟିକୁ ସନ୍ନିବେଶ କରନ୍ତୁଅଡ଼ିଓ ଫାଇଲ୍ଟି ଅତ୍ୟଧିକ ବଡ଼ ଅଟେ, ଦୟାକରି ହସ୍ତକୃତ ଭାବେ ଅଡ଼ିଓଟିକୁ ସନ୍ନିବେଶ କରନ୍ତୁ
@@ -270,6 +272,11 @@
Import deckକିଛି ଗଡ଼ବଡ଼ ଦେଖାଯାଇଛି, ଦୟାକରି ପୁଣିଥରେ ଚେଷ୍ଟା କରନ୍ତୁଡାଉନଲୋଡ୍ ଵିଫଳ ହେଲା
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftଡାଉନଲୋଡ୍ ଚାଲୁଥିବା ସମୟରେ ଆପଣ ଅନ୍ୟ ଆପସ୍ ବ୍ୟବହାର କରିପାରିବେଦୟାକରି ଆପଣଙ୍କ ନେଟୱର୍କ ସଂଯୋଜନା ଯାଞ୍ଚ କରନ୍ତୁଡାଉନଲୋଡ୍ ପରିତ୍ୟାଗ ହୋଇଛି, ବହିଃ-ଷ୍ଟୋରେଜ୍ ଉପଲବ୍ଧ ନାହିଁ
diff --git a/AnkiDroid/src/main/res/values-or/10-preferences.xml b/AnkiDroid/src/main/res/values-or/10-preferences.xml
index 9ce15e4f072b..70189639d661 100644
--- a/AnkiDroid/src/main/res/values-or/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-or/10-preferences.xml
@@ -311,6 +311,7 @@
ସମ୍ବନ୍ଧରେଯୋଗଦାତାଯୋଗଦାତା। ପ୍ରୋଗ୍ରାମିଂ, ଅନୁଵାଦ, ଦାନ ଓ ଆହୁରି ଅନେକ କିଛି ଦ୍ୱାରା ଆପଣ ମଧ୍ୟ AnkiDroidକୁ ସାହାଯ୍ୟ କରିପାରିବେ!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>ଲାଇସେନ୍ସGPL-3.0 ଏବଂ AGPL-3.0 ଲାଇସେନ୍ସ ଅଧୀନରେ ପ୍ରକାଶିତ କରାଯାଏ, ତାହା ସହ ସୋର୍ସ କୋଡ୍ କୁ GitHubରେ ଉପଲବ୍ଧ କରାଇ ଦିଆଯାଇଥାଏ]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-pa/02-strings.xml b/AnkiDroid/src/main/res/values-pa/02-strings.xml
index b72bd15de596..ccee318ec9d3 100644
--- a/AnkiDroid/src/main/res/values-pa/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-pa/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar itemਚੁਣੇ ਗਏ ਟੈਕਸਟ ਤੋਂ ਪਹਿਲਾਂ ਅਤੇ ਬਾਅਦ ਵਿੱਚ ਸੰਮਿਲਿਤ ਕਰਨ ਲਈ HTML ਦਾਖਲ ਕਰੋ\n\nਟੂਲਬਾਰ ਆਈਟਮ ਨੂੰ ਸੰਪਾਦਿਤ ਕਰਨ ਜਾਂ ਹਟਾਉਣ ਲਈ ਦੇਰ ਤੱਕ ਦਬਾਓRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredਚਿੱਤਰ ਬਹੁਤ ਵੱਡਾ ਹੈ, ਕਿਰਪਾ ਕਰਕੇ ਚਿੱਤਰ ਨੂੰ ਹੱਥੀਂ ਪਾਓਵੀਡੀਓ ਫਾਈਲ ਬਹੁਤ ਵੱਡੀ ਹੈ, ਕਿਰਪਾ ਕਰਕੇ ਹੱਥੀਂ ਵੀਡੀਓ ਪਾਓਆਡੀਓ ਫ਼ਾਈਲ ਬਹੁਤ ਵੱਡੀ ਹੈ, ਕਿਰਪਾ ਕਰਕੇ ਆਡੀਓ ਨੂੰ ਹੱਥੀਂ ਪਾਓ
@@ -270,6 +272,11 @@
Import deckਕੁਝ ਗਲਤ ਹੋ ਗਿਆ, ਕਿਰਪਾ ਕਰਕੇ ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋਡਾਊਨਲੋਡ ਅਸਫਲ ਰਿਹਾ
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftਜਦੋਂ ਡਾਊਨਲੋਡ ਚੱਲ ਰਿਹਾ ਹੋਵੇ ਤਾਂ ਤੁਸੀਂ ਹੋਰ ਐਪਸ ਦੀ ਵਰਤੋਂ ਕਰ ਸਕਦੇ ਹੋਕਿਰਪਾ ਕਰਕੇ ਆਪਣੇ ਨੈੱਟਵਰਕ ਕਨੈਕਸ਼ਨ ਦੀ ਜਾਂਚ ਕਰੋਡਾਊਨਲੋਡ ਅਧੂਰਾ ਛੱਡਿਆ ਗਿਆ, ਕੋਈ ਬਾਹਰੀ ਸਟੋਰੇਜ ਉਪਲਬਧ ਨਹੀਂ ਹੈ
diff --git a/AnkiDroid/src/main/res/values-pa/10-preferences.xml b/AnkiDroid/src/main/res/values-pa/10-preferences.xml
index c8153ae3fd64..a8d7225331df 100644
--- a/AnkiDroid/src/main/res/values-pa/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-pa/10-preferences.xml
@@ -310,6 +310,7 @@
ਬਾਰੇਯੋਗਦਾਨ ਪਾਉਣ ਵਾਲੇਯੋਗਦਾਨ ਹਨ। ਤੁਸੀਂ ਪ੍ਰੋਗਰਾਮਿੰਗ, ਅਨੁਵਾਦ, ਦਾਨ ਅਤੇ ਹੋਰ ਦੁਆਰਾ AnkiDroid ਦੀ ਮਦਦ ਵੀ ਕਰ ਸਕਦੇ ਹੋ!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>ਲਾਇਸੰਸGPL-3.0 ਅਤੇ AGPL-3.0 ਲਾਇਸੰਸ ਦੇ ਅਧੀਨ ਜਾਰੀ ਕੀਤਾ ਗਿਆ ਹੈ। , ਸਰੋਤ ਕੋਡ GitHub \'ਤੇ ਉਪਲਬਧ ਹੈ]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-pl/02-strings.xml b/AnkiDroid/src/main/res/values-pl/02-strings.xml
index 3021cfceb443..b59f31c1ddb9 100644
--- a/AnkiDroid/src/main/res/values-pl/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-pl/02-strings.xml
@@ -242,6 +242,8 @@
Edytuj element paska narzędziWprowadź kod HTML, który będzie wstawiony przed i po wybranym tekście\n\nPrzytrzymaj element paska narzędzi, aby go usunąćUsunąć element paska narzędzi?
+ Button text is required
+ At least one HTML field is requiredObraz jest zbyt duży, wstaw obraz ręczniePlik wideo jest za duży, wstaw go ręczniePlik audio jest za duży, wstaw go ręcznie
@@ -276,6 +278,11 @@
Importuj talięCoś poszło nie tak, spróbuj ponowniePobieranie nie powiodło się
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftMożesz używać innych aplikacji podczas pobieraniaSprawdź swoje połączenie internetowePobieranie przerwane, pamięć zewnętrzna nie jest dostępna
diff --git a/AnkiDroid/src/main/res/values-pl/10-preferences.xml b/AnkiDroid/src/main/res/values-pl/10-preferences.xml
index 05155fac2429..fa073ec462c3 100644
--- a/AnkiDroid/src/main/res/values-pl/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-pl/10-preferences.xml
@@ -317,6 +317,7 @@
O programieWspółtwórcywspółtwórcy. Możesz również pomóc AnkiDroid poprzez programowanie, tłumaczenie, darowiznę i więcej!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LicencjaGPL-3.0 i AGPL-3.0 z kodem źródłowym dostępnym na GitHubie]]>licencji open source ]]>
diff --git a/AnkiDroid/src/main/res/values-pt-rBR/02-strings.xml b/AnkiDroid/src/main/res/values-pt-rBR/02-strings.xml
index f885a929159a..c66f0b1db481 100644
--- a/AnkiDroid/src/main/res/values-pt-rBR/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-pt-rBR/02-strings.xml
@@ -236,6 +236,8 @@
Editar item na Barra de FerramentasInsira o código HTML que deve ser inserido antes e depois do texto selecionado\n\nMantenha o item pressionado para editá-lo ou removê-loRemover item na Barra de Ferramentas
+ Button text is required
+ At least one HTML field is requiredA imagem é muito grande, por favor insira a imagem manualmenteO arquivo de vídeo é muito grande, por favor insira o vídeo manualmenteO arquivo de áudio é muito grande, por favor insira o áudio manualmente
@@ -270,6 +272,11 @@
Import deckAlgo deu errado. Por favor, tente novamenteDownload falhou
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftVocê pode usar outros apps enquanto o download está rodandoPor favor, verifique sua conexão de redeTransferência abortada, o armazenamento externo não está disponível
diff --git a/AnkiDroid/src/main/res/values-pt-rBR/10-preferences.xml b/AnkiDroid/src/main/res/values-pt-rBR/10-preferences.xml
index 8ac597d53598..6542bc66cdae 100644
--- a/AnkiDroid/src/main/res/values-pt-rBR/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-pt-rBR/10-preferences.xml
@@ -311,6 +311,7 @@
SobreColaboradorescolaboradores. Você também pode ajudar o AnkiDroid programando, traduzindo, doando e mais!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LicençaGPL-3.0 e AGPL-3.0 , com o código-fonte disponível no GitHub]]>licenças de código aberto.]]>
diff --git a/AnkiDroid/src/main/res/values-pt-rPT/02-strings.xml b/AnkiDroid/src/main/res/values-pt-rPT/02-strings.xml
index 83bc465127f8..88db54cfefa0 100644
--- a/AnkiDroid/src/main/res/values-pt-rPT/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-pt-rPT/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar itemIntroduza o HTML a ser inserido antes e depois do texto seleccionado.\n\nPressione durante algum tempo sobre uma das entradas na Barra de Ferramentas para a editar ou remover.Remove toolbar item?
+ Button text is required
+ At least one HTML field is requiredA imagem é demasiado grande, por favor insira-a manualmente.O vídeo é demasiado grande, por favor insira-o manualmente.O ficheiro de áudio é demasiado grande, por favor insira-o manualmente.
@@ -270,6 +272,11 @@
Import deckOcorreu um erro. Tente novamenteA transferência falhou
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftPode utilizar outras aplicações enquanto a transferência decorre.Por favor, verifique a sua ligação à redeTransferência cancelada, o armazenamento externo não está disponível.
diff --git a/AnkiDroid/src/main/res/values-pt-rPT/10-preferences.xml b/AnkiDroid/src/main/res/values-pt-rPT/10-preferences.xml
index e5612916a553..aab4e7c231a0 100644
--- a/AnkiDroid/src/main/res/values-pt-rPT/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-pt-rPT/10-preferences.xml
@@ -312,6 +312,7 @@
SobreColaboradorescolaboradores. Também pode ajudar o AnkiDroid, ao programar, traduzir, doar ou doutras formas!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LicençaGPL-3.0 e AGPL-3.0 , com o código-fonte disponível no GitHub.]]>licenças de código aberto]]>
diff --git a/AnkiDroid/src/main/res/values-ro/02-strings.xml b/AnkiDroid/src/main/res/values-ro/02-strings.xml
index f18e08142d12..447bdc12bab2 100644
--- a/AnkiDroid/src/main/res/values-ro/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-ro/02-strings.xml
@@ -239,6 +239,8 @@
Edit toolbar itemEnter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove itRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredThe image is too large, please insert the image manuallyThe video file is too large, please insert the video manuallyThe audio file is too large, please insert the audio manually
@@ -273,6 +275,11 @@
Import deckSomething went wrong, please try againDownload failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftYou can use other apps while the download is runningPlease check your network connectionDownload aborted, the external storage is not available
diff --git a/AnkiDroid/src/main/res/values-ro/10-preferences.xml b/AnkiDroid/src/main/res/values-ro/10-preferences.xml
index e4822f76030b..b5a75edd77d2 100644
--- a/AnkiDroid/src/main/res/values-ro/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-ro/10-preferences.xml
@@ -315,6 +315,7 @@
AboutContributorscontributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LicenseGPL-3.0 and AGPL-3.0 licenses, with the source code available on GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-ru/02-strings.xml b/AnkiDroid/src/main/res/values-ru/02-strings.xml
index 895e268a3adf..54afb3bbab07 100644
--- a/AnkiDroid/src/main/res/values-ru/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-ru/02-strings.xml
@@ -242,6 +242,8 @@
Редактировать элемент панелиВведите HTML, который будет вставлен до и после выделенного текста.\n\nНажмите и удерживайте элемент панели инструментов, чтобы изменить или удалить его.Удалить элемент панели инструментов?
+ Button text is required
+ At least one HTML field is requiredИзображение слишком большое. Вставьте изображение вручнуюВидео слишком большое. Вставьте видео вручнуюАудио слишком большое. Вставьте аудио вручную
@@ -276,6 +278,11 @@
Импортировать колодуЧто-то пошло не так. Попробуйте ещё разЗагрузка не удалась
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftВы можете использовать другие приложения во время загрузкиПроверьте подключение к ИнтернетуЗагрузка прервана, внешняя память недоступна
diff --git a/AnkiDroid/src/main/res/values-ru/10-preferences.xml b/AnkiDroid/src/main/res/values-ru/10-preferences.xml
index 1a1e275573f2..7d8119364938 100644
--- a/AnkiDroid/src/main/res/values-ru/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-ru/10-preferences.xml
@@ -316,6 +316,7 @@
О программеУчастникиучастники. Вы также можете помочь AnkiDroid программируя, делая переводы, пожертвования и другое!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>ЛицензияGPL-3.0 и AGPL-3.0 с исходным кодом на GitHub]]>с открытым исходным кодом]]>
diff --git a/AnkiDroid/src/main/res/values-sat/02-strings.xml b/AnkiDroid/src/main/res/values-sat/02-strings.xml
index 709e5e7e3f11..9970041730f2 100644
--- a/AnkiDroid/src/main/res/values-sat/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-sat/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar itemᱵᱟᱪᱷᱟᱣ ᱟᱠᱟᱱ ᱴᱮᱠᱥᱴ ᱨᱮᱱᱟᱜ ᱯᱩᱭᱞᱩ ᱟᱨ ᱛᱟᱭᱚᱢ ᱨᱮ ᱥᱮᱞᱮᱫᱚᱜ ᱞᱟᱹᱜᱤᱫ HTML ᱮᱢᱢᱢᱮ\n\nᱥᱟᱯᱲᱟᱣ ᱥᱮ ᱦᱮᱡ ᱞᱟᱹᱜᱤᱫ ᱢᱤᱫ ᱴᱩᱞᱵᱟᱨ ᱮᱢᱮᱰᱤᱴ ᱟᱹᱰᱤ ᱫᱷᱟᱣ ᱞᱤᱱ ᱢᱮRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredᱪᱤᱛᱟᱹᱨ ᱫᱚ ᱟᱹᱰᱤ ᱢᱟᱨᱟᱝ ᱠᱟᱱᱟ, ᱫᱟᱭᱟ ᱠᱟᱛᱮ ᱪᱤᱛᱟᱹᱨ ᱥᱮᱞᱮᱫ ᱢᱮᱵᱷᱤᱰᱤᱭᱳ ᱯᱷᱤᱞᱤᱢ ᱫᱚ ᱟᱹᱰᱤ ᱢᱟᱨᱟᱝ ᱠᱟᱱᱟ, ᱫᱟᱭᱟᱠᱟᱛᱮ ᱵᱷᱤᱰᱤᱭᱳ ᱥᱮᱞᱮᱫᱽ ᱢᱮᱚᱰᱤᱭᱚ ᱯᱷᱤᱞ ᱫᱚ ᱟᱹᱰᱤ ᱢᱟᱨᱟᱝ ᱠᱟᱱᱟ, ᱫᱟᱭᱟ ᱠᱟᱛᱮ ᱚᱰᱤᱭᱚ ᱥᱮᱞᱮᱫ ᱢᱮ
@@ -270,6 +272,11 @@
Import deckᱪᱮᱫᱪᱚᱝ ᱦᱤᱲᱤᱡ ᱟᱠᱟᱱᱟ, ᱟᱨᱢᱤᱫ ᱫᱷᱟᱣ ᱪᱮᱥᱴᱟᱭ ᱢᱮᱰᱟᱩᱱᱞᱳᱰ ᱵᱟᱝᱜᱟᱱ ᱞᱮᱱᱟ
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftᱰᱟᱩᱱᱞᱳᱰᱤᱝ ᱚᱠᱛᱚᱨᱮ ᱟᱢ ᱮᱴᱟᱜ ᱟᱯᱥ ᱮᱢ ᱵᱮᱵᱷᱟᱨ ᱫᱟᱲᱮ ᱟᱫ-ᱟᱫᱟᱭᱟᱠᱟᱛᱮ ᱟᱢᱟᱜ ᱱᱮᱴᱣᱟᱨᱠ ᱠᱳᱱᱮᱠᱥᱚᱱ ᱡᱟᱸᱪ ᱢᱮᱰᱟᱩᱱᱞᱚᱰ ᱵᱚᱫᱚᱞ ᱟᱠᱟᱱᱟ, ᱵᱟᱦᱨᱮ ᱥᱴᱳᱨᱮᱡᱽ ᱵᱟᱭ ᱦᱟᱹᱴᱤᱧ ᱟᱠᱟᱱᱟ
diff --git a/AnkiDroid/src/main/res/values-sat/10-preferences.xml b/AnkiDroid/src/main/res/values-sat/10-preferences.xml
index aebf068a3691..aa8c0a47a6a7 100644
--- a/AnkiDroid/src/main/res/values-sat/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-sat/10-preferences.xml
@@ -311,6 +311,7 @@
ᱤᱫᱤ ᱠᱟᱛᱮᱥᱮᱞᱮᱫᱤᱭᱟᱹ ᱠᱚcontributors ᱠᱟᱱᱟ ᱠᱚ ᱾ ᱟᱢ ᱯᱨᱳᱜᱨᱟᱢ, ᱛᱚᱨᱡᱚᱢᱟ, ᱥᱚᱦᱚᱫ ᱟᱨ ᱦᱚᱲᱟᱜ ᱠᱟᱛᱮ ᱟᱢᱦᱚᱸ AnkiDroid ᱜᱚᱲᱚ ᱫᱟᱲᱮᱭᱟᱜᱼᱟᱢ!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>ᱞᱟᱭᱥᱮᱱᱥGPL-3.0 ᱟᱨ AGPL-3.0 ᱞᱟᱭᱥᱮᱱᱥ ᱟᱛᱮ ᱨᱤᱞᱤᱡᱽ ᱟᱠᱟᱱᱟ, ᱡᱟᱦᱟᱸ ᱨᱮᱱᱟᱜ ᱥᱳᱨᱥ ᱠᱚᱰ GitHub ᱨᱮ ᱢᱮᱱᱟᱜᱼᱟ]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-sc/02-strings.xml b/AnkiDroid/src/main/res/values-sc/02-strings.xml
index 4810d605b88b..4de97120f90a 100644
--- a/AnkiDroid/src/main/res/values-sc/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-sc/02-strings.xml
@@ -238,6 +238,8 @@
Edit toolbar itemInserta s\'HTML de pònnere in antis e a pustis de su testu seletzionadu\n\nIncarca a longu in un\'elementu de sa barra de sas ainas pro lu modificare o bogareRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredS\'immàgine est tropu manna, inserta·la in manera manualeCustu documentu vìdeu est tropu mannu, inserta·lu in manera manualeCustu documentu àudio est tropu mannu, inserta·lu in manera manuale
@@ -272,6 +274,11 @@
Import deckB\'at àpidu carchi problema. Torra a proareIscarrigamentu fallidu
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftPodes impreare àteras aplicatziones in su mentres chi s\'iscarrigamentu est in cursuVerìfica sa connessione de retze tuaIscarrigamentu annulladu, sa memòria esterna no est a disponimentu
diff --git a/AnkiDroid/src/main/res/values-sc/10-preferences.xml b/AnkiDroid/src/main/res/values-sc/10-preferences.xml
index 7955989b2cdf..bb04a698099b 100644
--- a/AnkiDroid/src/main/res/values-sc/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-sc/10-preferences.xml
@@ -311,6 +311,7 @@
InformatzionesContribuidorescontribuidores istravanados nostros. Fintzas tue podes agiudare AnkiDroid programmende, bortende, donende e àteru galu!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LitzèntziaGPL-3.0 e AGPL-3.0, cun su còdighe de orìgine a disponimentu in GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-sk/02-strings.xml b/AnkiDroid/src/main/res/values-sk/02-strings.xml
index faabb2d0419b..50a801c0cc0b 100644
--- a/AnkiDroid/src/main/res/values-sk/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-sk/02-strings.xml
@@ -242,6 +242,8 @@
Upraviť položku lišty nástrojovVložte HTML, ktorý má byť pred a po označenom texte\n\nPodržte dlho položku nástrojovej lišty na jej úpravu alebo vymazanieVymazať položku lišty nástrojov?
+ Button text is required
+ At least one HTML field is requiredThe image is too large, please insert the image manuallyVideo súbor je príliš veľký, prosím vložte video ručne.The audio file is too large, please insert the audio manually
@@ -276,6 +278,11 @@
Import deckNiečo sa pokazilo, prosím, skúste znovuSťahovanie zlyhalo
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftMôžete používať iné aplikácie kým prebieha sťahovanieProsím, skontrolujte vaše sieťové pripojenieDownload aborted, the external storage is not available
diff --git a/AnkiDroid/src/main/res/values-sk/10-preferences.xml b/AnkiDroid/src/main/res/values-sk/10-preferences.xml
index dd484891b10a..413491d467f0 100644
--- a/AnkiDroid/src/main/res/values-sk/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-sk/10-preferences.xml
@@ -57,7 +57,7 @@
Security: Unsafe JavaScript call blocked.\nIf you trust all cards and templates in your collection, this can be unblocked in the advanced settingsPovoliť kartičkám spúšťanie iných aplikácií
- Kartičky môžu otvoriť iné aplikácia vo vašom zariadení, potencionálne spúšťajúc nechcenú aktivitu.\nPonechajte vypnuté s výnimkou že veríte všetkým kartičkám a šablónam vo vašej kolekcii.
+ Kartičky môžu otvoriť iné aplikácie vo vašom zariadení, potencionálne spúšťajúc nechcenú aktivitu.\nPonechajte vypnuté s výnimkou že veríte všetkým kartičkám a šablónam vo vašej kolekcii.Security: Card blocked from launching another app.\nIf you trust all cards and templates in your collection, this can be unblocked in the advanced settingsHrúbka ťahu
@@ -318,6 +318,7 @@
O násPrispievateliadobrovoľníci a prispievatelia. Môžete tiež pomôcť programovaním, prekladaním, príspevkami a ďalším!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LicenciaGPL-3.0 a AGPL-3.0, so zdrojovým kódom dostupným na GitHube]]>licenciami]]>
diff --git a/AnkiDroid/src/main/res/values-sl/02-strings.xml b/AnkiDroid/src/main/res/values-sl/02-strings.xml
index 04f6442b2bff..09ffcc236941 100644
--- a/AnkiDroid/src/main/res/values-sl/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-sl/02-strings.xml
@@ -242,6 +242,8 @@
Edit toolbar itemEnter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove itRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredThe image is too large, please insert the image manuallyThe video file is too large, please insert the video manuallyThe audio file is too large, please insert the audio manually
@@ -276,6 +278,11 @@
Import deckSomething went wrong, please try againDownload failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftYou can use other apps while the download is runningPlease check your network connectionDownload aborted, the external storage is not available
diff --git a/AnkiDroid/src/main/res/values-sl/10-preferences.xml b/AnkiDroid/src/main/res/values-sl/10-preferences.xml
index 9be3094b4a61..32408263d57f 100644
--- a/AnkiDroid/src/main/res/values-sl/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-sl/10-preferences.xml
@@ -318,6 +318,7 @@
AboutContributorscontributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LicenseGPL-3.0 and AGPL-3.0 licenses, with the source code available on GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-sq/02-strings.xml b/AnkiDroid/src/main/res/values-sq/02-strings.xml
index f11af2156c59..a2164602533c 100644
--- a/AnkiDroid/src/main/res/values-sq/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-sq/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar itemEnter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove itRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredThe image is too large, please insert the image manuallyThe video file is too large, please insert the video manuallyThe audio file is too large, please insert the audio manually
@@ -270,6 +272,11 @@
Import deckSomething went wrong, please try againDownload failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftYou can use other apps while the download is runningPlease check your network connectionDownload aborted, the external storage is not available
diff --git a/AnkiDroid/src/main/res/values-sq/10-preferences.xml b/AnkiDroid/src/main/res/values-sq/10-preferences.xml
index 3afa08a1ced7..39b6f08a3552 100644
--- a/AnkiDroid/src/main/res/values-sq/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-sq/10-preferences.xml
@@ -312,6 +312,7 @@
AboutContributorscontributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LicenseGPL-3.0 and AGPL-3.0 licenses, with the source code available on GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-sr/02-strings.xml b/AnkiDroid/src/main/res/values-sr/02-strings.xml
index 10ab206c1e13..f49a3c196ab6 100644
--- a/AnkiDroid/src/main/res/values-sr/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-sr/02-strings.xml
@@ -239,6 +239,8 @@
Edit toolbar itemEnter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove itRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredThe image is too large, please insert the image manuallyThe video file is too large, please insert the video manuallyThe audio file is too large, please insert the audio manually
@@ -273,6 +275,11 @@
Import deckSomething went wrong, please try againDownload failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftYou can use other apps while the download is runningPlease check your network connectionDownload aborted, the external storage is not available
diff --git a/AnkiDroid/src/main/res/values-sr/10-preferences.xml b/AnkiDroid/src/main/res/values-sr/10-preferences.xml
index 5d739e5340ea..6192c39eefff 100644
--- a/AnkiDroid/src/main/res/values-sr/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-sr/10-preferences.xml
@@ -315,6 +315,7 @@
AboutContributorscontributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LicenseGPL-3.0 and AGPL-3.0 licenses, with the source code available on GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-sv/02-strings.xml b/AnkiDroid/src/main/res/values-sv/02-strings.xml
index 322ce619c611..04c13af072c8 100644
--- a/AnkiDroid/src/main/res/values-sv/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-sv/02-strings.xml
@@ -236,6 +236,8 @@
Redigera objekt i verktygsfältetAnge HTML som ska infogas före och efter den markerade texten\n\nTryck länge på ett verktyg för att redigera eller ta bort detTa bort objekt från verktygsfältet?
+ Button text is required
+ At least one HTML field is requiredBilden är för stor, lägg till bilden manuelltVideofilen är för stor, lägg till videon manuelltLjudfilen är för stor, lägg till ljudet manuellt
@@ -270,6 +272,11 @@
Import deckNågot gick fel, vänligen försök igenHämtning misslyckades
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftDu kan använda andra appar medan nedladdningen pågårKontrollera din nätverksanslutningNedladdningen avbruten, det externa lagringsutrymmet är inte tillgängligt
diff --git a/AnkiDroid/src/main/res/values-sv/10-preferences.xml b/AnkiDroid/src/main/res/values-sv/10-preferences.xml
index f8c360fac321..42e56d36505f 100644
--- a/AnkiDroid/src/main/res/values-sv/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-sv/10-preferences.xml
@@ -311,6 +311,7 @@
Om appenBidragsgivarebidragsgivare. Du kan också hjälpa AnkiDroid genom att utveckla, översätta, donera och mer!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>ProgramvarulicensGPL-3.0 och AGPL-3.0, med källkoden tillgänglig på GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-sw600dp/bottom_navigation.xml b/AnkiDroid/src/main/res/values-sw600dp/bottom_navigation.xml
new file mode 100644
index 000000000000..910277680380
--- /dev/null
+++ b/AnkiDroid/src/main/res/values-sw600dp/bottom_navigation.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+ false
+
diff --git a/AnkiDroid/src/main/res/values-ta/02-strings.xml b/AnkiDroid/src/main/res/values-ta/02-strings.xml
index 0e13bf8e9bb9..912a187e10da 100644
--- a/AnkiDroid/src/main/res/values-ta/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-ta/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar itemதேர்ந்தெடுக்கப்பட்ட உரைக்கு முன்னும் பின்னும் செருகுவதற்கு HTML ஐ உள்ளிடவும்\n\n கருவிப்பட்டி உருப்படியைத் திருத்த அல்லது அகற்ற நீண்ட நேரம் அழுத்தவும்Remove toolbar item?
+ Button text is required
+ At least one HTML field is requiredபடம் மிகவும் பெரிதாக உள்ளது, படத்தை கைமுறையாக செருகவும்வீடியோ கோப்பு மிகவும் பெரியதாக உள்ளது, வீடியோவை கைமுறையாக செருகவும்ஆடியோ கோப்பு மிகவும் பெரியதாக உள்ளது, ஆடியோவை கைமுறையாக செருகவும்
@@ -270,6 +272,11 @@
Import deckஏதோ தவறாகிவிட்டது, மீண்டும் முயற்சிக்கவும்பதிவிறக்கம் தோல்வியடைந்தது
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftபதிவிறக்கம் இயங்கும்போது நீங்கள் பிற பயன்பாடுகளைப் பயன்படுத்தலாம்உங்கள் பிணைய இணைப்பைச் சரிபார்க்கவும்பதிவிறக்கம் நிறுத்தப்பட்டது, வெளிப்புற சேமிப்பிடம் கிடைக்கவில்லை
diff --git a/AnkiDroid/src/main/res/values-ta/10-preferences.xml b/AnkiDroid/src/main/res/values-ta/10-preferences.xml
index 87518c1632d4..7c8fb98359a0 100644
--- a/AnkiDroid/src/main/res/values-ta/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-ta/10-preferences.xml
@@ -311,6 +311,7 @@
பற்றிபங்களிப்பாளர்கள்பங்களிப்பாளர்கள். நிரலாக்கம், மொழியாக்கம், நன்கொடை மற்றும் மேலும் மூலம் நீங்கள் AnkiDroid க்கு உதவலாம்!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>உரிமம்GPL-3.0 மற்றும் AGPL-3.0 உரிமங்களின் கீழ் வெளியிடப்பட்டது. மூலக் குறியீடு GitHub இல் கிடைக்கிறது]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-te/02-strings.xml b/AnkiDroid/src/main/res/values-te/02-strings.xml
index ba57be640244..c7b1263c7385 100644
--- a/AnkiDroid/src/main/res/values-te/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-te/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar itemఎంచుకున్న వచనానికి ముందు మరియు తర్వాత ఇన్సర్ట్ చేయడానికి HTMLని నమోదు చేయండి\n\nటూల్బార్ అంశాన్ని సవరించడానికి లేదా తీసివేయడానికి ఎక్కువసేపు నొక్కండిRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredచిత్రం చాలా పెద్దది, దయచేసి చిత్రాన్ని మాన్యువల్గా చొప్పించండివీడియో ఫైల్ చాలా పెద్దది, దయచేసి వీడియోని మాన్యువల్గా చొప్పించండిఆడియో ఫైల్ చాలా పెద్దది, దయచేసి ఆడియోను మాన్యువల్గా చొప్పించండి
@@ -270,6 +272,11 @@
Import deckఏదో తప్పు జరిగింది, దయచేసి మళ్లీ ప్రయత్నించండిడౌన్ లోడ్ విఫలం
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftడౌన్లోడ్ రన్ అవుతున్నప్పుడు మీరు ఇతర యాప్లను ఉపయోగించవచ్చుదయచేసి మీ నెట్వర్క్ కనెక్షన్ని తనిఖీ చేయండిడౌన్లోడ్ ఆగిపోయింది, బాహ్య నిల్వ అందుబాటులో లేదు
diff --git a/AnkiDroid/src/main/res/values-te/10-preferences.xml b/AnkiDroid/src/main/res/values-te/10-preferences.xml
index b0d08e52baf9..9ee094580530 100644
--- a/AnkiDroid/src/main/res/values-te/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-te/10-preferences.xml
@@ -310,6 +310,7 @@
సహకారులుకంట్రిబ్యూటర్లు. మీరు ప్రోగ్రామింగ్, అనువాదం, విరాళం మరియు మరిన్ని ద్వారా కూడా AnkiDroidకి సహాయం చేయవచ్చు!
]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>లైసెన్స్GPL-3.0 మరియు AGPL-3.0 లైసెన్స్ల సోర్స్ కోడ్ క్రింద విడుదల చేయబడింది GitHubలో అందుబాటులో ఉంది
]]>
diff --git a/AnkiDroid/src/main/res/values-tgl/02-strings.xml b/AnkiDroid/src/main/res/values-tgl/02-strings.xml
index 7728d7f1ccb7..8aee0de4fb73 100644
--- a/AnkiDroid/src/main/res/values-tgl/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-tgl/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar itemEnter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove itRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredThe image is too large, please insert the image manuallyThe video file is too large, please insert the video manuallyThe audio file is too large, please insert the audio manually
@@ -270,6 +272,11 @@
Import deckSomething went wrong, please try againDownload failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftYou can use other apps while the download is runningPlease check your network connectionDownload aborted, the external storage is not available
diff --git a/AnkiDroid/src/main/res/values-tgl/10-preferences.xml b/AnkiDroid/src/main/res/values-tgl/10-preferences.xml
index 4bf367512f81..0166ce51ec3a 100644
--- a/AnkiDroid/src/main/res/values-tgl/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-tgl/10-preferences.xml
@@ -312,6 +312,7 @@
AboutContributorscontributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LicenseGPL-3.0 and AGPL-3.0 licenses, with the source code available on GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-th/02-strings.xml b/AnkiDroid/src/main/res/values-th/02-strings.xml
index 2a564772ac6e..a9bc31800d4a 100644
--- a/AnkiDroid/src/main/res/values-th/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-th/02-strings.xml
@@ -233,6 +233,8 @@
Edit toolbar itemEnter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove itRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredThe image is too large, please insert the image manuallyThe video file is too large, please insert the video manuallyThe audio file is too large, please insert the audio manually
@@ -267,6 +269,11 @@
Import deckSomething went wrong, please try againDownload failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftYou can use other apps while the download is runningPlease check your network connectionDownload aborted, the external storage is not available
diff --git a/AnkiDroid/src/main/res/values-th/10-preferences.xml b/AnkiDroid/src/main/res/values-th/10-preferences.xml
index d8bbe0dec24b..ffc29951a8a6 100644
--- a/AnkiDroid/src/main/res/values-th/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-th/10-preferences.xml
@@ -309,6 +309,7 @@
AboutContributorscontributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LicenseGPL-3.0 and AGPL-3.0 licenses, with the source code available on GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-ti/02-strings.xml b/AnkiDroid/src/main/res/values-ti/02-strings.xml
index 7cd1529bac88..e07cedd7eb06 100644
--- a/AnkiDroid/src/main/res/values-ti/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-ti/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar itemEnter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove itRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredThe image is too large, please insert the image manuallyThe video file is too large, please insert the video manuallyThe audio file is too large, please insert the audio manually
@@ -270,6 +272,11 @@
Import deckSomething went wrong, please try againDownload failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftYou can use other apps while the download is runningPlease check your network connectionDownload aborted, the external storage is not available
diff --git a/AnkiDroid/src/main/res/values-ti/10-preferences.xml b/AnkiDroid/src/main/res/values-ti/10-preferences.xml
index e4052f7f9b26..fdb3c24b7644 100644
--- a/AnkiDroid/src/main/res/values-ti/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-ti/10-preferences.xml
@@ -312,6 +312,7 @@
AboutContributorscontributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LicenseGPL-3.0 and AGPL-3.0 licenses, with the source code available on GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-tr/01-core.xml b/AnkiDroid/src/main/res/values-tr/01-core.xml
index eb2913e81542..c2bd9437ab8e 100644
--- a/AnkiDroid/src/main/res/values-tr/01-core.xml
+++ b/AnkiDroid/src/main/res/values-tr/01-core.xml
@@ -127,7 +127,7 @@
AnkiDroid veritabanına okut ve yazvarolan notlara, kartlara, not türlerine ve destelere erişmek, ve yenilerini oluşturmak
- Card browser
+ Kart tarayıcısıAnki cardKoleksiyonlar birleştirilemiyor.\nHangi koleksiyonu tutmak istersiniz?
diff --git a/AnkiDroid/src/main/res/values-tr/02-strings.xml b/AnkiDroid/src/main/res/values-tr/02-strings.xml
index ce5a8fd3b101..9b8ab5358ddf 100644
--- a/AnkiDroid/src/main/res/values-tr/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-tr/02-strings.xml
@@ -220,8 +220,8 @@
Yazı tipi boyutuAraç çubuğunu göster
- Show preview
- Hide preview
+ Önizlemeyi göster
+ Önizlemeyi gizleKalın olarak şekillendirİtalik olarak şekillendirAltı çizili olarak şekillendir
@@ -236,6 +236,8 @@
Araç çubuğu öğesini düzenleSeçili yazıdan önce ve sonra eklenecek HTML girin\n\nBir araç çubuğu öğesini değiştirmek ya da kaldırmak için basılı tutunAraç çubuğu öğesi kaldırılsın mı?
+ Button text is required
+ En az bir HTML alanı gereklidir.Bu resim fazla büyük, lütfen resmi elle ekleyinBu video dosyası fazla büyük, lütfen videoyu elle ekleyinBu ses dosyası fazla büyük, lütfen sesi elle ekleyin
@@ -270,6 +272,11 @@
Deste aktarBir şeyler ters gitti, lütfen tekrar deneyinİndirme başarısız
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftİndirme devam ederken diğer uygulamaları kullanabilirsinizLütfen ağ bağlantını kontrol etİndirme iptal edildi, harici hafıza erişilebilir değil.
diff --git a/AnkiDroid/src/main/res/values-tr/03-dialogs.xml b/AnkiDroid/src/main/res/values-tr/03-dialogs.xml
index f0f510a169f8..bf9f336f5dd1 100644
--- a/AnkiDroid/src/main/res/values-tr/03-dialogs.xml
+++ b/AnkiDroid/src/main/res/values-tr/03-dialogs.xml
@@ -87,7 +87,7 @@
Bu, büyük medya koleksiyonlarıyla uzun zaman alabilir. Devam edilsin mi?Medya kontrol ediliyor…Etiketler ekleniyor…
- Tags added
+ Etiketler eklendiMedya siliniyor…Silme sonuçları
diff --git a/AnkiDroid/src/main/res/values-tr/10-preferences.xml b/AnkiDroid/src/main/res/values-tr/10-preferences.xml
index c3205ec3c8fb..5c67c9168d35 100644
--- a/AnkiDroid/src/main/res/values-tr/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-tr/10-preferences.xml
@@ -24,7 +24,7 @@
-->
- Enabled
+ EtkinDevredışı%d dk
@@ -108,7 +108,7 @@
Üç parmakla dokunmaDört parmakla dokunmaCihazı salla
- ‘%s’ menu
+ ‘%s’ menüsü\"%s\" durum menüsünü uygulama çapında etkinleştirirHızlı kaydırmaeOkuyucu\'yla kaydırma mesafesini iki katına çıkarmak istiyorsanız bunu etkinleştirin
@@ -223,7 +223,7 @@
####################################################################################### -->
Review reminders
- App-wide
+ Uygulama geneliDeck-specificAn error occurred while loading your review reminders, corrupted reminders have been deleted. Details:
@@ -231,9 +231,9 @@
Hatırlatıcı ekleÇalışma hatırlatıcınız yokTroubleshoot
- Notification settings
+ Bildirim ayarlarıList of scheduled review reminders
- Deck not found
+ Deste bulunamadıReminders are unavailableFixSuccessfully added new review reminder
@@ -243,15 +243,15 @@
Çalışma hatırlatıcısı ekleÇalışma hatırlatıcısını değiştirTime:
- Deck:
- Advanced settings
+ Deste:
+ Gelişmiş ayarlarCard threshold:Do not send me notifications if there are less than this many cards dueOnly notify me if no reviews have been done todayPlease enter a whole number of cardsThe threshold must be at least 0
- Delete this reminder?
- This action cannot be undone.
+ Bu hatırlatıcı silinsin mi?
+ Bu eylem geri alınamaz.Reminders are unavailable.Reminders may not work correctly.
@@ -259,16 +259,16 @@
Different devices have different notification restriction settings. Check your system settings or search online if issues persist.Notification permission
- Notification channel
+ Bildirim kanalıDo not disturb
- Battery optimization
- Power saving mode
+ Pil optimizasyonu
+ Güç tasarruf moduAlarms & reminders permissionGrantedDenied
- On
- Off
+ Açık
+ KapalıUnrestrictedOptimizedRestricted
@@ -310,6 +310,7 @@
HakkındaKatkıda Bulunanlarinsanlar. Siz de AnkiDroid\'e programlayarak, çevirerek, bağış yaparak ve daha fazla yolla yardım edebilirsiniz!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LisansGPL-3.0 ve AGPL-3.0 lisansları altında yayınlanmaktadır, kaynak kodu GitHub\'da mevcuttur]]>lisanslar altında üçüncü parti kütüphaneler ve simgeler de kullanır.]]>
@@ -447,8 +448,8 @@
Ayarları açİlgili ayarlar
- Not set
- Custom path
+ Ayarlanmamış
+ Özel yolEnter custom path
- Directory is not writable
+ Dizin yazılabilir değil
diff --git a/AnkiDroid/src/main/res/values-tt/02-strings.xml b/AnkiDroid/src/main/res/values-tt/02-strings.xml
index 96876eb71813..10a36fff7bfb 100644
--- a/AnkiDroid/src/main/res/values-tt/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-tt/02-strings.xml
@@ -234,6 +234,8 @@
Edit toolbar itemEnter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove itRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredThe image is too large, please insert the image manuallyThe video file is too large, please insert the video manuallyThe audio file is too large, please insert the audio manually
@@ -268,6 +270,11 @@
Import deckSomething went wrong, please try againDownload failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftYou can use other apps while the download is runningPlease check your network connectionDownload aborted, the external storage is not available
diff --git a/AnkiDroid/src/main/res/values-tt/10-preferences.xml b/AnkiDroid/src/main/res/values-tt/10-preferences.xml
index 302838d3284c..82f141d51097 100644
--- a/AnkiDroid/src/main/res/values-tt/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-tt/10-preferences.xml
@@ -309,6 +309,7 @@
ХакындаКатнашучыларcontributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>ЛицензияGPL-3.0 and AGPL-3.0 licenses, with the source code available on GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-ug/02-strings.xml b/AnkiDroid/src/main/res/values-ug/02-strings.xml
index a7b1617eabc4..b02ceb6f1247 100644
--- a/AnkiDroid/src/main/res/values-ug/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-ug/02-strings.xml
@@ -236,6 +236,8 @@
قورال بالداق تۈرىنى تەھرىرتاللانغان تېكىستتىن ئىلگىرى ۋە كېيىن قىستۇرىدىغان HTML كىرگۈزۈلىدۇ\n\nقورال بالداق ئۇزۇن بېسىلسا ئۇنى تەھرىرلىگىلى ياكى چىقىرىۋەتكىلى بولىدۇقورال بالداق تۈرىنى چىقىرىۋېتەمدۇ؟
+ Button text is required
+ At least one HTML field is requiredسۈرەت بەك چوڭ، سۈرەتنى قولدا قىستۇرۇڭسىن ھۆججىتى بەك چوڭ، سىننى قولدا قىستۇرۇڭئۈن ھۆججىتى بەك چوڭ، ئۈننى قولدا قىستۇرۇڭ
@@ -270,6 +272,11 @@
دەستە ئەكىرمەسىلە كۆرۈلدى، قايتا سىناڭچۈشۈرەلمىدى
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftاچۈشۈرۈۋاتقاندا باشقا ئەپنى ئىشلىتەلەيسىزتور باغلىنىشىنى تەكشۈرۈڭچۈشۈرۈش بىكار قىلىندى، سىرتقا ساقلىغۇچنى ئىشلەتكىلى بولمايدۇ
diff --git a/AnkiDroid/src/main/res/values-ug/10-preferences.xml b/AnkiDroid/src/main/res/values-ug/10-preferences.xml
index 3389003637eb..8f2616080bcc 100644
--- a/AnkiDroid/src/main/res/values-ug/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-ug/10-preferences.xml
@@ -312,6 +312,7 @@
ھەققىدەتۆھپىكارتۆھپىكارلىرىمىز. پىروگرامما تۈزۈش، تەرجىمە قىلىش، ئىئانە قىلىش ۋە تېخىمۇ كۆپ ئۇسۇللار ئارقىلىق AnkiDroid غا ياردەم قىلالايسىز!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>ئىجازەتنامەGPL-3.0 ۋە AGPL-3.0 ئىجازىتىدە تارقىتىدۇ، ئەسلى مەنبە كودى GitHub تا بار]]>ئىجازەتتە تارقىتىلىدىغان ئۈچىنچى تەرەپ ئامبار ۋە سىنبەلگىنىمۇ ئىشلىتىدۇ]]>
diff --git a/AnkiDroid/src/main/res/values-uk/02-strings.xml b/AnkiDroid/src/main/res/values-uk/02-strings.xml
index 35034b83cd25..8076543b3bd2 100644
--- a/AnkiDroid/src/main/res/values-uk/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-uk/02-strings.xml
@@ -242,6 +242,8 @@
Редагувати елемент панелі інструментівВведіть HTML, який буде вставлений до і після виділеного тексту\n\nДовго натискайте на елемент панелі інструментів, щоб його редагувати або видалитиВидалити елемент панелі інструментів?
+ Button text is required
+ At least one HTML field is requiredЗображення завелике, будь ласка, вставте зображення вручнуВідеофайл завеликий, будь ласка, вставте відео вручнуАудіофайл завеликий, будь ласка, вставте аудіо вручну
@@ -276,6 +278,11 @@
Імпортувати колодуЩось пішло не так. Будь ласка, спробуйте ще разНе вдалося завантажити
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftВи можете використовувати інші додатки під час завантаженняБудь ласка, перевірте підключення до мережіЗавантаження перервано, зовнішнє сховище недоступне
diff --git a/AnkiDroid/src/main/res/values-uk/10-preferences.xml b/AnkiDroid/src/main/res/values-uk/10-preferences.xml
index 2da28a4fc07d..ad7c8e1616d5 100644
--- a/AnkiDroid/src/main/res/values-uk/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-uk/10-preferences.xml
@@ -317,6 +317,7 @@
Про програмуУчасникиучасники. Ви теж можете допомогти AnkiDroid шляхом програмування, перекладу, пожертвуванням і іншим!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>ЛіцензіяGPL-3.0 і AGPL-3.0 ліцензією, з вихідним кодом доступним на GitHub]]>ліцензіями з відкритим кодом]]>
diff --git a/AnkiDroid/src/main/res/values-ur/02-strings.xml b/AnkiDroid/src/main/res/values-ur/02-strings.xml
index 9d053df4b36a..79a550b4c7c1 100644
--- a/AnkiDroid/src/main/res/values-ur/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-ur/02-strings.xml
@@ -237,6 +237,8 @@
Edit toolbar itemمنتخب کردہ متن سے پہلے اور بعد میں داخل کرنے کے لیے HTML درج کریں\n\nٹول بار آئٹم میں ترمیم کرنے یا اسے ہٹانے کے لیے دیر تک دبائیںRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredتصویر بہت بڑی ہے، براہ کرم دستی طور پر تصویر داخل کریں۔ویڈیو فائل بہت بڑی ہے، براہ کرم ویڈیو کو دستی طور پر داخل کریں۔آڈیو فائل بہت بڑی ہے، براہ کرم آڈیو کو دستی طور پر داخل کریں۔
@@ -271,6 +273,11 @@
Import deckکچھ غلط ہو گیا، براہ کرم دوبارہ کوشش کریں۔ڈاؤن لوڈ ناکام ہو گیا۔
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftجب ڈاؤن لوڈ چل رہا ہو تو آپ دوسری ایپس استعمال کر سکتے ہیں۔براہ کرم اپنا نیٹ ورک کنکشن چیک کریںڈاؤن لوڈ منسوخ کر دیا گیا، بیرونی اسٹوریج دستیاب نہیں ہے
diff --git a/AnkiDroid/src/main/res/values-ur/10-preferences.xml b/AnkiDroid/src/main/res/values-ur/10-preferences.xml
index 907231caca1b..7c1c5abd7aca 100644
--- a/AnkiDroid/src/main/res/values-ur/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-ur/10-preferences.xml
@@ -312,6 +312,7 @@
AboutContributorscontributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LicenseGPL-3.0 and AGPL-3.0 licenses, with the source code available on GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values-uz/02-strings.xml b/AnkiDroid/src/main/res/values-uz/02-strings.xml
index 5b779bc38ada..ed61f7d55286 100644
--- a/AnkiDroid/src/main/res/values-uz/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-uz/02-strings.xml
@@ -236,6 +236,8 @@
Asboblar paneli elementini tahrirlashTanlangan matndan oldin va keyin kiritiladigan HTMLʼni kiriting\n\nUni tahrirlash yoki olib tashlash uchun asboblar paneli elementini uzoq vaqt bosib turing.Asboblar paneli elementi olib tashlansinmi?
+ Button text is required
+ At least one HTML field is requiredRasm juda katta, rasmni qoʻlda kiritingVideo juda katta, videoni qoʻlda kiritingAudio fayl juda katta, audio faylni qoʻlda kiriting
@@ -270,6 +272,11 @@
Dastani import qilishNimadir xato ketdi, iltimos, qayta urinib koʻringYuklab olib boʻlmadi
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftYuklab olish jarayonida boshqa ilovalardan foydalanishingiz mumkinIltimos, tarmoqqa ulanganingizni tekshiringYuklab olish toʻxtatildi, tashqi xotira mavjud emas
diff --git a/AnkiDroid/src/main/res/values-uz/10-preferences.xml b/AnkiDroid/src/main/res/values-uz/10-preferences.xml
index 50c6218ffd86..724bbb392269 100644
--- a/AnkiDroid/src/main/res/values-uz/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-uz/10-preferences.xml
@@ -310,6 +310,7 @@
AnkiDroid haqidaHissa qoʻshganlarhissa qoʻshuvchilarimiz. Siz ham dasturlash, tarjima qilish, xayriya qilish va boshqa yoʻllar orqali AnkiDroidʼga yordam berishingiz mumkin!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LitsenziyaGPL-3.0 va AGPL-3.0 litsenziyalari ostida chiqarilgan, dastur kodi GitHubda mavjud]]>litsenziyalar asosida tarqatilgan uchinchi tomon kutubxonalari va piktogrammalaridan foydalanadi.]]>
diff --git a/AnkiDroid/src/main/res/values-vi/02-strings.xml b/AnkiDroid/src/main/res/values-vi/02-strings.xml
index cc3ed11f69e8..41fdf1540e34 100644
--- a/AnkiDroid/src/main/res/values-vi/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-vi/02-strings.xml
@@ -233,6 +233,8 @@
Edit toolbar itemNhập HTML sẽ được chèn vào trước và sau văn bản đã chọn\n\nNhấn và giữ một mục trên thanh công cụ để chỉnh sửa hoặc xóa nóRemove toolbar item?
+ Button text is required
+ At least one HTML field is requiredHình ảnh quá lớn, vui lòng chèn hình ảnh theo cách thủ côngTập tin video quá lớn, vui lòng chèn video theo cách thủ côngTệp âm thanh quá lớn, vui lòng chèn âm thanh thủ công
@@ -267,6 +269,11 @@
Nhập bộ thẻĐã xảy ra lỗi, vui lòng thử lạiTải xuống thất bại
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftBạn được phép sử dụng những ứng dụng khác khi đang tải.Vui lòng kiểm tra kết nối mạng của bạnTải xuống bị hủy, bộ nhớ ngoài không khả dụng
diff --git a/AnkiDroid/src/main/res/values-vi/10-preferences.xml b/AnkiDroid/src/main/res/values-vi/10-preferences.xml
index febb090715c3..55a22b2cd597 100644
--- a/AnkiDroid/src/main/res/values-vi/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-vi/10-preferences.xml
@@ -306,6 +306,7 @@
Giới thiệuNhững người đóng gópcộng tác viên tuyệt vời của chúng tôi. Bạn có thể hỗ trợ AnkiDroid bằng cách lập trình, dịch thuật, quyên góp và hơn thế nữa!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>Giấy phépGPL-3.0 và AGPL-3.0, với mã nguồn có sẵn trên GitHub]]>giấy phép mã nguồn mở khác nhau.]]>
diff --git a/AnkiDroid/src/main/res/values-zh-rCN/01-core.xml b/AnkiDroid/src/main/res/values-zh-rCN/01-core.xml
index 503c46749be4..cc1fdc76b994 100644
--- a/AnkiDroid/src/main/res/values-zh-rCN/01-core.xml
+++ b/AnkiDroid/src/main/res/values-zh-rCN/01-core.xml
@@ -122,8 +122,8 @@
读取并写入 AnkiDroid 数据库创建或访问现有的笔记、卡片、笔记模板和牌组
- Card browser
- Anki card
+ 卡片浏览器
+ Anki 卡片集合无法合并。\n您想要保留哪个集合?AnkiDroid
diff --git a/AnkiDroid/src/main/res/values-zh-rCN/02-strings.xml b/AnkiDroid/src/main/res/values-zh-rCN/02-strings.xml
index 6501ecb6deae..6d8294d9e634 100644
--- a/AnkiDroid/src/main/res/values-zh-rCN/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-zh-rCN/02-strings.xml
@@ -217,8 +217,8 @@
字体大小显示工具栏
- Show preview
- Hide preview
+ 显示预览
+ 隐藏预览粗体格式斜体格式下划线格式
@@ -233,6 +233,8 @@
编辑工具栏项目输入要在选定文本之前和之后插入的 HTML\n\n 长按工具栏项目来编辑或删除它删除工具栏项目?
+ 需要按钮文本
+ 至少需要一个 HTML 字段图片过大,请手动插入图像视频文件太大,请手动插入视频音频文件太大,请手动插入音频
@@ -267,6 +269,11 @@
导入牌组出错了,请重试下载失败
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left您可以在下载过程中使用其他应用请检查您的网络连接。下载中止,外部存储不可用
diff --git a/AnkiDroid/src/main/res/values-zh-rCN/03-dialogs.xml b/AnkiDroid/src/main/res/values-zh-rCN/03-dialogs.xml
index 9e7d4be1fbbb..b6af88b8a48b 100644
--- a/AnkiDroid/src/main/res/values-zh-rCN/03-dialogs.xml
+++ b/AnkiDroid/src/main/res/values-zh-rCN/03-dialogs.xml
@@ -47,7 +47,7 @@
增加/减少(“-3”)今天的复习卡片限制复习过去 x 天中忘记的卡片:复习接下去多久到期的卡片:
- Preview new cards added in the last:
+ 预览过去 x 天内添加的新卡片:从牌组中选择 x 张卡片:存储空间已满存储空间快满了
@@ -76,7 +76,7 @@
覆盖修复替换
- %1$s\n\nCorrect the date and time, or enable automatic date and time, then sync again.\n\nYour device’s current time is: %2$s
+ %1$s\n\n更正日期和时间,或启用自动日期和时间,然后再次同步。\n\n设备当前时间为:%2$s指定的路径不是一个有效的目录提供的文本无法解析为有效的 PEM 编码的 X509 证书解析证书时发生错误
@@ -85,7 +85,7 @@
对于大型媒体集合,这可能需要很长时间。继续吗?正在检查媒体文件…正在添加标签…
- Tags added
+ 标签已添加正在删除媒体…填空结果
diff --git a/AnkiDroid/src/main/res/values-zh-rCN/10-preferences.xml b/AnkiDroid/src/main/res/values-zh-rCN/10-preferences.xml
index a89e5a4598e9..db0c82a05337 100644
--- a/AnkiDroid/src/main/res/values-zh-rCN/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-zh-rCN/10-preferences.xml
@@ -24,7 +24,7 @@
-->
- Enabled
+ 已启用禁用%d 分钟
@@ -107,7 +107,7 @@
三指轻触四指轻触摇动设备
- ‘%s’ menu
+ “%s” 菜单全局启用 “%s” 上下文菜单双滚动电子阅读器中使用双滚动
@@ -221,74 +221,74 @@
################################## Review reminders ###################################
####################################################################################### -->
- Review reminders
- App-wide
- Deck-specific
- An error occurred while loading your review reminders, corrupted reminders have been deleted. Details:
+ 复习提醒
+ 全局
+ 仅牌组
+ 加载复习提醒时发生错误,损坏的提醒已被删除。细节:
- Schedule reminders
+ 安排提醒添加提醒您没有复习提醒
- Troubleshoot
- Notification settings
- List of scheduled review reminders
- Deck not found
- Reminders are unavailable
- Fix
- Successfully added new review reminder
- Successfully deleted review reminder
- Successfully edited review reminder
+ 故障排除
+ 通知设置
+ 排定的复习提醒列表
+ 找不到牌组
+ 提醒不可用
+ 修复
+ 成功添加新的复习提醒
+ 成功删除复习提醒
+ 已成功编辑复习提醒添加复习提醒编辑复习提醒
- Time:
- Deck:
- Advanced settings
- Card threshold:
- Do not send me notifications if there are less than this many cards due
- Only notify me if no reviews have been done today
- Please enter a whole number of cards
- The threshold must be at least 0
- Delete this reminder?
- This action cannot be undone.
+ 时间:
+ 牌组:
+ 高级设置
+ 卡片阈值:
+ 如果到期卡片少于此数量,则不要通知我
+ 仅当今天尚未复习时通知我
+ 请输入一个整数
+ 阈值必须至少为 0
+ 删除此提醒?
+ 无法撤消此操作。
- Reminders are unavailable.
- Reminders may not work correctly.
- Your reminders should work as expected.
- Different devices have different notification restriction settings. Check your system settings or search online if issues persist.
+ 提醒不可用.
+ 提醒可能无法正常工作。
+ 您的提醒应该按预期工作。
+ 不同的设备有不同的通知限制设置。如果问题持续存在,请检查您的系统设置或在线搜索。
- Notification permission
- Notification channel
- Do not disturb
- Battery optimization
- Power saving mode
- Alarms & reminders permission
+ 通知权限
+ 通知渠道
+ 勿扰模式
+ 电池优化
+ 省电模式
+ 闹钟和提醒权限
- Granted
- Denied
- On
- Off
- Unrestricted
- Optimized
- Restricted
+ 已授予
+ 已拒绝
+ 开
+ 关
+ 不受限制
+ 已优化
+ 受限制
- The review reminder notification channel must be enabled
- Do Not Disturb may mute reminder notifications
- Battery optimization may delay reminders
- Background usage is disabled. Reminders may not be delivered
- Power saving mode may prevent timely delivery of reminders
- Required to schedule reminders at exact times
+ 必须启用复习提醒通知渠道
+ 勿扰可能静音提醒通知
+ 电池优化可能会延迟提醒
+ 后台使用被禁用。提醒可能无法发送
+ 节电模式可能会阻止及时发送提醒
+ 需要在精确时间安排提醒
- Grant permission
- Enable notification channel
- Disable battery optimization
- Open battery settings
+ 授予权限
+ 启用通知渠道
+ 禁用电池优化
+ 打开电池设置
- It\'s time to study your cards
- It\'s time to study \"%s\"
- Snooze %s
+ 是时候学习您的卡片了
+ 是时候学习\"%s\"了
+ 推迟 %s
- %d cards due
+ %d 张到期卡片选择标签
@@ -308,6 +308,7 @@
关于贡献者贡献者。您也可以通过贡献代码、翻译、捐赠和更多方式来帮助 AnkiDroid!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>许可证GPL-3.0和AGPL-3.0许可证发布,源代码可在 Github 上获取]]>许可证许可]]>
@@ -438,15 +439,15 @@
反馈或故障。]]>显示回答反馈屏幕
- Also assign to ‘%1$s’?
- Skip
- Assign
+ 也分配到 ‘%1$s’ ?
+ 跳过
+ 分配打开设置相关设置
- Not set
- Custom path
- Enter custom path
- Directory is not writable
+ 未设置
+ 自定义路径
+ 输入自定义路径
+ 目录不可写
diff --git a/AnkiDroid/src/main/res/values-zh-rTW/02-strings.xml b/AnkiDroid/src/main/res/values-zh-rTW/02-strings.xml
index 923f7ada4718..d16aa6c1e89d 100644
--- a/AnkiDroid/src/main/res/values-zh-rTW/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-zh-rTW/02-strings.xml
@@ -233,6 +233,8 @@
Edit toolbar item請在選取文字的前後出入要添加的HTML碼\n\n長按工具列項目以編輯或移除Remove toolbar item?
+ Button text is required
+ At least one HTML field is required此圖像太大,無法貼上,請手動插入影片太大,無法貼上,請手動插入此音訊檔太大,無法貼上,請手動插入
@@ -267,6 +269,11 @@
Import deck出了點問題,請再試一次下載失敗
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left在下載同時你可以使用其他的APP請檢查你的網絡連接下載中止,外部儲存空間不可用
diff --git a/AnkiDroid/src/main/res/values-zh-rTW/10-preferences.xml b/AnkiDroid/src/main/res/values-zh-rTW/10-preferences.xml
index f76a4c218665..5c69c80fbe9d 100644
--- a/AnkiDroid/src/main/res/values-zh-rTW/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-zh-rTW/10-preferences.xml
@@ -308,6 +308,7 @@
關於貢獻者貢獻者。 您也可以通過貢獻程式碼、翻譯、捐贈和更多來幫助AnkiDroid!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>許可證GPL-3.0和AGPL-3.0許可證下發佈,原始程式碼位於Github上]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values/02-strings.xml b/AnkiDroid/src/main/res/values/02-strings.xml
index f6ae538d9976..0eba17b9c243 100644
--- a/AnkiDroid/src/main/res/values/02-strings.xml
+++ b/AnkiDroid/src/main/res/values/02-strings.xml
@@ -305,6 +305,11 @@
Import deckSomething went wrong, please try againDownload failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time leftYou can use other apps while the download is runningPlease check your network connectionDownload aborted, the external storage is not available
diff --git a/AnkiDroid/src/main/res/values/10-preferences.xml b/AnkiDroid/src/main/res/values/10-preferences.xml
index 0be7067c3945..a27c10dc368e 100644
--- a/AnkiDroid/src/main/res/values/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values/10-preferences.xml
@@ -320,6 +320,7 @@
AboutContributorscontributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>LicenseGPL-3.0 and AGPL-3.0 licenses, with the source code available on GitHub]]>licenses]]>
diff --git a/AnkiDroid/src/main/res/values/bottom_navigation.xml b/AnkiDroid/src/main/res/values/bottom_navigation.xml
new file mode 100644
index 000000000000..94e8fe540ccf
--- /dev/null
+++ b/AnkiDroid/src/main/res/values/bottom_navigation.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+ true
+
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/AnkiActivitySavedStateTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/AnkiActivitySavedStateTest.kt
index e6642bdd73b6..76604081cf8a 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/AnkiActivitySavedStateTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/AnkiActivitySavedStateTest.kt
@@ -3,7 +3,7 @@
package com.ichi2.anki
import android.content.Intent
-import androidx.core.os.bundleOf
+import android.os.Bundle
import androidx.fragment.app.Fragment
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.ichi2.testutils.EmptyAnkiActivity
@@ -34,7 +34,11 @@ class AnkiActivitySavedStateTest : RobolectricTest() {
fun savedSize(payload: ByteArray): Int =
savedStateSize(
SingleFragmentActivity::class.java,
- SingleFragmentActivity.getIntent(targetContext, Fragment::class, bundleOf("payload" to payload)),
+ SingleFragmentActivity.getIntent(
+ targetContext,
+ Fragment::class,
+ Bundle().apply { putByteArray("payload", payload) },
+ ),
)
val payload = ByteArray(300_000)
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/AnkiDroidJsAPITest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/AnkiDroidJsAPITest.kt
index f359176aebe2..fc764aef10d9 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/AnkiDroidJsAPITest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/AnkiDroidJsAPITest.kt
@@ -7,10 +7,12 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.ichi2.anki.AnkiDroidJsAPI.Companion.SUCCESS_KEY
import com.ichi2.anki.AnkiDroidJsAPI.Companion.VALUE_KEY
import com.ichi2.anki.AnkiDroidJsAPITest.Companion.jsApiContract
+import com.ichi2.anki.common.preferences.sharedPrefs
import com.ichi2.anki.common.time.TimeManager
import com.ichi2.anki.libanki.CardType
import com.ichi2.anki.libanki.testutils.ext.BASIC_NOTE_TYPE_NAME
import com.ichi2.anki.libanki.testutils.ext.setFlag
+import com.ichi2.anki.reviewer.FullScreenMode
import com.ichi2.testutils.getString
import net.ankiweb.rsdroid.withoutUnicodeIsolation
import org.hamcrest.CoreMatchers.equalTo
@@ -217,6 +219,32 @@ class AnkiDroidJsAPITest : RobolectricTest() {
)
}
+ @Test
+ fun fullscreenModeApiSetsEachModeExplicitly() =
+ runTest {
+ addDeck("Test", setAsSelected = true)
+ addBasicNote("foo", "bar")
+
+ val reviewer = startReviewer()
+ val jsapi = reviewer.jsApi
+ val prefs = targetContext.sharedPrefs()
+
+ val modes =
+ listOf(
+ "setFullscreenButtonsAndMenu" to FullScreenMode.BUTTONS_AND_MENU,
+ "setFullscreenButtonsOnly" to FullScreenMode.BUTTONS_ONLY,
+ "setFullscreenAllGone" to FullScreenMode.FULLSCREEN_ALL_GONE,
+ )
+
+ modes.forEach { (method, mode) ->
+ assertThat(
+ getDataFromRequest(method, jsapi),
+ equalTo(formatApiResult(true)),
+ )
+ assertEquals(mode, FullScreenMode.fromPreference(prefs))
+ }
+ }
+
@Test
fun ankiMarkAndFlagCardTest() =
runTest {
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt
index 6d430cfa4bf8..5c2fb4767bd6 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt
@@ -14,6 +14,7 @@ import android.widget.TextView
import androidx.core.app.ActivityCompat
import androidx.core.content.edit
import androidx.core.net.toUri
+import androidx.core.os.BundleCompat
import androidx.core.view.children
import androidx.core.view.get
import androidx.core.view.size
@@ -63,6 +64,7 @@ import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.ARG_REPLACE
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.TAGS_AS_FIELD
+import com.ichi2.anki.browser.IdsFile
import com.ichi2.anki.browser.column1
import com.ichi2.anki.browser.selectRowAtPosition
import com.ichi2.anki.browser.setColumn
@@ -134,6 +136,7 @@ import java.util.Random
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertNotNull
+import kotlin.test.assertNotSame
import kotlin.test.assertNull
import kotlin.test.fail
@@ -529,6 +532,65 @@ class CardBrowserTest : RobolectricTest() {
}
}
+ @Test
+ fun finishingBrowserDeletesSavedSelectionFile() {
+ ensureCollectionLoadIsSynchronous()
+ addBasicNote("front", "back")
+ val controller = Robolectric.buildActivity(CardBrowser::class.java).setup()
+ saveControllerForCleanup(controller)
+ advanceRobolectricLooper()
+ val browser = controller.get()
+ browser.viewModel.selectRowAtPosition(0)
+
+ controller.pause().saveInstanceState(Bundle()).stop()
+ val key = CardBrowserViewModel.STATE_MULTISELECT_VALUES
+ val savedSelection = assertNotNull(browser.viewModel.savedStateHandle.get(key), "selection bundle is saved")
+ val file = assertNotNull(BundleCompat.getParcelable(savedSelection, key, IdsFile::class.java), "selection file is saved")
+ assertThat("saved selection file exists before finishing", file.exists(), equalTo(true))
+
+ browser.finish()
+ assertThat(browser.isFinishing, equalTo(true))
+ controller.destroy()
+ assertThat("permanently closing the browser deletes its snapshot", file.exists(), equalTo(false))
+ }
+
+ @Test
+ fun selectionSurvivesNonFinishingDestructionAndFileIsDeletedOnFinish() {
+ ensureCollectionLoadIsSynchronous()
+ addBasicNote("front", "back")
+ val controller = Robolectric.buildActivity(CardBrowser::class.java).setup()
+ saveControllerForCleanup(controller)
+ advanceRobolectricLooper()
+ val browser = controller.get()
+ browser.viewModel.selectRowAtPosition(0)
+ val selected = browser.viewModel.selectedRows.toSet()
+ assertThat("one row is selected before saving", selected.size, equalTo(1))
+
+ // Model "Don't keep activities": destroy without finishing or retaining the ViewModel.
+ val state = Bundle()
+ controller.pause().saveInstanceState(state).stop()
+ assertThat(browser.isFinishing, equalTo(false))
+ assertThat(browser.isChangingConfigurations, equalTo(false))
+ controller.destroy()
+
+ val restored = Robolectric.buildActivity(CardBrowser::class.java).setup(state)
+ saveControllerForCleanup(restored)
+ advanceRobolectricLooper()
+ assertNotSame(browser.viewModel, restored.get().viewModel, "restoration creates a new ViewModel")
+ assertThat("saved selection survives ViewModel clearing", restored.get().viewModel.selectedRows, equalTo(selected))
+
+ val key = CardBrowserViewModel.STATE_MULTISELECT_VALUES
+ val handle = restored.get().viewModel.savedStateHandle
+ val savedSelection = assertNotNull(handle.get(key), "selection bundle is restored")
+ val file = assertNotNull(BundleCompat.getParcelable(savedSelection, key, IdsFile::class.java), "selection file is restored")
+ assertThat("inherited snapshot exists before finishing", file.exists(), equalTo(true))
+
+ // Finish without another save, so cleanup must delete the inherited snapshot.
+ restored.get().finish()
+ restored.pause().stop().destroy()
+ assertThat("finishing the restored browser deletes its inherited snapshot", file.exists(), equalTo(false))
+ }
+
@Test
fun tagWithBracketsDisplaysProperly() =
runTest {
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionLeaseTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionLeaseTest.kt
index 8e1cd8d316db..8282cf475a3c 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionLeaseTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionLeaseTest.kt
@@ -18,7 +18,6 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
-import kotlinx.coroutines.test.UnconfinedTestDispatcher
import net.ankiweb.rsdroid.Backend
import net.ankiweb.rsdroid.BackendException
import org.hamcrest.MatcherAssert.assertThat
@@ -413,11 +412,11 @@ class CollectionLeaseTest : RobolectricTest() {
dispatcher: CoroutineDispatcher,
block: suspend CoroutineScope.() -> Unit,
) {
- CollectionManager.setTestDispatcher(dispatcher, useReentrantLock = false)
+ val previousDispatcher = CollectionManager.setTestDispatcher(dispatcher, useReentrantLock = false)
try {
coroutineScope(block)
} finally {
- CollectionManager.setTestDispatcher(UnconfinedTestDispatcher(testScheduler))
+ CollectionManager.setTestDispatcher(previousDispatcher)
}
}
}
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerCancellationTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerCancellationTest.kt
new file mode 100644
index 000000000000..7e862257c5c4
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerCancellationTest.kt
@@ -0,0 +1,78 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.ichi2.anki.CollectionManager.withCol
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.launch
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.util.ReflectionHelpers
+import java.util.concurrent.atomic.AtomicReference
+import java.util.concurrent.locks.ReentrantLock
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+import kotlin.time.Duration.Companion.seconds
+import kotlin.time.TimeSource
+
+@RunWith(AndroidJUnit4::class)
+class CollectionManagerCancellationTest : RobolectricTest() {
+ @Test
+ fun `withCol rejects an already cancelled coroutine`() =
+ runTest {
+ assertFalse(CollectionManager.isOpenUnsafe())
+ launch {
+ cancel()
+ assertCollectionAccessCancelled()
+ }.join()
+ assertFalse(CollectionManager.isOpenUnsafe(), "cancelled work must not open the collection")
+ }
+
+ @Test
+ fun `withCol skips work cancelled while waiting for the Robolectric lock`() =
+ runTest {
+ val cancelled = withCol { cancelQueuedCollectionAccess() }
+ cancelled.join()
+ // Cancellation must release the lock and leave subsequent collection access working.
+ assertFalse(withCol { dbClosed })
+ }
+
+ private suspend fun assertCollectionAccessCancelled() {
+ assertFailsWith {
+ withCol { error("cancelled work must not run") }
+ }
+ }
+
+ /** The caller holds the collection lock until the worker has been cancelled. */
+ private fun CoroutineScope.cancelQueuedCollectionAccess(): Job {
+ val waitingThread = AtomicReference()
+ val worker =
+ launch(Dispatchers.IO) {
+ waitingThread.set(Thread.currentThread())
+ assertCollectionAccessCancelled()
+ }
+ try {
+ awaitCollectionLock(waitingThread)
+ } finally {
+ // Also cancel on assertion failure, before the caller releases the lock.
+ worker.cancel()
+ }
+ return worker
+ }
+
+ private fun awaitCollectionLock(waitingThread: AtomicReference) {
+ // Inspect the actual lock so cancellation happens after the worker starts waiting.
+ val mutex = ReflectionHelpers.getStaticField(CollectionManager::class.java, "testMutex")
+ val start = TimeSource.Monotonic.markNow()
+ while (waitingThread.get()?.let(mutex::hasQueuedThread) != true) {
+ assertTrue(start.elapsedNow() < 10.seconds, "worker did not wait for the collection lock")
+ Thread.sleep(1)
+ }
+ }
+}
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerImportTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerImportTest.kt
new file mode 100644
index 000000000000..16a6eef4aea0
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerImportTest.kt
@@ -0,0 +1,115 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki
+
+import androidx.core.content.edit
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.ichi2.anki.common.storage.CollectionHelper
+import net.ankiweb.rsdroid.BackendException
+import net.ankiweb.rsdroid.BackendException.BackendDbException.BackendDbLockedException
+import net.ankiweb.rsdroid.BackendException.BackendImportException
+import net.ankiweb.rsdroid.exceptions.BackendIoException
+import net.ankiweb.rsdroid.exceptions.BackendSyncException
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.io.File
+import java.util.zip.ZipEntry
+import java.util.zip.ZipOutputStream
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertIs
+import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
+
+@RunWith(AndroidJUnit4::class)
+class CollectionManagerImportTest : RobolectricTest() {
+ override fun getCollectionStorageMode() = CollectionStorageMode.ON_DISK
+
+ @Before
+ fun setCollectionPath() {
+ getPreferences().edit {
+ putString(CollectionHelper.PREF_COLLECTION_PATH, tempFolder.newFolder("collection").absolutePath)
+ }
+ }
+
+ @Test
+ fun `invalid backup leaves the original collection open`() =
+ runTest {
+ val backup = tempFolder.newFile("invalid.colpkg").apply { writeText("invalid backup") }
+
+ assertFailedImportKeepsCollectionOpen(backup)
+ }
+
+ @Test
+ fun `missing backup leaves the original collection open`() =
+ runTest {
+ assertFailedImportKeepsCollectionOpen(File(tempFolder.root, "missing.colpkg"))
+ }
+
+ @Test
+ fun `corrupt database in a valid archive leaves the original collection open`() =
+ runTest {
+ val failure = assertFailedImportKeepsCollectionOpen(corruptDatabaseBackup())
+
+ assertEquals(CollectionManager.TR.importingTheProvidedFileIsNotA(), failure.localizedMessage)
+ }
+
+ @Test
+ fun `failure to reopen does not replace the import error`() =
+ runTest {
+ addBasicNote()
+ val backup = corruptDatabaseBackup()
+ val expectedMessage = CollectionManager.TR.importingTheProvidedFileIsNotA()
+ CollectionManager.emulatedOpenFailure = CollectionManager.CollectionOpenFailure.LOCKED
+ try {
+ val failure = assertFailsWith { CollectionManager.importColpkg(backup.path) }
+
+ assertEquals(expectedMessage, failure.localizedMessage)
+ assertIs(failure.suppressed.single())
+ } finally {
+ CollectionManager.emulatedOpenFailure = null
+ }
+ }
+
+ @Test
+ fun `valid backup replaces the collection`() =
+ runTest {
+ val note = addBasicNote("backed up", "answer")
+ val backupDirectory = tempFolder.newFolder("backups")
+ assertTrue(col.createBackup(backupDirectory.path, force = true, waitForCompletion = true))
+ val files = assertNotNull(backupDirectory.listFiles(), "Unable to list backup directory: $backupDirectory")
+ val backups = files.filter { it.extension == "colpkg" }
+ assertEquals(1, backups.size, "Expected one collection backup in $backupDirectory; found ${files.map { it.name }}")
+ val backup = backups.single()
+ addBasicNote("added after backup", "answer")
+
+ CollectionManager.importColpkg(backup.path)
+
+ CollectionManager.withCol {
+ assertEquals(1, noteCount())
+ assertEquals("backed up", getNote(note.id).fields[0])
+ }
+ }
+
+ private fun corruptDatabaseBackup(): File =
+ tempFolder.newFile("corrupt.colpkg").apply {
+ ZipOutputStream(outputStream()).use { archive ->
+ archive.putNextEntry(ZipEntry("collection.anki2"))
+ archive.write("not a SQLite database".toByteArray())
+ archive.closeEntry()
+ }
+ }
+
+ private suspend inline fun assertFailedImportKeepsCollectionOpen(backup: File): T {
+ val note = addBasicNote("original", "answer")
+
+ val failure = assertFailsWith { CollectionManager.importColpkg(backup.path) }
+
+ // withCol would reopen the collection and hide the regression.
+ val fields = CollectionManager.withOpenColOrNull { getNote(note.id).fields.toList() }
+ assertNotNull(fields, "The original collection should already be open after the import fails")
+ assertEquals(listOf("original", "answer"), fields)
+ return failure
+ }
+}
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerStackTraceTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerStackTraceTest.kt
index 8c38dca3ffc9..1cf6d04952cd 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerStackTraceTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerStackTraceTest.kt
@@ -2,12 +2,11 @@
package com.ichi2.anki
-import android.os.Build
import androidx.test.ext.junit.runners.AndroidJUnit4
+import kotlinx.coroutines.test.StandardTestDispatcher
import net.ankiweb.rsdroid.BackendException.BackendDbException.BackendDbLockedException
import org.junit.Test
import org.junit.runner.RunWith
-import org.robolectric.shadows.ShadowBuild
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
@@ -17,9 +16,9 @@ class CollectionManagerStackTraceTest : RobolectricTest() {
@Test
fun `opening failure records the withCol caller`() =
runTest {
- val originalFingerprint = Build.FINGERPRINT
- // Exercise the real dispatcher switch: Robolectric's synchronous queue retains the caller.
- ShadowBuild.setFingerprint("collection-stack-trace-test")
+ // Queue collection access so the failure occurs after the caller suspends.
+ val previousQueue =
+ CollectionManager.setTestDispatcher(StandardTestDispatcher(testScheduler), useReentrantLock = false)
try {
CollectionManager.emulatedOpenFailure = CollectionManager.CollectionOpenFailure.LOCKED
@@ -29,7 +28,8 @@ class CollectionManagerStackTraceTest : RobolectricTest() {
val caller = failure.suppressed.single()
assertTrue(caller.stackTrace.any { it.methodName == "requestCollection" })
} finally {
- ShadowBuild.setFingerprint(originalFingerprint)
+ CollectionManager.emulatedOpenFailure = null
+ CollectionManager.setTestDispatcher(previousQueue)
}
}
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionStorageModeTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionStorageModeTest.kt
new file mode 100644
index 000000000000..85e4729ed17d
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionStorageModeTest.kt
@@ -0,0 +1,110 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki
+
+import com.ichi2.anki.CollectionManager.withCol
+import com.ichi2.anki.libanki.Collection
+import com.ichi2.anki.libanki.CollectionFiles
+import net.ankiweb.rsdroid.BackendException.BackendDbException.BackendDbLockedException
+import org.hamcrest.MatcherAssert.assertThat
+import org.hamcrest.Matchers.equalTo
+import org.hamcrest.Matchers.instanceOf
+import org.hamcrest.Matchers.nullValue
+import org.hamcrest.Matchers.sameInstance
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.ParameterizedRobolectricTestRunner
+import java.io.File
+
+@RunWith(ParameterizedRobolectricTestRunner::class)
+class CollectionStorageModeTest(
+ private val storageMode: String,
+) : RobolectricTest() {
+ override fun getCollectionStorageMode() = CollectionStorageMode.valueOf(storageMode)
+
+ @Test
+ fun `withCol opens the selected storage mode`() =
+ runTest {
+ assertThat(CollectionManager.isOpenUnsafe(), equalTo(false))
+ val opened = withCol { this }
+ assertStorageMode(opened)
+ assertThat(col, sameInstance(opened))
+ }
+
+ @Test
+ fun `synchronous access opens the selected storage mode`() {
+ assertThat(CollectionManager.isOpenUnsafe(), equalTo(false))
+ val opened = CollectionManager.getColUnsafe()
+ assertStorageMode(opened)
+ assertThat(col, sameInstance(opened))
+ }
+
+ @Test
+ fun `test and application access share the collection`() =
+ runTest {
+ val opened = col
+ assertStorageMode(opened)
+ assertThat(withCol { this }, sameInstance(opened))
+ }
+
+ @Test
+ fun `test access respects collection opening failures`() =
+ runTest {
+ withNullCollection {
+ assertThat(runCatching { col }.exceptionOrNull(), instanceOf(BackendDbLockedException::class.java))
+ assertThat(runCatching { withCol { this } }.exceptionOrNull(), instanceOf(BackendDbLockedException::class.java))
+ }
+ }
+
+ @Test
+ fun `reopening retains the selected storage mode`() =
+ runTest {
+ val mediaFolder = col.collectionFiles.mediaFolder
+ CollectionManager.ensureClosed()
+ assertThat(CollectionManager.isOpenUnsafe(), equalTo(false))
+
+ val reopened = withCol { this }
+ assertStorageMode(reopened)
+ assertThat(reopened.collectionFiles.mediaFolder, equalTo(mediaFolder))
+ assertThat(col, sameInstance(reopened))
+ }
+
+ @Test
+ fun `discarding the backend retains the storage mode`() =
+ runTest {
+ val mediaFolder = col.collectionFiles.mediaFolder
+ CollectionManager.discardBackend()
+
+ val reopened = withCol { this }
+ assertStorageMode(reopened)
+ assertThat(reopened.collectionFiles.mediaFolder, equalTo(mediaFolder))
+ assertThat(col, sameInstance(reopened))
+ }
+
+ private fun assertStorageMode(collection: Collection) {
+ val databasePath = collection.db.queryString("select file from pragma_database_list where name = 'main'")
+ when (getCollectionStorageMode()) {
+ CollectionStorageMode.IN_MEMORY_NO_FOLDERS -> {
+ assertThat(databasePath, equalTo(""))
+ assertThat(collection.collectionFiles, sameInstance(CollectionFiles.InMemory))
+ assertThat(collection.collectionFiles.mediaFolder, nullValue())
+ }
+ CollectionStorageMode.IN_MEMORY_WITH_MEDIA -> {
+ assertThat(databasePath, equalTo(""))
+ assertThat(collection.collectionFiles, instanceOf(CollectionFiles.InMemoryWithMedia::class.java))
+ assertThat(collection.collectionFiles.mediaFolder?.isDirectory, equalTo(true))
+ }
+ CollectionStorageMode.ON_DISK -> {
+ assertThat(collection.collectionFiles, instanceOf(CollectionFiles.FolderBasedCollection::class.java))
+ assertThat(File(databasePath).canonicalPath, equalTo(collection.colDb.canonicalPath))
+ assertThat(collection.colDb.isFile, equalTo(true))
+ }
+ }
+ }
+
+ companion object {
+ @JvmStatic
+ @ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
+ fun storageModes(): List = CollectionStorageMode.entries.map { it.name }
+ }
+}
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerBackupRestoreTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerBackupRestoreTest.kt
new file mode 100644
index 000000000000..6e57c98e03ee
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerBackupRestoreTest.kt
@@ -0,0 +1,87 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki
+
+import androidx.appcompat.app.AlertDialog
+import androidx.core.content.edit
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.ichi2.anki.CollectionManager.TR
+import com.ichi2.anki.common.storage.CollectionHelper
+import com.ichi2.testutils.BackupManagerTestUtilities
+import com.ichi2.utils.positiveButton
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.shadows.ShadowDialog
+import java.io.File
+import java.util.zip.ZipOutputStream
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.test.assertTrue
+import kotlin.test.fail
+
+@RunWith(AndroidJUnit4::class)
+class DeckPickerBackupRestoreTest : RobolectricTest() {
+ override fun getCollectionStorageMode() = CollectionStorageMode.ON_DISK
+
+ @Before
+ fun setCollectionPath() {
+ getPreferences().edit {
+ putString(CollectionHelper.PREF_COLLECTION_PATH, tempFolder.newFolder("collection").absolutePath)
+ }
+ }
+
+ @After
+ fun resetBackupSpace() {
+ BackupManagerTestUtilities.reset()
+ }
+
+ @Test
+ fun `failed restore reports the error and leaves the original collection open`() {
+ val note = addBasicNote("original", "answer")
+ val backup = createInvalidBackup()
+
+ withDeckPicker(deckCount = 0) { deckPicker ->
+ throwOnShowError = false
+ deckPicker.importColpkgListener = ImportColpkgListener { fail("Failed restore reported success") }
+
+ deckPicker.restoreBackupAndWaitForError(backup)
+
+ assertEquals(TR.importingTheProvidedFileIsNotA(), getAlertDialogText(checkDismissed = false))
+ // Check before accessing col: that getter would reopen it and hide the regression.
+ assertTrue(CollectionManager.isOpenUnsafe(), "Failed restore left the collection closed")
+
+ clickAlertDialogButton { positiveButton }
+ assertTrue(CollectionManager.isOpenUnsafe(), "Dismissing the restore error left the collection closed")
+ assertEquals(listOf("original", "answer"), col.getNote(note.id).fields.toList())
+ }
+ }
+
+ private fun createInvalidBackup(): File {
+ val backupDirectory = BackupManager.getBackupDirectory(CollectionManager.getCollectionDirectory())
+ val backup = File(backupDirectory, "backup-2020-07-07-07.00.00.colpkg")
+ // A valid ZIP missing its collection produces the localized invalid-backup error.
+ ZipOutputStream(backup.outputStream()).use { }
+ return backup
+ }
+
+ private fun DeckPicker.restoreBackupAndWaitForError(backup: File) {
+ assertTrue(shadowOf(this).clickMenuItem(R.id.action_restore_backup))
+ advanceRobolectricLooper()
+ clickAlertDialogButton { positiveButton }
+ val chooser = assertIs(ShadowDialog.getLatestDialog())
+ val listView = chooser.listView
+ assertEquals(1, listView.count)
+ assertTrue(
+ listView.performItemClick(listView.getChildAt(0), 0, listView.adapter.getItemId(0)),
+ "Selecting the backup did not invoke its restore listener",
+ )
+
+ advanceRobolectricLooperUntil(lazyMessage = { "Expected a restore error dialog after selecting ${backup.name}" }) {
+ val dialog = ShadowDialog.getLatestDialog()
+ dialog is AlertDialog && dialog !== chooser && dialog.isShowing
+ }
+ }
+}
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerBackupTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerBackupTest.kt
new file mode 100644
index 000000000000..845d24664cdf
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerBackupTest.kt
@@ -0,0 +1,66 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.ichi2.anki.CollectionManager.TR
+import com.ichi2.testutils.BackupManagerTestUtilities
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.shadows.ShadowToast
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+@RunWith(AndroidJUnit4::class)
+class DeckPickerBackupTest : RobolectricTest() {
+ override fun getCollectionStorageMode() = CollectionStorageMode.ON_DISK
+
+ @After
+ fun resetBackupSpace() {
+ BackupManagerTestUtilities.reset()
+ }
+
+ @Test
+ fun `create backup reports when collection is unchanged`() =
+ withBackupDeckPicker { deckPicker ->
+ assertTrue(
+ col.createBackup(
+ BackupManager.getBackupDirectoryFromCollection(col.colDb),
+ force = true,
+ waitForCompletion = true,
+ ),
+ )
+
+ deckPicker.createBackupAndWait()
+
+ assertEquals(TR.profilesBackupUnchanged(), ShadowToast.getTextOfLatestToast())
+ assertEquals(1, BackupManager.getBackups(col.colDb).size)
+ }
+
+ @Test
+ fun `create backup reports success after writing backup`() =
+ withBackupDeckPicker { deckPicker ->
+ deckPicker.createBackupAndWait()
+
+ assertEquals(TR.profilesBackupCreated(), ShadowToast.getTextOfLatestToast())
+ assertEquals(1, BackupManager.getBackups(col.colDb).size)
+ }
+
+ private fun withBackupDeckPicker(block: suspend (DeckPicker) -> Unit) =
+ runTest {
+ // Dispatch the IO continuation back to the test thread before updating the UI.
+ Dispatchers.setMain(StandardTestDispatcher(testScheduler))
+ lateinit var deckPicker: DeckPicker
+ withDeckPicker(deckCount = 0, withCards = true) { deckPicker = it }
+ block(deckPicker)
+ }
+
+ private suspend fun DeckPicker.createBackupAndWait() {
+ ShadowToast.reset()
+ createBackup().join()
+ }
+}
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerDatabaseRestorationStateTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerDatabaseRestorationStateTest.kt
new file mode 100644
index 000000000000..0d17bb0b1f45
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerDatabaseRestorationStateTest.kt
@@ -0,0 +1,60 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki
+
+import android.content.Intent
+import androidx.core.content.edit
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.ichi2.anki.common.preferences.sharedPrefs
+import com.ichi2.anki.common.storage.CollectionHelper
+import com.ichi2.testutils.BackupManagerTestUtilities
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.test.assertNotSame
+import kotlin.test.assertSame
+
+@RunWith(AndroidJUnit4::class)
+class DeckPickerDatabaseRestorationStateTest : RobolectricTest() {
+ @Before
+ fun prepareCollection() {
+ setIntroductionSlidesShown(true)
+ addBasicNote()
+ BackupManagerTestUtilities.setupSpaceForBackup(targetContext)
+ InitialActivity.setUpgradedToLatestVersion(targetContext.sharedPrefs())
+ targetContext.sharedPrefs().edit {
+ putBoolean("backupPromptDisabled", true)
+ putBoolean(getResourceString(R.string.automatic_sync_choice_key), false)
+ }
+ }
+
+ @After
+ fun resetBackupSpace() {
+ BackupManagerTestUtilities.reset()
+ }
+
+ @Test
+ fun `recreation restores the destination and binds the listener to the new activity`() {
+ val previousOverride = CollectionHelper.ankiDroidDirectoryOverride
+ try {
+ val controller = startActivityControllerNormallyOpenCollectionWithIntent(DeckPicker::class.java, Intent())
+ val original = controller.get()
+ val destination = tempFolder.newFolder("restored-collection")
+ original.importColpkgListener = DatabaseRestorationListener(original, destination)
+
+ controller.recreate()
+ advanceRobolectricLooper()
+
+ val recreated = controller.get()
+ assertNotSame(original, recreated)
+ val listener = assertIs(recreated.importColpkgListener)
+ assertEquals(destination, listener.newAnkiDroidDirectory)
+ assertSame(recreated, listener.activity)
+ } finally {
+ CollectionHelper.ankiDroidDirectoryOverride = previousOverride
+ }
+ }
+}
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerLoginSyncStateTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerLoginSyncStateTest.kt
new file mode 100644
index 000000000000..3e6e1706b85b
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerLoginSyncStateTest.kt
@@ -0,0 +1,77 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki
+
+import android.content.Intent
+import androidx.core.content.edit
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.ichi2.anki.common.preferences.sharedPrefs
+import com.ichi2.testutils.BackupManagerTestUtilities
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.android.controller.ActivityController
+import kotlin.test.assertEquals
+import kotlin.test.assertNotSame
+
+@RunWith(AndroidJUnit4::class)
+class DeckPickerLoginSyncStateTest : RobolectricTest() {
+ @Before
+ fun prepareCollection() {
+ setIntroductionSlidesShown(true)
+ addBasicNote()
+ BackupManagerTestUtilities.setupSpaceForBackup(targetContext)
+ InitialActivity.setUpgradedToLatestVersion(targetContext.sharedPrefs())
+ targetContext.sharedPrefs().edit {
+ putBoolean("backupPromptDisabled", true)
+ putBoolean(getResourceString(R.string.automatic_sync_choice_key), false)
+ }
+ }
+
+ @After
+ fun resetBackupSpace() {
+ BackupManagerTestUtilities.reset()
+ }
+
+ @Test
+ fun `launch login sync is not repeated after recreation`() {
+ val controller = launch(autoSync = true)
+ assertEquals(1, controller.get().syncRequests)
+
+ assertNoSyncAfterRecreation(controller)
+ }
+
+ @Test
+ fun `an existing activity still handles a new login request`() {
+ val controller = launch(autoSync = false)
+ assertEquals(0, controller.get().syncRequests)
+ controller.pause().newIntent(loginIntent(autoSync = true)).resume()
+ advanceRobolectricLooper()
+ assertEquals(1, controller.get().syncRequests)
+ }
+
+ private fun launch(autoSync: Boolean): ActivityController =
+ startActivityControllerNormallyOpenCollectionWithIntent(SyncCountingDeckPicker::class.java, loginIntent(autoSync))
+
+ private fun loginIntent(autoSync: Boolean): Intent =
+ DeckPicker.getIntent(targetContext, autoSync).setClass(targetContext, SyncCountingDeckPicker::class.java)
+
+ private fun assertNoSyncAfterRecreation(controller: ActivityController) {
+ val original = controller.get()
+ controller.recreate()
+ advanceRobolectricLooper()
+
+ assertNotSame(original, controller.get())
+ assertEquals(0, controller.get().syncRequests, "Recreation must not request another sync")
+ }
+
+ class SyncCountingDeckPicker : DeckPicker() {
+ var syncRequests = 0
+ private set
+
+ override fun sync(conflict: ConflictResolution?) {
+ syncRequests++
+ }
+ }
+}
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt
index dec67c58fb48..0bc617e090ba 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt
@@ -113,6 +113,7 @@ import timber.log.Timber
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
+import kotlin.test.assertNotSame
import kotlin.test.assertTrue
import kotlin.time.Duration.Companion.seconds
@@ -1018,6 +1019,38 @@ class DeckPickerTest : RobolectricTest() {
)
}
+ @Test
+ fun `expanded FAB menu is restored after recreation`() {
+ ActivityScenario.launch(DeckPicker::class.java).use { scenario ->
+ lateinit var originalActivity: DeckPicker
+ scenario.onActivity { deckPicker ->
+ originalActivity = deckPicker
+ deckPicker.floatingActionMenu.showFloatingActionMenu()
+ }
+
+ scenario.recreate()
+ advanceRobolectricLooper()
+
+ scenario.onActivity { deckPicker ->
+ assertNotSame(originalActivity, deckPicker)
+ assertTrue(deckPicker.floatingActionMenu.isFABOpen)
+ with(deckPicker.floatingActionButtonBinding) {
+ assertEquals(View.VISIBLE, addSharedButton.visibility)
+ assertEquals(View.VISIBLE, addDeckButton.visibility)
+ assertEquals(View.VISIBLE, addFilteredDeckButton.visibility)
+ }
+
+ deckPicker.invalidateOptionsMenu()
+ advanceRobolectricLooper()
+ assertTrue(deckPicker.floatingActionMenu.isFABOpen)
+
+ deckPicker.onBackPressedDispatcher.onBackPressed()
+
+ assertFalse(deckPicker.floatingActionMenu.isFABOpen)
+ }
+ }
+ }
+
@Test
fun `expanding the FAB menu shows the correct labels`() =
deckPicker {
@@ -1057,6 +1090,37 @@ class DeckPickerTest : RobolectricTest() {
)
}
+ @Test
+ fun `bottom navigation features are disabled on tablets`() =
+ withBottomNavigationEnabled {
+ deckPicker {
+ assumeTrue("Running on tablet", fragmented)
+
+ assertThat(Prefs.devBottomNavEnabled, equalTo(true))
+ assertThat(bottomNavigationEnabled, equalTo(false))
+ assertThat(resources.getBoolean(R.bool.bottom_navigation_available), equalTo(false))
+ val bottomNavigation = ActivityHomescreenBinding.bind(findViewById(R.id.root_layout)).bottomNavigation
+ assertThat(bottomNavigation, nullValue())
+ assertThat(deckPickerBinding.decks.itemDecorationCount, equalTo(0))
+ }
+ }
+
+ @Test
+ fun `bottom navigation follows the single pane layout on large screens`() =
+ withBottomNavigationEnabled {
+ assumeTrue("Only run once", qualifiers == "normal")
+ RuntimeEnvironment.setQualifiers("sw480dp-large")
+ deckPicker {
+ assertThat(fragmented, equalTo(false))
+ assertThat(Prefs.devBottomNavEnabled, equalTo(true))
+ assertThat(bottomNavigationEnabled, equalTo(true))
+ assertThat(resources.getBoolean(R.bool.bottom_navigation_available), equalTo(true))
+ assertThat(binding.bottomNavigation, notNullValue())
+ assertThat(binding.bottomNavigation!!.visibility, equalTo(View.VISIBLE))
+ assertThat(deckPickerBinding.decks.itemDecorationCount, equalTo(1))
+ }
+ }
+
@Test
fun `bottom navigation has correct labels`() =
withBottomNavigationEnabled {
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/FragmentCoroutineLifecycleTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/FragmentCoroutineLifecycleTest.kt
new file mode 100644
index 000000000000..7d78a6fd3a68
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/FragmentCoroutineLifecycleTest.kt
@@ -0,0 +1,173 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki
+
+import android.os.Build
+import androidx.fragment.app.DialogFragment
+import androidx.fragment.app.Fragment
+import androidx.fragment.app.commitNow
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.lifecycleScope
+import com.ichi2.anki.FragmentLifecycleFixture.Transition
+import com.ichi2.anki.FragmentLifecycleFixture.Transition.DESTROY_VIEW
+import com.ichi2.anki.FragmentLifecycleFixture.Transition.RECREATE_VIEW
+import com.ichi2.anki.FragmentLifecycleFixture.Transition.REMOVE
+import com.ichi2.testutils.FragmentCoroutineTracker
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import org.junit.Assume.assumeFalse
+import org.junit.Assume.assumeTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.ParameterizedRobolectricTestRunner
+import org.robolectric.Robolectric
+import org.robolectric.shadows.ShadowBuild
+import kotlin.test.assertEquals
+import kotlin.test.assertNotNull
+import kotlin.test.assertNotSame
+import kotlin.test.assertNull
+import kotlin.test.assertSame
+import kotlin.test.assertTrue
+
+/** Startup lifecycle stress tests for Issue 22065; not navigation reproductions for every screen. */
+@RunWith(ParameterizedRobolectricTestRunner::class)
+class FragmentCoroutineLifecycleTest(
+ private val fixture: FragmentLifecycleFixture,
+) : RobolectricTest() {
+ override fun getCollectionStorageMode() = CollectionStorageMode.ON_DISK
+
+ /**
+ * Validates a normal startup path, and that there are no active coroutines after onDestroy().
+ */
+ @Test
+ fun `normal startup and removal`() =
+ withHost { activity, fragment ->
+ add(activity, fragment)
+ advanceRobolectricLooper()
+ assertEquals(Lifecycle.State.RESUMED, fragment.lifecycle.currentState)
+ activity.supportFragmentManager.commitNow { remove(fragment) }
+ advanceRobolectricLooper()
+ assertNull(fragment.activity)
+ }
+
+ /**
+ * Removes the fragment while startup coroutines are still queued (#22065).
+ * Delayed work should neither crash nor outlive the destroyed lifecycle.
+ */
+ @Test
+ fun `removed before startup coroutines execute`() =
+ withQueuedStartup(REMOVE) { activity, fragment ->
+ add(activity, fragment)
+ val lifecycle = fragment.lifecycle
+ activity.supportFragmentManager.commitNow { remove(fragment) }
+ assertEquals(Lifecycle.State.DESTROYED, lifecycle.currentState)
+ assertNull(fragment.activity)
+ }
+
+ /**
+ * Destroys the view before startup work runs, while keeping the fragment attached to its activity.
+ *
+ * - No crashes if a view is missing.
+ * - No coroutines outlive the destroyed view lifecycle.
+ */
+ @Test
+ fun `view destroyed before startup coroutines execute`() =
+ withQueuedStartup(DESTROY_VIEW) { activity, fragment ->
+ add(activity, fragment)
+ assertNotNull(fragment.view)
+ val owner = fragment.viewLifecycleOwner
+ activity.supportFragmentManager.commitNow { detach(fragment) }
+ assertEquals(Lifecycle.State.DESTROYED, owner.lifecycle.currentState)
+ assertNull(fragment.view)
+ assertSame(activity, fragment.activity)
+ assertEquals(Lifecycle.State.CREATED, fragment.lifecycle.currentState)
+ }
+
+ /**
+ * Detaches and reattaches the fragment before startup work runs, exercising view recreation.
+ *
+ * - Startup survives the transition.
+ * - No coroutines remain in the old view lifecycle scope.
+ */
+ @Test
+ fun `view recreated before startup coroutines execute`() =
+ withQueuedStartup(RECREATE_VIEW) { activity, fragment ->
+ add(activity, fragment)
+ val oldView = assertNotNull(fragment.view)
+ val oldOwner = fragment.viewLifecycleOwner
+ activity.supportFragmentManager.commitNow { detach(fragment) }
+ assertEquals(Lifecycle.State.DESTROYED, oldOwner.lifecycle.currentState)
+ activity.supportFragmentManager.commitNow { attach(fragment) }
+ assertNotSame(oldView, assertNotNull(fragment.view))
+ assertNotSame(oldOwner, fragment.viewLifecycleOwner)
+ assertEquals(Lifecycle.State.RESUMED, fragment.viewLifecycleOwner.lifecycle.currentState)
+ assertEquals(Lifecycle.State.RESUMED, fragment.lifecycle.currentState)
+ }
+
+ private fun withQueuedStartup(
+ transition: Transition,
+ block: (AnkiActivity, Fragment) -> Unit,
+ ) {
+ if (transition != REMOVE) {
+ assumeTrue("$fixture has no fragment view", fixture.hasFragmentView)
+ }
+ assumeFalse("Known startup failure: $fixture during $transition (Issue 22065)", transition in fixture.knownFailures)
+ withHost { activity, fragment ->
+ // Main.immediate queues nested launches until this outer coroutine finishes.
+ // This lets the test destroy the fragment/view before its startup work runs.
+ // Using runTest would replace Main and hide that ordering.
+ val job = activity.lifecycleScope.launch { block(activity, fragment) }
+ advanceRobolectricLooper()
+ assertTrue(job.isCompleted, "The lifecycle transition must execute")
+ }
+ }
+
+ private fun withHost(block: (AnkiActivity, Fragment) -> Unit) {
+ // Use a real open collection for settings reads and view model initialization.
+ col
+ val controller = Robolectric.buildActivity(fixture.host).setup()
+ saveControllerForCleanup(controller)
+ advanceRobolectricLooper()
+ val activity = controller.get()
+ withCoroutineScheduling {
+ FragmentCoroutineTracker(activity.supportFragmentManager).use { tracker ->
+ block(activity, fixture.create(this))
+ tracker.assertDestroyedLifecyclesAreIdle()
+ }
+ }
+ }
+
+ private fun withCoroutineScheduling(block: () -> Unit) {
+ val originalFingerprint = Build.FINGERPRINT
+ // Complete collection work deterministically without replacing Main.immediate.
+ val originalDispatcher = CollectionManager.setTestDispatcher(UnconfinedTestDispatcher())
+ // Flow.launchCollectionInLifecycleScope otherwise posts to a test-only Handler,
+ // which adds a lifecycle check and hides failures in the production collector.
+ ShadowBuild.setFingerprint("fragment-lifecycle-test")
+ try {
+ block()
+ } finally {
+ CollectionManager.setTestDispatcher(originalDispatcher)
+ ShadowBuild.setFingerprint(originalFingerprint)
+ }
+ }
+
+ private fun add(
+ activity: AnkiActivity,
+ fragment: Fragment,
+ ) {
+ if (fragment is DialogFragment) {
+ fragment.showNow(activity.supportFragmentManager, "fragment-under-test")
+ } else {
+ activity.supportFragmentManager.commitNow { add(android.R.id.content, fragment) }
+ assertNotNull(fragment.view)
+ }
+ assertEquals(Lifecycle.State.RESUMED, fragment.lifecycle.currentState)
+ }
+
+ companion object {
+ @JvmStatic
+ @ParameterizedRobolectricTestRunner.Parameters(name = "{0}")
+ fun fixtures(): List = fragmentLifecycleFixtures()
+ }
+}
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/FragmentLifecycleFixture.kt b/AnkiDroid/src/test/java/com/ichi2/anki/FragmentLifecycleFixture.kt
new file mode 100644
index 000000000000..6935ec49a536
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/FragmentLifecycleFixture.kt
@@ -0,0 +1,79 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki
+
+import androidx.fragment.app.Fragment
+import com.ichi2.anki.FragmentLifecycleFixture.Transition.DESTROY_VIEW
+import com.ichi2.anki.FragmentLifecycleFixture.Transition.RECREATE_VIEW
+import com.ichi2.anki.FragmentLifecycleFixture.Transition.REMOVE
+import com.ichi2.anki.browser.BrowserColumnSelectionFragment
+import com.ichi2.anki.cardviewer.SingleCardSide
+import com.ichi2.anki.dialogs.ChangeNoteTypeDialog
+import com.ichi2.anki.dialogs.InsertFieldDialog
+import com.ichi2.anki.dialogs.InsertFieldMetadata
+import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog
+import com.ichi2.anki.filtered.FilteredDeckOptionsFragment
+import com.ichi2.anki.mediacheck.MediaCheckFragment
+import com.ichi2.anki.model.CardsOrNotes
+import com.ichi2.anki.preferences.AboutFragment
+import com.ichi2.anki.preferences.AdvancedSettingsFragment
+import com.ichi2.anki.preferences.AppearanceSettingsFragment
+import com.ichi2.anki.preferences.DeveloperOptionsFragment
+import com.ichi2.anki.preferences.GeneralSettingsFragment
+import com.ichi2.anki.preferences.ReviewerOptionsFragment
+import com.ichi2.anki.preferences.ReviewingSettingsFragment
+import com.ichi2.anki.preferences.SyncSettingsFragment
+import com.ichi2.anki.reviewreminders.AddEditReminderDialog
+import com.ichi2.anki.reviewreminders.ReviewReminderScope
+import com.ichi2.anki.reviewreminders.ScheduleRemindersFragment
+import com.ichi2.anki.ui.windows.managespace.ManageSpaceFragment
+
+/** A screen and its required setup for [FragmentCoroutineLifecycleTest]. */
+class FragmentLifecycleFixture(
+ private val name: String,
+ val host: Class = AnkiActivity::class.java,
+ val hasFragmentView: Boolean = true,
+ // Confirmed failures in the startup stress test. Remove each entry when its failure is fixed.
+ val knownFailures: Set = emptySet(),
+ val create: RobolectricTest.() -> Fragment,
+) {
+ enum class Transition { REMOVE, DESTROY_VIEW, RECREATE_VIEW }
+
+ override fun toString(): String = name
+}
+
+fun fragmentLifecycleFixtures(): List =
+ listOf(
+ FragmentLifecycleFixture("StudyOptions", knownFailures = setOf(REMOVE)) { StudyOptionsFragment() },
+ FragmentLifecycleFixture("AppearanceSettings", knownFailures = setOf(REMOVE)) { AppearanceSettingsFragment() },
+ FragmentLifecycleFixture("GeneralSettings", knownFailures = setOf(REMOVE)) { GeneralSettingsFragment() },
+ FragmentLifecycleFixture("ReviewerOptions", knownFailures = setOf(REMOVE)) { ReviewerOptionsFragment() },
+ FragmentLifecycleFixture("ReviewingSettings", knownFailures = setOf(REMOVE)) { ReviewingSettingsFragment() },
+ FragmentLifecycleFixture("AdvancedSettings") { AdvancedSettingsFragment() },
+ FragmentLifecycleFixture("DeveloperOptions") { DeveloperOptionsFragment() },
+ FragmentLifecycleFixture("SyncSettings") { SyncSettingsFragment() },
+ FragmentLifecycleFixture("About") { AboutFragment() },
+ FragmentLifecycleFixture("ScheduleReminders", knownFailures = setOf(REMOVE, DESTROY_VIEW)) { ScheduleRemindersFragment() },
+ FragmentLifecycleFixture("FilteredDeckOptions", knownFailures = setOf(REMOVE, DESTROY_VIEW, RECREATE_VIEW)) {
+ FilteredDeckOptionsFragment()
+ },
+ FragmentLifecycleFixture("MediaCheck", knownFailures = setOf(REMOVE, DESTROY_VIEW)) { MediaCheckFragment() },
+ FragmentLifecycleFixture("ManageSpace", knownFailures = setOf(REMOVE)) { ManageSpaceFragment() },
+ FragmentLifecycleFixture("ChangeNoteType", knownFailures = setOf(REMOVE)) {
+ ChangeNoteTypeDialog.newInstance(listOf(addBasicNote().id))
+ },
+ FragmentLifecycleFixture("CustomStudy", hasFragmentView = false) { CustomStudyDialog.createInstance(col.decks.selected()) },
+ FragmentLifecycleFixture("AddReminder", hasFragmentView = false, knownFailures = setOf(REMOVE)) {
+ AddEditReminderDialog.getInstance(AddEditReminderDialog.DialogMode.Add(ReviewReminderScope.Global))
+ },
+ FragmentLifecycleFixture("InsertField", knownFailures = setOf(REMOVE)) {
+ InsertFieldDialog.newInstance(
+ fieldItems = listOf("Front", "Back"),
+ metadata = InsertFieldMetadata(SingleCardSide.FRONT, "Card 1", "Basic", null, null, null, "Default"),
+ requestKey = "insert-field-test",
+ )
+ },
+ FragmentLifecycleFixture("BrowserColumns", host = CardBrowser::class.java, knownFailures = setOf(REMOVE, DESTROY_VIEW)) {
+ BrowserColumnSelectionFragment.createInstance(CardsOrNotes.CARDS)
+ },
+ )
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/NoteEditorTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/NoteEditorTest.kt
index 7c5b153bb1cb..0c6a5cbe01b3 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/NoteEditorTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/NoteEditorTest.kt
@@ -815,6 +815,83 @@ class NoteEditorTest : RobolectricTest() {
)
}
+ @Test
+ fun `hasUnsavedChanges - sticky field with a br line break survives a note type switch`() =
+ runTest {
+ val basic = makeNoteForType(NoteType.BASIC)
+ basic!!.fields[0].sticky = true
+
+ val editor =
+ getNoteEditorAdding(NoteType.BASIC)
+ .withFirstField("Hello World")
+ .build()
+
+ editor.saveNote()
+ advanceRobolectricLooper()
+ assertFalse(editor.hasUnsavedChanges(), "fresh screen after save: no real edits yet")
+
+ // switching note type re-populates every field from its own current content, which
+ // converts the sticky field's ` ` into a real line break - this must not look like
+ // an edit, since the field's rendered content is unchanged from the user's perspective
+ editor.noteType = col.notetypes.byName("Basic (optional reversed card)")!!
+ advanceRobolectricLooper()
+
+ assertFalse(
+ editor.hasUnsavedChanges(),
+ "the sticky field's content round-tripped through a /newline conversion but was never actually edited",
+ )
+ }
+
+ @Test
+ fun `hasUnsavedChanges - changing note type while adding does not discard unsaved field content`() =
+ runTest {
+ val editor =
+ getNoteEditorAdding(NoteType.BASIC)
+ .build()
+
+ // user types content into the first field but hasn't saved yet
+ editor.setFieldValueFromUi(0, "unsaved user content")
+ assertTrue(editor.hasUnsavedChanges(), "typing into a field is an unsaved change")
+
+ // user switches the note type (Basic -> Basic (and reversed)); both have a "Front" field
+ // at index 0, so the typed content is carried over into the new field layout
+ editor.noteType = col.notetypes.basicAndReversed
+ advanceRobolectricLooper()
+
+ assertThat(
+ "content should be preserved across the note type change",
+ editor.currentFieldStrings.toList(),
+ contains("unsaved user content", ""),
+ )
+ assertTrue(
+ editor.hasUnsavedChanges(),
+ "the user's unsaved content survived the note type change, so it must still be " +
+ "reported as an unsaved change - otherwise pressing back silently discards it",
+ )
+ }
+
+ @Test
+ fun `hasUnsavedChanges - typing then clearing a field before a note type switch that adds fields is not an unsaved change`() =
+ runTest {
+ val editor =
+ getNoteEditorAdding(NoteType.BASIC)
+ .build()
+
+ // user types into the first field, then reverts their own edit before switching
+ editor.setFieldValueFromUi(0, "x")
+ editor.setFieldValueFromUi(0, "")
+ assertFalse(editor.hasUnsavedChanges(), "user's only edit was reverted; nothing to save")
+
+ // switch to a note type with an extra field (Basic -> Basic (optional reversed card))
+ editor.noteType = col.notetypes.byName("Basic (optional reversed card)")!!
+ advanceRobolectricLooper()
+
+ assertFalse(
+ editor.hasUnsavedChanges(),
+ "all fields are still blank after the note type switch added a field; there is nothing to save",
+ )
+ }
+
@Test
fun `changing deck with multiple card ids moves all sibling cards`() =
runTest {
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ReadTextPickerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ReadTextPickerTest.kt
new file mode 100644
index 000000000000..3f5fb4535bef
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/ReadTextPickerTest.kt
@@ -0,0 +1,102 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki
+
+import android.os.Looper
+import android.speech.tts.TextToSpeech
+import androidx.appcompat.app.AlertDialog
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.ichi2.anki.reviewer.CardSide
+import io.mockk.coEvery
+import io.mockk.mockk
+import io.mockk.mockkObject
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.test.advanceTimeBy
+import kotlinx.coroutines.test.runCurrent
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+import org.robolectric.shadows.ShadowDialog
+import java.util.Locale
+import java.util.concurrent.TimeUnit
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+@RunWith(AndroidJUnit4::class)
+@Config(shadows = [ReadTextTest.NoLanguageQueryTextToSpeech::class])
+class ReadTextPickerTest : RobolectricTest() {
+ override fun getCollectionStorageMode() = CollectionStorageMode.IN_MEMORY_WITH_MEDIA
+
+ @Test
+ fun `releasing TTS while the picker awaits languages prevents the dialog`() =
+ runTest {
+ addBasicNote()
+ val viewer = startRegularActivity()
+ val refreshed = CompletableDeferred>()
+ try {
+ mockkObject(TtsVoices) {
+ coEvery { TtsVoices.localesForEngine(any()) } returns listOf(Locale.US)
+ ReadText.initializeTts(viewer, mockk(relaxed = true))
+ shadowOf(ReadText.textToSpeech).onInitListener.onInit(TextToSpeech.SUCCESS)
+ runCurrent()
+
+ coEvery { TtsVoices.localesForEngine(any()) } coAnswers { refreshed.await() }
+ ReadText.selectTts("text", 1, 0, CardSide.QUESTION)
+ runCurrent()
+ ReadText.releaseTts(viewer)
+ assertFalse(refreshed.isCancelled)
+ refreshed.complete(listOf(Locale.FRANCE))
+ runCurrent()
+ advanceTimeBy(500)
+ runCurrent()
+ shadowOf(Looper.getMainLooper()).idleFor(500, TimeUnit.MILLISECONDS)
+
+ assertTrue(ShadowDialog.getShownDialogs().none { it.isShowing })
+ }
+ } finally {
+ ShadowDialog.getShownDialogs().forEach { it.dismiss() }
+ ReadText.releaseTts(viewer)
+ }
+ }
+
+ @Test
+ fun `language picker uses refreshed cached languages`() =
+ runTest {
+ addBasicNote()
+ val viewer = startRegularActivity()
+ var languages = listOf(Locale.US)
+ try {
+ mockkObject(TtsVoices) {
+ coEvery { TtsVoices.localesForEngine(any()) } coAnswers { languages }
+ ReadText.initializeTts(viewer, mockk(relaxed = true))
+ shadowOf(ReadText.textToSpeech).onInitListener.onInit(TextToSpeech.SUCCESS)
+ runCurrent()
+
+ // A successful voice refresh replaces the shared cache while this reviewer stays open.
+ languages = listOf(Locale.FRANCE)
+ ReadText.selectTts("text", 1, 0, CardSide.QUESTION)
+ runCurrent()
+ advanceTimeBy(500)
+ runCurrent()
+ shadowOf(Looper.getMainLooper()).idleFor(500, TimeUnit.MILLISECONDS)
+
+ val dialog = ShadowDialog.getLatestDialog() as AlertDialog
+ try {
+ assertEquals(2, dialog.listView.adapter.count)
+ assertEquals(
+ Locale.FRANCE.displayName,
+ dialog.listView.adapter
+ .getItem(1)
+ .toString(),
+ )
+ } finally {
+ dialog.dismiss()
+ }
+ }
+ } finally {
+ ReadText.releaseTts(viewer)
+ }
+ }
+}
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ReadTextTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ReadTextTest.kt
index 288dd9fca37b..b3796277c8fe 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/ReadTextTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/ReadTextTest.kt
@@ -3,19 +3,36 @@
package com.ichi2.anki
import android.content.Context
+import android.speech.tts.TextToSpeech
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.ichi2.anki.ReadText.closeForTests
import com.ichi2.anki.ReadText.initializeTts
import com.ichi2.anki.ReadText.releaseTts
import com.ichi2.anki.ReadText.textToSpeech
import com.ichi2.anki.reviewer.CardSide
+import io.mockk.coEvery
+import io.mockk.coVerify
+import io.mockk.mockkObject
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.test.runCurrent
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.equalTo
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito.mock
+import org.mockito.Mockito.never
+import org.mockito.Mockito.verify
+import org.mockito.Mockito.`when`
import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.Implementation
+import org.robolectric.annotation.Implements
+import org.robolectric.shadows.ShadowTextToSpeech
+import java.util.Locale
+import kotlin.test.assertFalse
@RunWith(AndroidJUnit4::class)
class ReadTextTest : RobolectricTest() {
@@ -75,6 +92,106 @@ class ReadTextTest : RobolectricTest() {
assertThat(isTextToSpeechShutdown, equalTo(false))
}
+ @Test
+ @Config(shadows = [NoLanguageQueryTextToSpeech::class])
+ fun `reviewer TTS waits for cached languages without blocking the callback`() =
+ runTest {
+ val (viewer, lifecycle) = createViewer()
+ val locales = CompletableDeferred>()
+ try {
+ mockkObject(TtsVoices) {
+ coEvery { TtsVoices.localesForEngine(any()) } coAnswers { locales.await() }
+ initializeTextToSpeech(viewer)
+
+ shadowOf(textToSpeech).onInitListener.onInit(TextToSpeech.SUCCESS)
+ runCurrent()
+ verify(viewer, never()).ttsInitialized()
+
+ locales.complete(listOf(Locale.US))
+ runCurrent()
+ verify(viewer).ttsInitialized()
+ coVerify { TtsVoices.localesForEngine("reviewer.engine") }
+ }
+ } finally {
+ lifecycle.currentState = Lifecycle.State.DESTROYED
+ }
+ }
+
+ @Test
+ @Config(shadows = [NoLanguageQueryTextToSpeech::class])
+ fun `releasing TTS cancels the reviewer wait without cancelling discovery`() = assertWaitCancelled { viewer, _ -> releaseTts(viewer) }
+
+ @Test
+ @Config(shadows = [NoLanguageQueryTextToSpeech::class])
+ fun `destroying the reviewer cancels its wait without cancelling discovery`() =
+ assertWaitCancelled { _, lifecycle -> lifecycle.currentState = Lifecycle.State.DESTROYED }
+
+ private fun assertWaitCancelled(stop: (AbstractFlashcardViewer, LifecycleRegistry) -> Unit) =
+ runTest {
+ val (viewer, lifecycle) = createViewer()
+ val locales = CompletableDeferred>()
+ try {
+ mockkObject(TtsVoices) {
+ coEvery { TtsVoices.localesForEngine(any()) } coAnswers { locales.await() }
+ initializeTextToSpeech(viewer)
+ shadowOf(textToSpeech).onInitListener.onInit(TextToSpeech.SUCCESS)
+ runCurrent()
+
+ stop(viewer, lifecycle)
+ runCurrent()
+ assertFalse(locales.isCancelled)
+ locales.complete(listOf(Locale.US))
+ runCurrent()
+
+ verify(viewer, never()).ttsInitialized()
+ }
+ } finally {
+ lifecycle.currentState = Lifecycle.State.DESTROYED
+ }
+ }
+
+ @Test
+ @Config(shadows = [NoLanguageQueryTextToSpeech::class])
+ fun `an old TTS callback cannot initialize its replacement`() =
+ runTest {
+ val (viewer, lifecycle) = createViewer()
+ try {
+ mockkObject(TtsVoices) {
+ coEvery { TtsVoices.localesForEngine(any()) } returns listOf(Locale.US)
+ initializeTextToSpeech(viewer)
+ val oldCallback = shadowOf(textToSpeech).onInitListener
+ initializeTextToSpeech(viewer)
+
+ oldCallback.onInit(TextToSpeech.SUCCESS)
+ runCurrent()
+ verify(viewer, never()).ttsInitialized()
+
+ shadowOf(textToSpeech).onInitListener.onInit(TextToSpeech.SUCCESS)
+ runCurrent()
+ verify(viewer).ttsInitialized()
+ }
+ } finally {
+ lifecycle.currentState = Lifecycle.State.DESTROYED
+ }
+ }
+
+ @Implements(TextToSpeech::class)
+ class NoLanguageQueryTextToSpeech : ShadowTextToSpeech() {
+ @Implementation
+ fun getDefaultEngine(): String = "reviewer.engine"
+
+ @Implementation
+ fun getAvailableLanguages(): Set = throw AssertionError("Reviewer must use the startup language cache")
+ }
+
+ private fun createViewer(): Pair {
+ val viewer = mock(AbstractFlashcardViewer::class.java)
+ val lifecycle = LifecycleRegistry(viewer)
+ `when`(viewer.lifecycle).thenReturn(lifecycle)
+ lifecycle.currentState = Lifecycle.State.RESUMED
+ return viewer to lifecycle
+ }
+
private val isTextToSpeechShutdown: Boolean
get() = shadowOf(textToSpeech).isShutdown
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerTest.kt
index 8f561cb744a8..0c8d226fce43 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerTest.kt
@@ -5,10 +5,15 @@ package com.ichi2.anki
import android.app.Application
import android.content.Intent
+import android.text.InputType
import android.view.Menu
import android.view.View
+import android.view.inputmethod.EditorInfo
+import android.view.inputmethod.InputMethodManager
+import android.widget.EditText
import androidx.annotation.CheckResult
import androidx.core.content.edit
+import androidx.core.content.getSystemService
import androidx.core.os.BundleCompat
import androidx.core.view.iterator
import androidx.test.core.app.ActivityScenario
@@ -21,7 +26,9 @@ import com.ichi2.anki.AnkiDroidJsAPITest.Companion.jsApiContract
import com.ichi2.anki.CollectionManager.TR
import com.ichi2.anki.cardviewer.Gesture
import com.ichi2.anki.cardviewer.ViewerCommand.ANSWER_AGAIN
+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.time.MockTime
import com.ichi2.anki.common.time.TimeManager
@@ -42,8 +49,12 @@ import com.ichi2.anki.observability.undoableOp
import com.ichi2.anki.preferences.PreferenceTestUtils
import com.ichi2.anki.reviewer.ActionButtonStatus
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.withBooleanPreference
import junit.framework.TestCase.assertEquals
import junit.framework.TestCase.assertFalse
import junit.framework.TestCase.assertTrue
@@ -58,14 +69,71 @@ import org.junit.Assume.assumeTrue
import org.junit.Ignore
import org.junit.Test
import org.junit.runner.RunWith
+import org.robolectric.RuntimeEnvironment
import org.robolectric.Shadows
+import org.robolectric.annotation.Config
+import org.robolectric.shadow.api.Shadow
import timber.log.Timber
+import kotlin.test.assertContains
+import kotlin.test.assertNotEquals
import kotlin.test.junit5.JUnit5Asserter.assertNotNull
@RunWith(AndroidJUnit4::class)
class ReviewerTest : RobolectricTest() {
override fun getCollectionStorageMode() = CollectionStorageMode.IN_MEMORY_WITH_MEDIA
+ @Test
+ fun `nosuggest supports Done`() =
+ runTest {
+ targetContext.sharedPrefs().edit { putBoolean("useInputTag", false) }
+ col.addNoSuggestNote()
+ val reviewer = startReviewer()
+ val field = reviewer.findViewById(R.id.answer_field)
+ assertTrue(field.onCheckIsTextEditor())
+ assertNotEquals(InputType.TYPE_NULL, field.inputType)
+ val info = EditorInfo()
+ val connection = field.createInputConnection(info)
+ assertEquals(InputType.TYPE_NULL, info.inputType)
+
+ connection.performEditorAction(EditorInfo.IME_ACTION_DONE)
+ advanceRobolectricLooper()
+ assertTrue(reviewer.isDisplayingAnswer)
+ }
+
+ @Test
+ @Config(shadows = [RecordingInputMethodManager::class])
+ fun `nosuggest resets for the next typing card`() =
+ runTest {
+ targetContext.sharedPrefs().edit { putBoolean("useInputTag", false) }
+ val noSuggestCard = col.addNoSuggestNote().firstCard()
+ val normalCard = addBasicWithTypingNote("Normal question", "Answer").firstCard()
+ val reviewer = startReviewer()
+ val field = reviewer.findViewById(R.id.answer_field)
+ val originalInputType = field.inputType
+ val inputMethodManager = Shadow.extract(reviewer.getSystemService())
+ assertEquals(noSuggestCard.id, reviewer.currentCardId)
+ assertTrue(field.onCheckIsTextEditor())
+ assertNotEquals(InputType.TYPE_NULL, field.inputType)
+ val noSuggestInfo = EditorInfo()
+ field.createInputConnection(noSuggestInfo)
+ assertEquals(InputType.TYPE_NULL, noSuggestInfo.inputType)
+
+ reviewer.executeCommand(SHOW_ANSWER)
+ advanceRobolectricLooper()
+ assertTrue(reviewer.isDisplayingAnswer)
+
+ inputMethodManager.restartedViews.clear()
+ reviewer.executeCommand(ANSWER_EASY)
+ advanceRobolectricLooper()
+ assertEquals(normalCard.id, reviewer.currentCardId)
+ assertContains(inputMethodManager.restartedViews, field)
+ assertTrue(field.onCheckIsTextEditor())
+ assertEquals(originalInputType, field.inputType)
+ val normalInfo = EditorInfo()
+ field.createInputConnection(normalInfo)
+ assertEquals(originalInputType, normalInfo.inputType)
+ }
+
@Ignore("flaky")
@Test
fun verifyNormalStartup() {
@@ -79,6 +147,24 @@ class ReviewerTest : RobolectricTest() {
}
}
+ @Test
+ fun `saved bottom nav preference does not show a back icon on split pane layouts`() =
+ withBooleanPreference(R.string.dev_bottom_nav_key, true) {
+ RuntimeEnvironment.setQualifiers("sw600dp")
+ val reviewer = startReviewer(withCards = 1)
+ assertFalse(reviewer.resources.getBoolean(R.bool.bottom_navigation_available))
+ assertTrue(reviewer.drawerToggle.isDrawerIndicatorEnabled)
+ }
+
+ @Test
+ fun `saved bottom nav preference shows a back icon on large single pane layouts`() =
+ withBooleanPreference(R.string.dev_bottom_nav_key, true) {
+ RuntimeEnvironment.setQualifiers("sw480dp-large")
+ val reviewer = startReviewer(withCards = 1)
+ assertTrue(reviewer.resources.getBoolean(R.bool.bottom_navigation_available))
+ assertFalse(reviewer.drawerToggle.isDrawerIndicatorEnabled)
+ }
+
@Test
fun testOnSelectedTags() {
// Add a note using basic model
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt
index 3db9dcb2b740..ff9bf5eed72f 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt
@@ -33,12 +33,10 @@ import com.ichi2.anki.common.time.TimeManager
import com.ichi2.anki.dialogs.DialogHandler
import com.ichi2.anki.libanki.Card
import com.ichi2.anki.libanki.Collection
+import com.ichi2.anki.libanki.CollectionFiles
import com.ichi2.anki.libanki.Note
import com.ichi2.anki.libanki.NotetypeJson
import com.ichi2.anki.libanki.testutils.AnkiTest
-import com.ichi2.anki.libanki.testutils.InMemoryCollectionManager
-import com.ichi2.anki.libanki.testutils.InMemoryCollectionManagerWithMediaFolder
-import com.ichi2.anki.libanki.testutils.TestCollectionManager
import com.ichi2.anki.observability.ChangeManager
import com.ichi2.anki.observability.undoableOp
import com.ichi2.anki.utils.OnlyOnce
@@ -50,11 +48,11 @@ import com.ichi2.testutils.common.FailOnUnhandledExceptionRule
import com.ichi2.testutils.common.IgnoreFlakyTestsInCIRule
import com.ichi2.testutils.filter
import com.ichi2.testutils.grantPermissions
+import com.ichi2.testutils.rules.CollectionStorageRule
+import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.TestDispatcher
-import kotlinx.coroutines.test.TestScope
-import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import net.ankiweb.rsdroid.BackendException
import net.ankiweb.rsdroid.testing.RustBackendLoader
@@ -77,6 +75,7 @@ import org.robolectric.shadows.ShadowLooper
import org.robolectric.shadows.ShadowMediaPlayer
import timber.log.Timber
import kotlin.test.assertNotNull
+import kotlin.test.assertSame
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TimeSource
@@ -110,6 +109,17 @@ open class RobolectricTest :
@get:Rule
val tempFolder = TemporaryFolder()
+ // Run inside tempFolder so in-memory collections can use its media directory.
+ @get:Rule(order = 0)
+ val collectionStorage =
+ CollectionStorageRule {
+ when (getCollectionStorageMode()) {
+ ON_DISK -> null
+ IN_MEMORY_WITH_MEDIA -> CollectionFiles.InMemoryWithMedia(tempFolder.newFolder())
+ IN_MEMORY_NO_FOLDERS -> CollectionFiles.InMemory
+ }
+ }
+
/**
* After all `@After` methods, fail if any Robolectric activities are still live.
* Retained AppCompat delegates can recreate activities on later night-mode changes.
@@ -119,14 +129,7 @@ open class RobolectricTest :
@get:Rule(order = Int.MIN_VALUE)
val noLiveActivities = NoLiveRobolectricActivitiesRule()
- override val collectionManager: TestCollectionManager by lazy {
- when (getCollectionStorageMode()) {
- ON_DISK -> ProductionCollectionManager as TestCollectionManager
- // tempFolder.newFolder() requires `lazy { }`
- IN_MEMORY_WITH_MEDIA -> InMemoryCollectionManagerWithMediaFolder(tempFolder.newFolder())
- IN_MEMORY_NO_FOLDERS -> InMemoryCollectionManager()
- }
- }
+ override val collectionManager = ProductionCollectionManager
protected open fun getCollectionStorageMode(): CollectionStorageMode = IN_MEMORY_NO_FOLDERS
@@ -163,8 +166,7 @@ open class RobolectricTest :
WorkManagerTestInitHelper.initializeTestWorkManager(targetContext, config)
- // resolved issues with the collection being reused if useInMemoryDatabase is false
- CollectionManager.setColForTests(null)
+ CollectionManager.closeCollectionBlocking()
maybeSetupBackend()
@@ -374,7 +376,6 @@ open class RobolectricTest :
/** Call this method in your test if you to test behavior with a null collection */
protected fun enableNullCollection() {
CollectionManager.closeCollectionBlocking()
- CollectionManager.setColForTests(null)
CollectionManager.emulatedOpenFailure = CollectionOpenFailure.LOCKED
}
@@ -497,10 +498,22 @@ open class RobolectricTest :
ioDispatcher = dispatcher
}
- override suspend fun TestScope.runTestInner(testBody: suspend TestScope.() -> Unit) {
- (collectionManager as? ProductionCollectionManager)
- ?.setTestDispatcher(UnconfinedTestDispatcher(testScheduler))
- testBody()
+ override fun withTestDispatcher(
+ dispatcher: TestDispatcher,
+ block: () -> Unit,
+ ) {
+ val previousDispatcher = CollectionManager.setTestDispatcher(dispatcher)
+ val dispatcherAfterTest: CoroutineDispatcher
+ try {
+ block()
+ } finally {
+ dispatcherAfterTest = CollectionManager.setTestDispatcher(previousDispatcher)
+ }
+ assertSame(
+ dispatcher,
+ dispatcherAfterTest,
+ "CollectionManager dispatcher was not restored. Save and restore it in a finally block.",
+ )
}
}
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt
index c4fdde87afc4..4bf33d50b7db 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt
@@ -7,9 +7,11 @@ import android.view.WindowManager
import android.widget.EditText
import androidx.core.content.getSystemService
import androidx.core.view.allViews
+import androidx.core.view.drawToBitmap
import com.github.takahirom.roborazzi.ExperimentalRoborazziApi
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
import com.github.takahirom.roborazzi.RoborazziOptions
+import com.github.takahirom.roborazzi.captureRoboImage
import com.github.takahirom.roborazzi.captureScreenRoboImage
import com.github.takahirom.roborazzi.provideRoborazziContext
import com.google.testing.junit.testparameterinjector.TestParameter
@@ -52,6 +54,22 @@ abstract class ScreenshotTest : RobolectricTest() {
enum class DeviceConfig { PHONE, TABLET, FOLDABLE, DESKTOP }
+ /**
+ * Devices always covered by the annotated class, in addition to the `-Pdevice` selection.
+ *
+ * Usage:
+ *
+ * ```kt
+ * @IncludeDevices(TABLET)
+ * class TestClass : ScreenshotTest() {
+ * ```
+ */
+ @Target(AnnotationTarget.CLASS)
+ @Retention(AnnotationRetention.RUNTIME)
+ annotation class IncludeDevices(
+ vararg val value: DeviceConfig,
+ )
+
@TestParameter(valuesProvider = ThemeProvider::class)
lateinit var theme: ThemeConfig
@@ -125,7 +143,24 @@ abstract class ScreenshotTest : RobolectricTest() {
* Writes to /diffs/ if there is an issue.
*/
@OptIn(ExperimentalRoborazziApi::class)
- protected fun captureScreen(name: String) {
+ protected fun captureScreen(name: String) =
+ capture(name) { filePath, options ->
+ captureScreenRoboImage(filePath = filePath, roborazziOptions = options)
+ }
+
+ /** Captures a laid-out view, including content extending beyond the screen. */
+ protected fun captureView(
+ name: String,
+ view: View,
+ ) = capture(name) { filePath, options ->
+ view.drawToBitmap().captureRoboImage(filePath = filePath, roborazziOptions = options)
+ }
+
+ @OptIn(ExperimentalRoborazziApi::class)
+ private fun capture(
+ name: String,
+ captureImage: (String, RoborazziOptions) -> Unit,
+ ) {
// Note: this.javaClass should not be used inside a lambda, as 'this' will be unnamed
val classDir = "build/outputs/roborazzi/${this.javaClass.simpleName}"
val diffDir = File("$classDir/diffs")
@@ -134,9 +169,9 @@ abstract class ScreenshotTest : RobolectricTest() {
val baseline = File(classDir, fileName)
disableScrollbarFading()
restartTextCursorBlink()
- captureScreenRoboImage(
- filePath = baseline.path,
- roborazziOptions = provideRoborazziContext().options.withCompareOutputDir(diffDir.path),
+ captureImage(
+ baseline.path,
+ provideRoborazziContext().options.withCompareOutputDir(diffDir.path),
)
// copy the baseline into /diffs (if it exists)
@@ -180,7 +215,13 @@ abstract class ScreenshotTest : RobolectricTest() {
if ("all" in requestedDevices) {
return DeviceConfig.entries
}
- return DeviceConfig.entries.filter { requestedDevices.contains(it.name.lowercase()) }
+ val includedDevices =
+ context
+ ?.testClass()
+ ?.getAnnotation(IncludeDevices::class.java)
+ ?.value
+ .orEmpty()
+ return DeviceConfig.entries.filter { it in includedDevices || requestedDevices.contains(it.name.lowercase()) }
}
}
}
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ThrowableFilterServiceTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ThrowableFilterServiceTest.kt
index 227853d3682e..a7cf49697b83 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/ThrowableFilterServiceTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/ThrowableFilterServiceTest.kt
@@ -22,6 +22,8 @@ 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 net.ankiweb.rsdroid.BackendException
+import net.ankiweb.rsdroid.BackendException.BackendSchedulerUpgradeRequiredException
import net.ankiweb.rsdroid.exceptions.BackendDeckIsFilteredException
import net.ankiweb.rsdroid.exceptions.BackendNetworkException
import net.ankiweb.rsdroid.exceptions.BackendSyncException
@@ -33,6 +35,17 @@ import kotlin.test.assertTrue
@RunWith(AndroidJUnit4::class)
class ThrowableFilterServiceTest : JvmTest() {
+ @Test
+ fun `scheduler upgrade required exceptions are discarded`() {
+ val exception = BackendSchedulerUpgradeRequiredException(backendError {})
+ assertTrue(ThrowableFilterService.shouldDiscardThrowable(exception))
+ }
+
+ @Test
+ fun `other backend exceptions are retained`() {
+ assertFalse(ThrowableFilterService.shouldDiscardThrowable(BackendException(backendError {})))
+ }
+
@Test
fun `Normal exceptions are flagged as PII-safe`() {
val exception = BackendDeckIsFilteredException(backendError {})
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/TtsVoicesCacheTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/TtsVoicesCacheTest.kt
new file mode 100644
index 000000000000..70bff1e26881
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/TtsVoicesCacheTest.kt
@@ -0,0 +1,55 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+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
+import io.mockk.coVerify
+import io.mockk.every
+import io.mockk.just
+import io.mockk.mockk
+import io.mockk.mockkObject
+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() {
+ @Test
+ fun `a new reviewer engine gets its own cached languages`() =
+ runTest {
+ TtsVoicesCacheOverride(ENGINE_A, listOf(Locale.US.normalize())).use {
+ val engineB =
+ mockk {
+ every { availableLanguages } returns setOf(Locale.FRANCE)
+ every { shutdown() } just Runs
+ }
+ mockkObject(TtsVoices) {
+ every { TtsVoices.launchBuildLocalesJob() } just Runs
+ coEvery { TtsVoices.createTts(any()) } coAnswers {
+ check(firstArg() == ENGINE_B)
+ engineB
+ }
+
+ assertEquals(listOf(Locale.US.normalize()), TtsVoices.localesForEngine(ENGINE_A))
+ assertEquals(listOf(Locale.FRANCE.normalize()), TtsVoices.localesForEngine(ENGINE_B))
+ assertEquals(listOf(Locale.FRANCE.normalize()), TtsVoices.localesForEngine(ENGINE_B))
+ // The second lookup of the new engine must use its cached languages.
+ coVerify(exactly = 1) { TtsVoices.createTts(ENGINE_B) }
+ }
+ }
+ }
+
+ companion object {
+ private const val ENGINE_A = "engine.a"
+ private const val ENGINE_B = "engine.b"
+ }
+}
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/TtsVoicesMultiEngineTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/TtsVoicesMultiEngineTest.kt
index 76d430047b18..231a7862dbc4 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/TtsVoicesMultiEngineTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/TtsVoicesMultiEngineTest.kt
@@ -6,14 +6,23 @@ package com.ichi2.anki
import android.speech.tts.TextToSpeech
import android.speech.tts.Voice
import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.ichi2.anki.i18n.normalize
import io.mockk.every
import io.mockk.mockk
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.async
import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.equalTo
import org.junit.Test
import org.junit.runner.RunWith
import java.util.Locale
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
/**
* Regression tests for #18737: voices should be listed from every installed TTS engine,
@@ -23,6 +32,109 @@ import java.util.Locale
*/
@RunWith(AndroidJUnit4::class)
class TtsVoicesMultiEngineTest {
+ @Test
+ @Suppress("DEPRECATION") // Engine-provided variants may not be valid BCP 47 tags.
+ fun `default languages are deduplicated after normalization`() =
+ runTest {
+ val aliases = setOf(Locale("en", "GB", "f00"), Locale("en", "GB", "f01"))
+ val defaultLocales = CompletableDeferred()
+ val probe = probeTts()
+ every { probe.availableLanguages } returns aliases
+
+ TtsVoices.loadVoices(defaultLocales) { engine -> if (engine == null) probe else null }
+
+ assertEquals(listOf(Locale.UK.normalize()), defaultLocales.await().locales)
+ }
+
+ @Test
+ fun `default languages are ready while another engine is still initializing`() =
+ runTest {
+ val defaultLocales = CompletableDeferred()
+ val otherEngine = CompletableDeferred()
+ val defaultTts = fakeTts(setOf(fakeVoice("a", Locale.US)), setOf(Locale.US))
+ val allVoices =
+ async {
+ TtsVoices.loadVoices(defaultLocales) { engine ->
+ when (engine) {
+ null -> probeTts()
+ ENGINE_A -> defaultTts
+ ENGINE_B -> otherEngine.await()
+ else -> error("Unexpected engine: $engine")
+ }
+ }
+ }
+ try {
+ runCurrent()
+ assertTrue(defaultLocales.isCompleted)
+ assertEquals(listOf(Locale.US.normalize()), defaultLocales.await().locales)
+ assertFalse(allVoices.isCompleted)
+
+ otherEngine.complete(fakeTts(setOf(fakeVoice("b", Locale.FRANCE)), setOf(Locale.FRANCE)))
+ assertEquals(
+ setOf(ENGINE_A, ENGINE_B),
+ allVoices
+ .await()
+ .map { it.engine }
+ .toSet(),
+ )
+ assertEquals(listOf(Locale.US.normalize()), defaultLocales.await().locales)
+ } finally {
+ allVoices.cancel()
+ }
+ }
+
+ @Test
+ fun `a failed default engine completes both caches with no voices`() =
+ runTest {
+ val defaultLocales = CompletableDeferred()
+ val voices = TtsVoices.loadVoices(defaultLocales) { null }
+
+ assertTrue(defaultLocales.isCompleted)
+ assertEquals(emptyList(), defaultLocales.await().locales)
+ assertEquals(emptySet(), voices)
+ }
+
+ @Test
+ fun `a default language query error does not stop scanning other engines`() =
+ runTest {
+ val defaultLocales = CompletableDeferred()
+ val probe = probeTts()
+ every { probe.availableLanguages } throws IllegalStateException("Language query failed")
+ val voices =
+ TtsVoices.loadVoices(defaultLocales) { engine ->
+ when (engine) {
+ null -> probe
+ ENGINE_B -> fakeTts(setOf(fakeVoice("b", Locale.FRANCE)), setOf(Locale.FRANCE))
+ else -> null
+ }
+ }
+
+ assertTrue(defaultLocales.isCompleted)
+ assertEquals(emptyList(), defaultLocales.await().locales)
+ assertEquals(setOf(ENGINE_B), voices.map { it.engine }.toSet())
+ }
+
+ @Test
+ fun `unexpected discovery failure does not leave default languages pending`() =
+ runTest {
+ val defaultLocales = CompletableDeferred()
+ assertFailsWith {
+ TtsVoices.loadVoices(defaultLocales) { throw IllegalStateException("Discovery failed") }
+ }
+
+ assertTrue(defaultLocales.isCompleted)
+ assertFailsWith { defaultLocales.await() }
+ }
+
+ private fun probeTts(): TextToSpeech =
+ fakeTts(emptySet(), setOf(Locale.US)).apply {
+ every { defaultEngine } returns ENGINE_A
+ every { engines } returns
+ listOf(ENGINE_A, ENGINE_B).map { engine ->
+ TextToSpeech.EngineInfo().apply { name = engine }
+ }
+ }
+
@Test
fun `voices are aggregated across multiple engines`() =
runBlocking {
@@ -37,7 +149,7 @@ class TtsVoicesMultiEngineTest {
engineLanguages = setOf(Locale.forLanguageTag("fr-FR")),
)
- val (voices, _) =
+ val voices =
TtsVoices.loadVoicesFromEngines(listOf(ENGINE_A, ENGINE_B)) { engine ->
when (engine) {
ENGINE_A -> ttsA
@@ -61,7 +173,7 @@ class TtsVoicesMultiEngineTest {
engineLanguages = setOf(Locale.forLanguageTag("de-DE")),
)
- val (voices, _) =
+ val voices =
TtsVoices.loadVoicesFromEngines(listOf(ENGINE_A, ENGINE_B)) { engine ->
// ENGINE_A 'fails' to initialise (returns null) and must not abort the scan
if (engine == ENGINE_B) workingTts else null
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/browser/BrowserMultiColumnAdapterRowsTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/browser/BrowserMultiColumnAdapterRowsTest.kt
new file mode 100644
index 000000000000..0ffb13cf8256
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/browser/BrowserMultiColumnAdapterRowsTest.kt
@@ -0,0 +1,89 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.browser
+
+import android.view.ContextThemeWrapper
+import android.view.View.MeasureSpec
+import android.widget.FrameLayout
+import androidx.recyclerview.widget.LinearLayoutManager
+import androidx.recyclerview.widget.RecyclerView
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.ichi2.anki.R
+import com.ichi2.anki.RobolectricTest
+import org.hamcrest.MatcherAssert.assertThat
+import org.hamcrest.Matchers.equalTo
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@RunWith(AndroidJUnit4::class)
+class BrowserMultiColumnAdapterRowsTest : RobolectricTest() {
+ private val browserContext by lazy { ContextThemeWrapper(targetContext, R.style.Theme_Light) }
+
+ @Test
+ fun `scroll during search refresh does not crash - Issue 17759`() =
+ runCardBrowserViewModelTest(notes = 20) {
+ val adapter = createAdapterWithSearchResults()
+ val recyclerView = adapter.createLaidOutRecyclerView()
+
+ cards.reset()
+ recyclerView.scrollBy(0, 200)
+ }
+
+ @Test
+ fun `clearing search results does not change adapter rows before notification - Issue 17759`() =
+ runCardBrowserViewModelTest(notes = 2) {
+ val adapter = createAdapterWithSearchResults()
+ val originalRows = cards.toList()
+ assertThat(originalRows.size, equalTo(2))
+
+ // A search clears the ViewModel before the fragment collects its update flow.
+ // RecyclerView can lay out the previous results during that interval.
+ cards.reset()
+
+ assertThat(adapter.itemCount, equalTo(originalRows.size))
+ val holder = adapter.onCreateViewHolder(FrameLayout(browserContext), 0)
+ adapter.onBindViewHolder(holder, 1)
+ assertThat(holder.id, equalTo(originalRows[1]))
+
+ val notifiedCounts = adapter.recordNotifiedItemCounts()
+ adapter.refreshRows()
+ assertThat(adapter.itemCount, equalTo(0))
+ assertThat(notifiedCounts, equalTo(listOf(0)))
+
+ cards.replaceWith(cardsOrNotes, originalRows.reversed())
+ assertThat(adapter.itemCount, equalTo(0))
+ adapter.refreshRows()
+ assertThat(notifiedCounts, equalTo(listOf(0, 2)))
+ adapter.onBindViewHolder(holder, 0)
+ assertThat(holder.id, equalTo(originalRows[1]))
+ }
+
+ private suspend fun CardBrowserViewModel.createAdapterWithSearchResults(): BrowserMultiColumnAdapter {
+ launchSearchForCards()
+ searchJob?.join()
+ return BrowserMultiColumnAdapter(browserContext, this, onLongPress = {}, onTap = {})
+ }
+
+ private fun BrowserMultiColumnAdapter.createLaidOutRecyclerView(): RecyclerView =
+ RecyclerView(browserContext).apply {
+ layoutManager = LinearLayoutManager(browserContext)
+ adapter = this@createLaidOutRecyclerView
+ measure(
+ MeasureSpec.makeMeasureSpec(1080, MeasureSpec.EXACTLY),
+ MeasureSpec.makeMeasureSpec(200, MeasureSpec.EXACTLY),
+ )
+ layout(0, 0, 1080, 200)
+ }
+
+ private fun BrowserMultiColumnAdapter.recordNotifiedItemCounts(): List {
+ val counts = mutableListOf()
+ registerAdapterDataObserver(
+ object : RecyclerView.AdapterDataObserver() {
+ override fun onChanged() {
+ counts.add(itemCount)
+ }
+ },
+ )
+ return counts
+ }
+}
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/browser/BrowserOptionsRepositoryTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/browser/BrowserOptionsRepositoryTest.kt
index 598602bca6c5..c0dd72dd22b8 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/browser/BrowserOptionsRepositoryTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/browser/BrowserOptionsRepositoryTest.kt
@@ -6,6 +6,7 @@ import android.os.Build
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.ichi2.anki.AnkiDroidApp
import com.ichi2.anki.CollectionManager
+import com.ichi2.anki.CollectionManager.withCol
import com.ichi2.anki.model.CardsOrNotes
import com.ichi2.anki.utils.ext.ignoreAccentsInSearch
import com.ichi2.testutils.JvmTest
@@ -35,7 +36,7 @@ class BrowserOptionsRepositoryTest : JvmTest() {
repository.setIgnoreAccentsInSearch(false)
first.join()
- assertFalse(col.config.ignoreAccentsInSearch, "The latest saved value should be persisted")
+ assertFalse(withCol { config.ignoreAccentsInSearch }, "The latest saved value should be persisted")
assertFalse(repository.ignoreAccentsInSearch.value, "The flow should match the latest saved value")
}
}
@@ -54,7 +55,11 @@ class BrowserOptionsRepositoryTest : JvmTest() {
repository.setCardsOrNotes(CardsOrNotes.CARDS)
first.join()
- assertEquals(CardsOrNotes.CARDS, CardsOrNotes.fromCollection(col), "The latest saved value should be persisted")
+ assertEquals(
+ CardsOrNotes.CARDS,
+ withCol { CardsOrNotes.fromCollection(this) },
+ "The latest saved value should be persisted",
+ )
assertEquals(CardsOrNotes.CARDS, repository.cardsOrNotes.value, "The flow should match the latest saved value")
}
}
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserFragmentTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserFragmentTest.kt
index b65ac67fb2ba..f8e96b049350 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserFragmentTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserFragmentTest.kt
@@ -9,11 +9,16 @@ import androidx.lifecycle.Lifecycle
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import app.cash.turbine.test
+import com.google.android.material.chip.Chip
import com.ichi2.anki.CardBrowser
+import com.ichi2.anki.R
import com.ichi2.anki.RobolectricTest
+import com.ichi2.anki.getVisibleRows
+import com.ichi2.anki.model.SortType
import com.ichi2.anki.settings.Prefs
import kotlinx.coroutines.runBlocking
import org.hamcrest.CoreMatchers.equalTo
+import org.hamcrest.CoreMatchers.sameInstance
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.empty
import org.junit.Test
@@ -22,6 +27,34 @@ import org.junit.runner.RunWith
/** Test of [CardBrowserFragment] */
@RunWith(AndroidJUnit4::class)
class CardBrowserFragmentTest : RobolectricTest() {
+ @Test
+ fun `search results completed while stopped are displayed on restart - Issue 17759`() =
+ runTest {
+ val removedNote = addBasicNote("dog", "barks")
+ val matchingRow = CardOrNoteId(addBasicNote("cat", "meows").firstCard().id)
+
+ withStartedBrowser { browser ->
+ val adapter = browser.cardBrowserFragment.cardsAdapter
+ assertThat("initial search results", adapter.itemCount, equalTo(2))
+
+ // The browser stops collecting search updates while the note editor is open.
+ stopBrowser()
+ col.removeNotes(noteIds = listOf(removedNote.id))
+ browser.refreshSearchAndWait()
+
+ assertThat(browser.viewModel.cards.toList(), equalTo(listOf(matchingRow)))
+ assertThat("the stopped adapter has not received the new results", adapter.itemCount, equalTo(2))
+
+ val completedSearch = browser.viewModel.searchJob
+ resumeBrowser { resumedBrowser ->
+ assertThat(resumedBrowser.cardBrowserFragment.cardsAdapter, sameInstance(adapter))
+ assertThat("resuming does not run another search", resumedBrowser.viewModel.searchJob, sameInstance(completedSearch))
+ assertThat("the adapter displays the completed search", adapter.itemCount, equalTo(1))
+ assertThat(resumedBrowser.getVisibleRows().map { it.id }, equalTo(listOf(matchingRow)))
+ }
+ }
+ }
+
@Test
fun `searchView EditText submit via IME_ACTION_SEARCH`() =
withCardBrowserFragment(useSearchView = true) {
@@ -48,8 +81,63 @@ class CardBrowserFragmentTest : RobolectricTest() {
val offenders = shortcuts.shortcuts.map { it.label }.filterNot { it.isSentenceCase }
assertThat("shortcut labels should be Sentence case, not Title Case", offenders, empty())
}
+
+ @Test
+ fun `sort chip describes the saved sort order`() {
+ col.config.set("sortType", "noteCrt")
+ col.config.set("sortBackwards", true)
+
+ withCardBrowserFragment(useSearchView = true) {
+ advanceRobolectricLooper()
+ assertThat(sortChipDescription, equalTo("Sort by Created · Newest first"))
+ }
+ }
+
+ @Test
+ fun `sort chip description follows sort changes`() =
+ withCardBrowserFragment(useSearchView = true) {
+ activityViewModel.setSortType(SortType.CollectionOrdering(BrowserColumnKey("cardEase"), reverse = false)).join()
+ advanceRobolectricLooper()
+ assertThat(sortChipDescription, equalTo("Sort by Ease · Low to high"))
+
+ activityViewModel.setSortType(SortType.NoOrdering).join()
+ advanceRobolectricLooper()
+ assertThat(sortChipDescription, equalTo("No sorting"))
+ }
+
+ private suspend fun withStartedBrowser(block: suspend ActivityScenario.(CardBrowser) -> Unit) {
+ ActivityScenario.launch(Intent(targetContext, CardBrowser::class.java)).use { scenario ->
+ lateinit var browser: CardBrowser
+ scenario.onActivity { browser = it }
+ browser.viewModel.searchJob?.join()
+ advanceRobolectricLooper()
+ scenario.block(browser)
+ }
+ }
+
+ private fun ActivityScenario.stopBrowser() {
+ moveToState(Lifecycle.State.CREATED)
+ onActivity { browser ->
+ assertThat(browser.cardBrowserFragment.lifecycle.currentState, equalTo(Lifecycle.State.CREATED))
+ }
+ }
+
+ private suspend fun CardBrowser.refreshSearchAndWait() {
+ viewModel.launchSearchForCards()
+ requireNotNull(viewModel.searchJob).join()
+ advanceRobolectricLooper()
+ }
+
+ private fun ActivityScenario.resumeBrowser(block: (CardBrowser) -> Unit) {
+ moveToState(Lifecycle.State.RESUMED)
+ advanceRobolectricLooper()
+ onActivity(block)
+ }
}
+private val CardBrowserFragment.sortChipDescription: String?
+ get() = requireView().findViewById(R.id.sort_chip).contentDescription?.toString()
+
private val capitalizedFollowingWord = Regex("""\s\p{Lu}\p{Ll}""")
// Material Design sentence case: the first word is capitalized; later words are not
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserViewModelTest.kt
index e82396faa9af..8398767898ef 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserViewModelTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserViewModelTest.kt
@@ -104,15 +104,18 @@ import org.hamcrest.Matchers.instanceOf
import org.hamcrest.Matchers.lessThan
import org.hamcrest.Matchers.not
import org.hamcrest.Matchers.nullValue
+import org.hamcrest.Matchers.sameInstance
import org.junit.Test
import org.junit.jupiter.api.assertDoesNotThrow
import org.junit.jupiter.api.assertInstanceOf
import org.junit.runner.RunWith
import timber.log.Timber
import java.io.File
+import java.io.IOException
import kotlin.io.path.createTempDirectory
import kotlin.io.path.pathString
import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
import kotlin.test.assertIs
import kotlin.test.assertNotNull
import kotlin.test.assertNull
@@ -1403,7 +1406,27 @@ class CardBrowserViewModelTest : JvmTest() {
col.reopenWithLanguage("de")
onReinit()
+ assertThat(col, sameInstance(CollectionManager.withCol { this }))
assertThat("German", firstHeading(), equalTo("Sortierfeld"))
+ assertThat(
+ "Changing the backend language keeps the collection in memory",
+ CollectionManager.withCol { db.queryString("select file from pragma_database_list where name = 'main'") },
+ equalTo(""),
+ )
+ }
+
+ @Suppress("SpellCheckingInspection")
+ @Test
+ fun `current sort - language change`() =
+ runViewModelTest {
+ fun sortField(label: String) = SortChangeNotification.CollectionOrdering(label, ColumnType.TEXT, reverse = false)
+
+ assertEquals(sortField("Sort Field"), flowOfCurrentSort.value, "English")
+
+ col.reopenWithLanguage("de")
+ onReinit()
+
+ assertEquals(sortField("Sortierfeld"), flowOfCurrentSort.value, "German")
}
@Test
@@ -1798,6 +1821,23 @@ class CardBrowserViewModelTest : JvmTest() {
}
}
+ @Test
+ fun `corrupt multiselect state file results in an empty selection`() {
+ val handle = SavedStateHandle()
+ runViewModelTest(savedStateHandle = handle, notes = 2, initMode = InitMode.NO_DELAY) {
+ selectRowAtPosition(1)
+ handle[STATE_MULTISELECT_VALUES] = generateExpensiveSavedState()
+ }
+
+ // A negative count must be treated as corrupt data, not a programming error.
+ handle.multiselectStateFile!!.writeBytes(ByteArray(4) { -1 })
+
+ runViewModelTest(savedStateHandle = handle, initMode = InitMode.NO_DELAY) {
+ assertThat("no rows are selected", selectedRows, empty())
+ assertThat("multiselect mode is preserved", isInMultiSelectMode, equalTo(true))
+ }
+ }
+
@Test
fun `multiselect state is kept if saved before the selection is restored`() {
val handle = SavedStateHandle()
@@ -1839,6 +1879,70 @@ class CardBrowserViewModelTest : JvmTest() {
}
}
+ @Test
+ fun `superseded multiselect state file is deleted`() =
+ runViewModelTest(notes = 2, initMode = InitMode.NO_DELAY) {
+ selectRowAtPosition(1)
+ val firstFile = generateExpensiveSavedState().multiselectStateFile!!
+ assertThat("first state file exists", firstFile.exists(), equalTo(true))
+
+ val secondFile = generateExpensiveSavedState().multiselectStateFile!!
+ assertThat("second state file exists", secondFile.exists(), equalTo(true))
+ assertThat("first state file was deleted", firstFile.exists(), equalTo(false))
+ }
+
+ @Test
+ fun `restored multiselect state file is deleted when superseded`() {
+ val handle = SavedStateHandle()
+ runViewModelTest(savedStateHandle = handle, notes = 2, initMode = InitMode.NO_DELAY) {
+ selectRowAtPosition(1)
+ // Simulate the saved-state provider saving its bundle.
+ handle[STATE_MULTISELECT_VALUES] = generateExpensiveSavedState()
+ }
+
+ val restoredFile = handle.multiselectStateFile!!
+ runViewModelTest(savedStateHandle = handle, initMode = InitMode.NO_DELAY) {
+ assertThat("row is selected after restore", selectedRows, hasSize(1))
+
+ val newFile = generateExpensiveSavedState().multiselectStateFile!!
+ assertThat("new state file exists", newFile.exists(), equalTo(true))
+ assertThat("restored state file was deleted", restoredFile.exists(), equalTo(false))
+ }
+ }
+
+ @Test
+ fun `failed replacement preserves restored multiselect state file`() {
+ val handle = SavedStateHandle()
+ runViewModelTest(savedStateHandle = handle, notes = 2, initMode = InitMode.NO_DELAY) {
+ selectRowAtPosition(1)
+ handle[STATE_MULTISELECT_VALUES] = generateExpensiveSavedState()
+ }
+ val restoredFile = assertNotNull(handle.multiselectStateFile)
+ val savedIds = restoredFile.getIds()
+
+ // A missing cache directory makes the replacement write fail without affecting the old file.
+ runViewModelTest(
+ savedStateHandle = handle,
+ initMode = InitMode.NO_DELAY,
+ cacheDir = File(createTransientDirectory(), "missing"),
+ ) {
+ assertThat("selection is restored", selectedRows, hasSize(1))
+ assertFailsWith { generateExpensiveSavedState() }
+ assertThat("previous snapshot remains readable", restoredFile.getIds(), equalTo(savedIds))
+ }
+ }
+
+ @Test
+ fun `multiselect state file is deleted when an empty selection is saved`() =
+ runViewModelTest(notes = 2, initMode = InitMode.NO_DELAY) {
+ selectRowAtPosition(1)
+ val file = generateExpensiveSavedState().multiselectStateFile!!
+ endMultiSelectMode(SingleSelectCause.NavigateBack)
+
+ assertNull(generateExpensiveSavedState().multiselectStateFile)
+ assertThat("previous state file was deleted", file.exists(), equalTo(false))
+ }
+
@Test
fun `change note type - no selection`() =
runViewModelTest {
@@ -2152,6 +2256,22 @@ class CardBrowserViewModelTest : JvmTest() {
}
}
+ @Test
+ fun `sort state follows the cards or notes mode`() {
+ col.config.set("noteSortType", "noteCrt")
+ col.config.set("browserNoteSortBackwards", true)
+
+ runViewModelTest(initMode = InitMode.NO_DELAY) {
+ setCardsOrNotes(CardsOrNotes.NOTES).join()
+ assertEquals(true, flowOfReverseDirection.value)
+ assertEquals(SortChangeNotification.CollectionOrdering("Created", ColumnType.DATE, reverse = true), flowOfCurrentSort.value)
+
+ setCardsOrNotes(CardsOrNotes.CARDS).join()
+ assertEquals(false, flowOfReverseDirection.value)
+ assertEquals(SortChangeNotification.CollectionOrdering("Sort Field", ColumnType.TEXT, reverse = false), flowOfCurrentSort.value)
+ }
+ }
+
private fun assertDate(str: String?) {
// 2025-01-09 @ 18:06
assertNotNull(str)
@@ -2200,6 +2320,7 @@ class CardBrowserViewModelTest : JvmTest() {
savedStateHandle: SavedStateHandle = SavedStateHandle(),
options: CardBrowserLaunchOptions? = null,
isFragmented: Boolean = false,
+ cacheDir: File = createTransientDirectory(),
testBody: suspend CardBrowserViewModel.() -> Unit,
) = runTest {
repeat(notes) {
@@ -2209,7 +2330,7 @@ class CardBrowserViewModelTest : JvmTest() {
val viewModel =
CardBrowserViewModel(
lastDeckIdRepository = SharedPreferencesLastDeckIdRepository(),
- cacheDir = createTransientDirectory(),
+ cacheDir = cacheDir,
options = options,
preferences = AnkiDroidApp.sharedPreferencesProvider,
isFragmented = isFragmented,
@@ -2451,11 +2572,11 @@ fun CardOrNoteId.toRowSelection() = RowSelection(rowId = this, topOffset = 0)
private val SavedStateHandle.multiselectMode
get() = get("multiselect")
+private val Bundle.multiselectStateFile: IdsFile?
+ get() = BundleCompat.getParcelable(this, CardBrowserViewModel.STATE_MULTISELECT_VALUES, IdsFile::class.java)
+
private val SavedStateHandle.multiselectStateFile: IdsFile?
- get() =
- get(CardBrowserViewModel.STATE_MULTISELECT_VALUES)?.let { bundle ->
- BundleCompat.getParcelable(bundle, CardBrowserViewModel.STATE_MULTISELECT_VALUES, IdsFile::class.java)
- }
+ get() = get(CardBrowserViewModel.STATE_MULTISELECT_VALUES)?.multiselectStateFile
/**
* Helper function to move a card to the review queue with review history.
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/browser/FindAndReplaceDialogFragmentTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/browser/FindAndReplaceDialogFragmentTest.kt
index b88b2db94d1f..5160a529fe26 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/browser/FindAndReplaceDialogFragmentTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/browser/FindAndReplaceDialogFragmentTest.kt
@@ -1,5 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
-// SPDX-FileCopyrightText: Copyright (c) 2025 lukstbit <52494258+lukstbit@users.noreply.github.com>
package com.ichi2.anki.browser
@@ -7,6 +6,8 @@ import android.content.Context
import android.os.Bundle
import android.widget.Spinner
import android.widget.SpinnerAdapter
+import androidx.coordinatorlayout.widget.CoordinatorLayout
+import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.testing.FragmentScenario
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions.matches
@@ -28,9 +29,37 @@ import org.hamcrest.CoreMatchers.equalTo
import org.hamcrest.MatcherAssert.assertThat
import org.junit.Test
import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
@RunWith(AndroidJUnit4::class)
class FindAndReplaceDialogFragmentTest : RobolectricTest() {
+ @Test
+ fun `missing selection dismisses find and replace`() = assertUnavailableSelection { assertTrue(delete()) }
+
+ @Test
+ fun `truncated selection dismisses find and replace`() = assertUnavailableSelection { writeBytes(byteArrayOf(0)) }
+
+ private fun assertUnavailableSelection(changeFile: IdsFile.() -> Unit) =
+ runTest {
+ val file = IdsFile(targetContext.cacheDir, listOf(addBasicNote().id)).apply(changeFile)
+ val fragment =
+ FindAndReplaceDialogFragment().apply {
+ arguments = Bundle().apply { putParcelable(FindAndReplaceDialogFragment.ARG_IDS, file) }
+ }
+ Robolectric.buildActivity(FragmentActivity::class.java).use { controller ->
+ controller.get().setTheme(R.style.Theme_Light)
+ val activity = controller.setup().get()
+ activity.setContentView(CoordinatorLayout(activity).apply { id = R.id.root_layout })
+ fragment.show(activity.supportFragmentManager, "find-replace")
+ advanceRobolectricLooper()
+ advanceUntilIdle()
+ advanceRobolectricLooper()
+ assertNull(activity.supportFragmentManager.findFragmentByTag("find-replace"))
+ }
+ }
+
@Test
fun `with no selected notes 'only selected notes' check box is not actionable`() =
runTest {
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/browser/IdsFileTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/browser/IdsFileTest.kt
new file mode 100644
index 000000000000..c9da2f15470e
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/browser/IdsFileTest.kt
@@ -0,0 +1,77 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.browser
+
+import android.os.Parcel
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import org.junit.runner.RunWith
+import java.io.DataOutputStream
+import java.io.IOException
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertTrue
+
+@RunWith(AndroidJUnit4::class)
+class IdsFileTest {
+ @get:Rule
+ val temporaryFolder = TemporaryFolder()
+
+ @Test
+ fun `parcelled snapshot can be restored repeatedly`() {
+ val ids = listOf(9L, 1L, Long.MAX_VALUE)
+ val file = IdsFile(temporaryFolder.root, ids)
+ val parcel = Parcel.obtain()
+ try {
+ file.writeToParcel(parcel, 0)
+ parcel.setDataPosition(0)
+ val restored = IdsFile.CREATOR.createFromParcel(parcel)
+ assertEquals(ids, restored.getIds())
+ assertEquals(ids, restored.getIds())
+ assertTrue(file.exists())
+ } finally {
+ parcel.recycle()
+ }
+ }
+
+ @Test
+ fun `empty selection is distinct from a missing file`() {
+ val file = IdsFile(temporaryFolder.root, emptyList())
+ assertEquals(emptyList(), file.getIds())
+ assertTrue(file.delete())
+ assertFailsWith { file.getIds() }
+ }
+
+ @Test
+ fun `incomplete header cannot be read`() {
+ val file = IdsFile(temporaryFolder.root, emptyList())
+ file.writeBytes(byteArrayOf(0, 0))
+ assertFailsWith { file.getIds() }
+ }
+
+ @Test
+ fun `incomplete selections are rejected before allocating the declared count`() {
+ val file = IdsFile(temporaryFolder.root, emptyList())
+ for (count in listOf(2, Int.MAX_VALUE)) {
+ DataOutputStream(file.outputStream()).use {
+ it.writeInt(count)
+ it.writeLong(1L)
+ }
+ assertFailsWith("count: $count") { file.getIds() }
+ }
+ }
+
+ @Test
+ fun `negative counts and trailing data cannot be read`() {
+ val file = IdsFile(temporaryFolder.root, emptyList())
+ for (count in listOf(-1, 0)) {
+ DataOutputStream(file.outputStream()).use {
+ it.writeInt(count)
+ it.writeLong(1L)
+ }
+ assertFailsWith("count: $count") { file.getIds() }
+ }
+ }
+}
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/browser/search/SortOrderBottomSheetFragmentScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/browser/search/SortOrderBottomSheetFragmentScreenshotTest.kt
new file mode 100644
index 000000000000..16e0726932ce
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/browser/search/SortOrderBottomSheetFragmentScreenshotTest.kt
@@ -0,0 +1,53 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.browser.search
+
+import android.os.Bundle
+import com.google.testing.junit.testparameterinjector.TestParameter
+import com.ichi2.anki.ScreenshotTest
+import com.ichi2.anki.ScreenshotTest.DeviceConfig.TABLET
+import com.ichi2.anki.ScreenshotTest.IncludeDevices
+import com.ichi2.anki.browser.BrowserColumnKey
+import com.ichi2.anki.browser.createCardBrowserViewModel
+import com.ichi2.anki.browser.search.SortOrderBottomSheetFragment.Companion.ARG_CURRENT_SORT_TYPE
+import com.ichi2.anki.model.SortType
+import com.ichi2.anki.testutils.SingleViewModelFactory
+import com.ichi2.testutils.launchFragment
+import com.ichi2.testutils.layoutForFullHeightScreenshot
+import com.ichi2.testutils.scrollToEnd
+import org.junit.Test
+import org.robolectric.RuntimeEnvironment
+
+/**
+ * Screenshots of the [display order dialog][SortOrderBottomSheetFragment]
+ */
+@IncludeDevices(TABLET)
+class SortOrderBottomSheetFragmentScreenshotTest : ScreenshotTest() {
+ @Test
+ fun `display order`(
+ @TestParameter("port", "land") orientation: String,
+ ) {
+ RuntimeEnvironment.setQualifiers("+$orientation")
+ val viewModelFactory = SingleViewModelFactory.create(createCardBrowserViewModel())
+ val args =
+ Bundle().apply {
+ putParcelable(ARG_CURRENT_SORT_TYPE, SortType.CollectionOrdering(BrowserColumnKey("noteFld"), reverse = false))
+ }
+ launchFragment(args) { SortOrderBottomSheetFragment(viewModelFactory) }.use { scenario ->
+ scenario.onFragment { fragment ->
+ advanceRobolectricLooper()
+ val name = if (orientation == "land") "landscape" else "portrait"
+ captureScreen(name)
+
+ if (device == DeviceConfig.PHONE) {
+ fragment.binding.list.scrollToEnd()
+ captureScreen("${name}_scrolled_to_bottom")
+
+ fragment.binding.list.scrollToPosition(0)
+ advanceRobolectricLooper()
+ captureView("${name}_scrollshot", fragment.layoutForFullHeightScreenshot(fragment.binding.list))
+ }
+ }
+ }
+ }
+}
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/MediaErrorHandlerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/MediaErrorHandlerTest.kt
index 0fc697938c96..0875ed7780f8 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/MediaErrorHandlerTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/MediaErrorHandlerTest.kt
@@ -115,6 +115,46 @@ class MediaErrorHandlerTest {
assertThat(timesCalled, equalTo(0))
}
+ @Test
+ fun jsApiFailuresAreNotReportedAsMissingMedia() {
+ for (method in listOf("POST", "GET", "OPTIONS")) {
+ for (endpoint in listOf("cardDue", "cardMod", "ttsSetLanguage", "showAnswer?test=1")) {
+ processFailure(getWebResourceRequest("http://127.0.0.1:40001/jsapi/$endpoint", method))
+ assertThat("$method $endpoint", timesCalled, equalTo(0))
+ }
+ }
+ }
+
+ @Test
+ fun jsApiFailureDoesNotHideMissingMediaOnSameSide() {
+ processFailure(getWebResourceRequest("http://127.0.0.1:40001/jsapi/cardDue", "POST"))
+ processFailure(getValidRequest("example.jpg"))
+ assertThat(fileNames, contains("example.jpg"))
+ }
+
+ @Test
+ fun jsApiFailuresDoNotConsumeMissingMediaLimit() {
+ processFailure(getWebResourceRequest("http://127.0.0.1:40001/jsapi/cardDue", "POST"))
+ sut.onCardSideChange()
+ processFailure(getWebResourceRequest("http://127.0.0.1:40001/jsapi/cardMod", "POST"))
+ sut.onCardSideChange()
+ processFailure(getValidRequest("example.jpg"))
+ sut.onCardSideChange()
+ processFailure(getValidRequest("example2.jpg"))
+ sut.onCardSideChange()
+ processFailure(getValidRequest("example3.jpg"))
+ assertThat(fileNames, contains("example.jpg", "example2.jpg"))
+ }
+
+ @Test
+ fun mediaWithApiLikeNamesIsStillReported() {
+ processFailure(getWebResourceRequest("http://127.0.0.1:40001/jsapi/cardDue", "POST"))
+ processFailure(getValidRequest("cardDue.bin"))
+ sut.onCardSideChange()
+ processFailure(getValidRequest("jsapi.jpg"))
+ assertThat(fileNames, contains("cardDue.bin", "jsapi.jpg"))
+ }
+
private fun processFailure(
invalidRequest: WebResourceRequest,
consumer: (String) -> Unit = defaultHandler(),
@@ -182,7 +222,10 @@ class MediaErrorHandlerTest {
return getWebResourceRequest(url)
}
- private fun getWebResourceRequest(url: String): WebResourceRequest =
+ private fun getWebResourceRequest(
+ url: String,
+ method: String = "GET",
+ ): WebResourceRequest =
object : WebResourceRequest {
override fun getUrl(): Uri = url.toUri()
@@ -192,7 +235,7 @@ class MediaErrorHandlerTest {
override fun hasGesture(): Boolean = false
- override fun getMethod(): String? = null
+ override fun getMethod(): String = method
override fun getRequestHeaders(): Map? = null
}
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/BackupSelectionCancellationTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/BackupSelectionCancellationTest.kt
new file mode 100644
index 000000000000..edf81c679a0b
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/BackupSelectionCancellationTest.kt
@@ -0,0 +1,61 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.dialogs
+
+import android.content.DialogInterface
+import androidx.appcompat.app.AlertDialog
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.ichi2.anki.BackupManager
+import com.ichi2.anki.CollectionManager
+import com.ichi2.anki.CommonString
+import com.ichi2.anki.DeckPicker
+import com.ichi2.anki.RobolectricTest
+import com.ichi2.anki.common.storage.CollectionHelper
+import com.ichi2.anki.dialogs.DatabaseErrorDialog.DatabaseErrorDialogType
+import com.ichi2.anki.dialogs.utils.performPositiveClick
+import com.ichi2.anki.dialogs.utils.title
+import com.ichi2.anki.utils.ext.DIALOG_FRAGMENT_TAG
+import com.ichi2.anki.withDeckPicker
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.io.File
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+@RunWith(AndroidJUnit4::class)
+class BackupSelectionCancellationTest : RobolectricTest() {
+ @Test
+ fun `Cancel returns to the deck list without reopening the warning`() =
+ withDeckPicker(deckCount = 0) { activity ->
+ createBackup()
+ activity.showDatabaseErrorDialog(DatabaseErrorDialogType.DIALOG_CONFIRM_RESTORE_BACKUP)
+ activity.currentDialog().performPositiveClick()
+
+ activity.currentDialog().getButton(DialogInterface.BUTTON_NEGATIVE).performClick()
+ advanceRobolectricLooper()
+
+ assertNull(activity.supportFragmentManager.findFragmentByTag(DIALOG_FRAGMENT_TAG))
+ }
+
+ @Test
+ fun `Back returns to recovery options`() =
+ withDeckPicker(deckCount = 0) { activity ->
+ createBackup()
+ CollectionManager.closeCollectionBlocking()
+ activity.showDatabaseErrorDialog(DatabaseErrorDialogType.DIALOG_ERROR_HANDLING)
+ activity.showDatabaseErrorDialog(DatabaseErrorDialogType.DIALOG_RESTORE_BACKUP)
+
+ activity.currentDialog().onBackPressedDispatcher.onBackPressed()
+ advanceRobolectricLooper()
+
+ assertEquals(getResourceString(CommonString.error_handling_title), activity.currentDialog().title)
+ }
+
+ private fun createBackup() {
+ val directory = BackupManager.getBackupDirectory(CollectionHelper.getCurrentAnkiDroidDirectory(targetContext))
+ File(directory, "backup-2026-09-29-12.00.00.colpkg").writeText("backup")
+ }
+
+ private fun DeckPicker.currentDialog(): AlertDialog =
+ (supportFragmentManager.findFragmentByTag(DIALOG_FRAGMENT_TAG) as DatabaseErrorDialog).requireDialog() as AlertDialog
+}
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/EmptyCardsReportTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/EmptyCardsReportTest.kt
index 4993c4547daf..5ee469c88e34 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/EmptyCardsReportTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/EmptyCardsReportTest.kt
@@ -1,18 +1,5 @@
-/*
- * Copyright (c) 2025 lukstbit <52494258+lukstbit@users.noreply.github.com>
- *
- * This program is free software; you can redistribute it and/or modify it under
- * the terms of the GNU General Public License as published by the Free Software
- * Foundation; either version 3 of the License, or (at your option) any later
- * version.
- *
- * This program is distributed in the hope that it will be useful, but WITHOUT ANY
- * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
- * PARTICULAR PURPOSE. See the GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License along with
- * this program. If not, see .
- */
+// SPDX-License-Identifier: GPL-3.0-or-later
+
package com.ichi2.anki.dialogs
import androidx.test.ext.junit.runners.AndroidJUnit4
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/tags/TagsDialogTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/tags/TagsDialogTest.kt
index 6cd848458ed9..ce195028f923 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/tags/TagsDialogTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/tags/TagsDialogTest.kt
@@ -21,31 +21,140 @@ import android.view.View
import android.view.WindowManager
import android.widget.EditText
import androidx.appcompat.app.AlertDialog
+import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.testing.FragmentScenario
import androidx.lifecycle.Lifecycle
-import androidx.recyclerview.widget.RecyclerView
import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.ichi2.anki.CommonString
import com.ichi2.anki.R
import com.ichi2.anki.RobolectricTest
+import com.ichi2.anki.browser.IdsFile
import com.ichi2.anki.libanki.testutils.ext.newNote
+import com.ichi2.anki.model.CardStateFilter
+import com.ichi2.anki.utils.ext.requireParcelable
import com.ichi2.testutils.ParametersUtils
import com.ichi2.testutils.RecyclerViewUtils
+import com.ichi2.testutils.parcelledCopy
+import com.ichi2.testutils.saveState
import com.ichi2.ui.CheckBoxTriStates
+import com.ichi2.ui.CheckBoxTriStates.State.CHECKED
+import com.ichi2.ui.CheckBoxTriStates.State.INDETERMINATE
+import com.ichi2.ui.CheckBoxTriStates.State.UNCHECKED
import com.ichi2.utils.ListUtil
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.equalTo
import org.hamcrest.Matchers.greaterThanOrEqualTo
import org.hamcrest.Matchers.lessThan
-import org.hamcrest.core.IsNull
import org.junit.Assert
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mockito
+import org.robolectric.Robolectric
import org.robolectric.annotation.Config
+import org.robolectric.shadows.ShadowToast
import timber.log.Timber
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
@RunWith(AndroidJUnit4::class)
class TagsDialogTest : RobolectricTest() {
+ @Test
+ fun `confirming removal of a parent does not preserve its display state`() {
+ confirmTags(listOf("B B::child"), parentClicks = 1, selected = listOf("B::child"), preserved = emptyList())
+ }
+
+ @Test
+ fun `confirming unchanged partial selection preserves the original parent tag`() {
+ confirmTags(listOf("B B::child", "B::child"), parentClicks = 0, selected = listOf("B::child"), preserved = listOf("B"))
+ }
+
+ @Test
+ fun `confirming an overridden partial parent does not restore its original selection`() {
+ confirmTags(listOf("B B::child", "B::child"), parentClicks = 2, selected = listOf("B::child"), preserved = emptyList())
+ }
+
+ @Test
+ fun `confirming a partial child does not export a synthetic parent`() {
+ confirmTags(listOf("B::child", ""), parentClicks = 0, selected = emptyList(), preserved = listOf("B::child"))
+ }
+
+ /** Opens the dialog for the supplied notes, clicks the parent tag, and verifies the confirmed selection. */
+ private fun confirmTags(
+ noteTags: List,
+ parentClicks: Int,
+ selected: List,
+ preserved: List,
+ ) {
+ val ids =
+ noteTags.mapIndexed { index, tags ->
+ addBasicNote("note $index").id.also { col.tags.bulkAdd(listOf(it), tags) }
+ }
+ val listener = Mockito.mock(TagsDialogListener::class.java)
+ val args = TagsDialog().withArguments(targetContext, TagsDialog.DialogType.EDIT_TAGS, ids).requireArguments()
+ FragmentScenario.launch(TagsDialog::class.java, args, R.style.Theme_Light, TagsDialogFactory(listener)).use { scenario ->
+ scenario.moveToState(Lifecycle.State.STARTED)
+ scenario.onFragment { fragment ->
+ advanceRobolectricLooperUntil { fragment.binding.tagsList.adapter != null }
+ val recycler = fragment.binding.tagsList
+ recycler.measure(0, 0)
+ recycler.layout(0, 0, 100, 1000)
+ val parent = RecyclerViewUtils.viewHolderAt(recycler, 0)
+ repeat(parentClicks) { parent.checkBoxView.performClick() }
+ (fragment.requireDialog() as AlertDialog).getButton(AlertDialog.BUTTON_POSITIVE).performClick()
+ advanceRobolectricLooper()
+ Mockito.verify(listener).onSelectedTags(selected, preserved, CardStateFilter.ALL_CARDS)
+ }
+ }
+ }
+
+ @Test
+ fun `missing selection dismisses tags dialog without submitting`() = assertUnavailableSelection { assertTrue(delete()) }
+
+ @Test
+ fun `truncated selection dismisses tags dialog without submitting`() =
+ assertUnavailableSelection {
+ writeBytes(readBytes().dropLast(1).toByteArray())
+ }
+
+ private fun assertUnavailableSelection(changeFile: IdsFile.() -> Unit) {
+ val listener = Mockito.mock(TagsDialogListener::class.java)
+ val fragment = TagsDialog(listener).withArguments(targetContext, TagsDialog.DialogType.EDIT_TAGS, listOf(addBasicNote().id))
+ fragment.requireArguments().requireParcelable(TagsDialog.ARG_TAGS_FILE).changeFile()
+ Robolectric.buildActivity(FragmentActivity::class.java).use { controller ->
+ controller.get().setTheme(R.style.Theme_Light)
+ val activity = controller.setup().get()
+ fragment.show(activity.supportFragmentManager, "tags")
+ advanceRobolectricLooper()
+ assertNull(activity.supportFragmentManager.findFragmentByTag("tags"))
+ assertEquals(targetContext.getString(CommonString.something_wrong), ShadowToast.getTextOfLatestToast())
+ Mockito.verifyNoInteractions(listener)
+ }
+ }
+
+ @Test
+ fun `restoring after selection loss dismisses tags dialog`() {
+ val fragment = TagsDialog().withArguments(targetContext, TagsDialog.DialogType.EDIT_TAGS, listOf(addBasicNote().id))
+ val file = fragment.requireArguments().requireParcelable(TagsDialog.ARG_TAGS_FILE)
+ val savedState =
+ Robolectric.buildActivity(FragmentActivity::class.java).use { controller ->
+ controller.get().setTheme(R.style.Theme_Light)
+ val activity = controller.setup().get()
+ fragment.show(activity.supportFragmentManager, "tags")
+ advanceRobolectricLooper()
+ controller.saveState().parcelledCopy(FragmentActivity::class.java.classLoader)
+ }
+ assertTrue(file.delete())
+ ShadowToast.reset()
+ Robolectric.buildActivity(FragmentActivity::class.java).use { controller ->
+ controller.get().setTheme(R.style.Theme_Light)
+ val activity = controller.setup(savedState).get()
+ advanceRobolectricLooper()
+ assertNull(activity.supportFragmentManager.findFragmentByTag("tags"))
+ assertEquals(targetContext.getString(CommonString.something_wrong), ShadowToast.getTextOfLatestToast())
+ }
+ }
+
// regression test #8762
// test for #8763
@Test
@@ -60,10 +169,7 @@ class TagsDialogTest : RobolectricTest() {
val mockListener = Mockito.mock(TagsDialogListener::class.java)
val factory = TagsDialogFactory(mockListener)
runTagsDialogScenario(args, factory) { f: TagsDialog ->
- val dialog = f.dialog as AlertDialog?
- assertThat(dialog, IsNull.notNullValue())
-
- val recycler: RecyclerView = dialog!!.findViewById(R.id.tags_list)!!
+ val recycler = f.binding.tagsList
val tag = "zzzz"
f.addTag(tag)
@@ -94,10 +200,7 @@ class TagsDialogTest : RobolectricTest() {
val mockListener = Mockito.mock(TagsDialogListener::class.java)
val factory = TagsDialogFactory(mockListener)
runTagsDialogScenario(args, factory) { f: TagsDialog ->
- val dialog = f.dialog as AlertDialog?
- assertThat(dialog, IsNull.notNullValue())
-
- val recycler: RecyclerView = dialog!!.findViewById(R.id.tags_list)!!
+ val recycler = f.binding.tagsList
val tag = "e"
f.addTag(tag)
@@ -115,6 +218,28 @@ class TagsDialogTest : RobolectricTest() {
}
}
+ @Test
+ fun `unchecking parent after check all keeps it indeterminate`() {
+ withTagsAfterTogglingAll(arrayListOf("1")) { parent, child ->
+ parent.performClick()
+ assertThat(parent.state, equalTo(INDETERMINATE))
+ assertThat(child.state, equalTo(CHECKED))
+
+ child.performClick()
+ assertThat(parent.state, equalTo(UNCHECKED))
+ }
+ }
+
+ @Test
+ fun `uncheck all clears the indeterminate cycle for parents`() {
+ withTagsAfterTogglingAll(arrayListOf("1", "1::2")) { parent, child ->
+ parent.performClick()
+ parent.performClick()
+ assertThat(parent.state, equalTo(UNCHECKED))
+ assertThat(child.state, equalTo(UNCHECKED))
+ }
+ }
+
@Test
fun test_checked_unchecked_indeterminate() {
val type = TagsDialog.DialogType.EDIT_TAGS
@@ -130,10 +255,7 @@ class TagsDialogTest : RobolectricTest() {
val mockListener = Mockito.mock(TagsDialogListener::class.java)
val factory = TagsDialogFactory(mockListener)
runTagsDialogScenario(args, factory) { f: TagsDialog ->
- val dialog = f.dialog as AlertDialog?
- assertThat(dialog, IsNull.notNullValue())
-
- val recycler: RecyclerView = dialog!!.findViewById(R.id.tags_list)!!
+ val recycler = f.binding.tagsList
// workaround robolectric recyclerView issue
// update recycler
@@ -185,10 +307,7 @@ class TagsDialogTest : RobolectricTest() {
val mockListener = Mockito.mock(TagsDialogListener::class.java)
val factory = TagsDialogFactory(mockListener)
runTagsDialogScenario(args, factory) { f: TagsDialog ->
- val dialog = f.dialog as AlertDialog?
- assertThat(dialog, IsNull.notNullValue())
-
- val recycler: RecyclerView = dialog!!.findViewById(R.id.tags_list)!!
+ val recycler = f.binding.tagsList
fun getItem(index: Int): TagsArrayAdapter.ViewHolder = RecyclerViewUtils.viewHolderAt(recycler, index)
@@ -230,10 +349,7 @@ class TagsDialogTest : RobolectricTest() {
val mockListener = Mockito.mock(TagsDialogListener::class.java)
val factory = TagsDialogFactory(mockListener)
runTagsDialogScenario(args, factory) { f: TagsDialog ->
- val dialog = f.dialog as AlertDialog?
- assertThat(dialog, IsNull.notNullValue())
-
- val recycler: RecyclerView = dialog!!.findViewById(R.id.tags_list)!!
+ val recycler = f.binding.tagsList
val tag = "common::sport::football::small"
f.addTag(tag)
@@ -283,10 +399,7 @@ class TagsDialogTest : RobolectricTest() {
val mockListener = Mockito.mock(TagsDialogListener::class.java)
val factory = TagsDialogFactory(mockListener)
runTagsDialogScenario(args, factory) { f: TagsDialog ->
- val dialog = f.dialog as AlertDialog?
- assertThat(dialog, IsNull.notNullValue())
-
- val recycler: RecyclerView = dialog!!.findViewById(R.id.tags_list)!!
+ val recycler = f.binding.tagsList
val tag = "common::::careless"
f.addTag(tag)
@@ -332,10 +445,7 @@ class TagsDialogTest : RobolectricTest() {
val mockListener = Mockito.mock(TagsDialogListener::class.java)
val factory = TagsDialogFactory(mockListener)
runTagsDialogScenario(args, factory) { f: TagsDialog ->
- val dialog = f.dialog as AlertDialog?
- assertThat(dialog, IsNull.notNullValue())
-
- val recycler: RecyclerView = dialog!!.findViewById(R.id.tags_list)!!
+ val recycler = f.binding.tagsList
val adapter = recycler.adapter!! as TagsArrayAdapter
adapter.filter.filter("tennis")
@@ -372,10 +482,7 @@ class TagsDialogTest : RobolectricTest() {
val mockListener = Mockito.mock(TagsDialogListener::class.java)
val factory = TagsDialogFactory(mockListener)
runTagsDialogScenario(args, factory) { f: TagsDialog ->
- val dialog = f.dialog as AlertDialog?
- assertThat(dialog, IsNull.notNullValue())
-
- val recycler: RecyclerView = dialog!!.findViewById(R.id.tags_list)!!
+ val recycler = f.binding.tagsList
fun updateLayout() {
recycler.measure(0, 0)
@@ -419,10 +526,7 @@ class TagsDialogTest : RobolectricTest() {
val mockListener = Mockito.mock(TagsDialogListener::class.java)
val factory = TagsDialogFactory(mockListener)
runTagsDialogScenario(args, factory) { f: TagsDialog ->
- val dialog = f.dialog as AlertDialog?
- assertThat(dialog, IsNull.notNullValue())
-
- val recycler: RecyclerView = dialog!!.findViewById(R.id.tags_list)!!
+ val recycler = f.binding.tagsList
fun getItem(index: Int): TagsArrayAdapter.ViewHolder = RecyclerViewUtils.viewHolderAt(recycler, index)
@@ -536,9 +640,9 @@ class TagsDialogTest : RobolectricTest() {
val mockListener = Mockito.mock(TagsDialogListener::class.java)
val factory = TagsDialogFactory(mockListener)
runTagsDialogScenario(args, factory) { f: TagsDialog ->
- val dialog = f.dialog as AlertDialog?
- assertThat(dialog, IsNull.notNullValue())
- val editText = f.getSearchView()!!.findViewById(androidx.appcompat.R.id.search_src_text)!!
+ val toolbar = f.binding.toolbar.root
+ val searchView = toolbar.menu.findItem(R.id.tags_dialog_action_filter).actionView!!
+ val editText = searchView.findViewById(androidx.appcompat.R.id.search_src_text)!!
editText.setText("hello ")
Assert.assertEquals(
@@ -590,9 +694,8 @@ class TagsDialogTest : RobolectricTest() {
.withTestArguments(TagsDialog.DialogType.FILTER_BY_TAG, arrayListOf(), allTags)
.requireArguments()
runTagsDialogScenario(args) { f: TagsDialog ->
- val dialog = f.requireDialog()
- val recycler: RecyclerView = dialog.findViewById(R.id.tags_list)!!
- val content = dialog.findViewById(R.id.toolbar)!!.parent as View
+ val recycler = f.binding.tagsList
+ val content = f.binding.root
content.measure(
View.MeasureSpec.makeMeasureSpec(500, View.MeasureSpec.EXACTLY),
@@ -646,6 +749,27 @@ class TagsDialogTest : RobolectricTest() {
.withTestArguments(TagsDialog.DialogType.EDIT_TAGS, arrayListOf(), listOf("a"))
.requireArguments()
+ private fun withTagsAfterTogglingAll(
+ checkedTags: ArrayList,
+ block: (parent: CheckBoxTriStates, child: CheckBoxTriStates) -> Unit,
+ ) {
+ val args =
+ TagsDialog()
+ .withTestArguments(TagsDialog.DialogType.EDIT_TAGS, checkedTags, listOf("1", "1::2"))
+ .requireArguments()
+ runTagsDialogScenario(args) { fragment ->
+ val toolbar = fragment.binding.toolbar.root
+ toolbar.menu.performIdentifierAction(R.id.tags_dialog_action_select_all, 0)
+
+ val recycler = fragment.binding.tagsList
+ recycler.measure(0, 0)
+ recycler.layout(0, 0, 100, 1000)
+ val parent = RecyclerViewUtils.viewHolderAt(recycler, 0).checkBoxView
+ val child = RecyclerViewUtils.viewHolderAt(recycler, 1).checkBoxView
+ block(parent, child)
+ }
+ }
+
// these are called 'withTestArguments' due to "extension is shadowed by a member" warnings
// this is needed so we can pass in 'targetContext' for context.cacheDir
private fun TagsDialog.withTestArguments(
@@ -671,6 +795,7 @@ class TagsDialogTest : RobolectricTest() {
FragmentScenario.launch(TagsDialog::class.java, args, R.style.Theme_Light, factory).use { scenario ->
scenario.moveToState(Lifecycle.State.STARTED)
scenario.onFragment { tagsDialog: TagsDialog ->
+ advanceRobolectricLooperUntil { tagsDialog.binding.tagsList.adapter != null }
block(tagsDialog)
}
}
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/tags/TagsListTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/tags/TagsListTest.kt
index 80d5d1ef598f..2ab3559f81fd 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/tags/TagsListTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/tags/TagsListTest.kt
@@ -506,6 +506,82 @@ class TagsListTest {
)
}
+ @Test
+ fun `sort keeps selected branches first and compares hierarchy parts ignoring case`() {
+ val tags =
+ TagsList(
+ listOf("Zoo", "alpha!", "ALPHA::bbbz", "Alpha::bbb::leaf", "alpha::BBB", "Beta::z", "beta::A"),
+ listOf("BETA::a", "Zoo"),
+ )
+
+ tags.sort()
+
+ assertEquals(
+ listOf("Beta", "beta::A", "Beta::z", "Zoo", "ALPHA", "alpha::BBB", "Alpha::bbb::leaf", "ALPHA::bbbz", "alpha!"),
+ tags.copyOfAllTagList(),
+ )
+ }
+
+ @Test
+ fun `sort updates ordering after checking and unchecking tags`() {
+ val tags = TagsList(listOf("c", "b", "a"), emptyList())
+ tags.sort()
+
+ tags.check("C")
+ tags.sort()
+ assertEquals(listOf("c", "a", "b"), tags.copyOfAllTagList())
+
+ tags.uncheck("c")
+ tags.sort()
+ assertEquals(listOf("a", "b", "c"), tags.copyOfAllTagList())
+ }
+
+ @Test
+ fun `sort updates ordering when a branch becomes indeterminate`() {
+ val tags = TagsList(listOf("a", "b::child"), emptyList())
+ tags.sort()
+
+ tags.check("B::child")
+ tags.sort()
+ assertEquals(listOf("b", "b::child", "a"), tags.copyOfAllTagList())
+
+ tags.uncheck("b::child")
+ tags.uncheck("b")
+ tags.sort()
+ assertEquals(listOf("a", "b", "b::child"), tags.copyOfAllTagList())
+
+ tags.setIndeterminate("B")
+ tags.sort()
+ assertEquals(listOf("b", "b::child", "a"), tags.copyOfAllTagList())
+ }
+
+ @Test
+ fun `sort updates ordering after selecting all tags`() {
+ val tags = TagsList(listOf("c", "b", "a"), listOf("c"))
+ tags.sort()
+ assertEquals(listOf("c", "a", "b"), tags.copyOfAllTagList())
+
+ tags.toggleAllCheckedStatuses()
+ tags.sort()
+ assertEquals(listOf("a", "b", "c"), tags.copyOfAllTagList())
+ }
+
+ @Test
+ fun `sort includes newly added ancestors and preserves case insensitive uniqueness`() {
+ val tags = TagsList(listOf("z", "a"), listOf("z"))
+ tags.sort()
+
+ assertTrue(tags.add("B::child"))
+ tags.sort()
+ assertEquals(listOf("z", "a", "B", "B::child"), tags.copyOfAllTagList())
+ assertFalse("Sorting must preserve case insensitive uniqueness", tags.add("b::CHILD"))
+
+ assertTrue(tags.check("b::CHILD"))
+ tags.sort()
+ assertEquals(listOf("B", "B::child", "z", "a"), tags.copyOfAllTagList())
+ assertFalse("Sorting must preserve case insensitive uniqueness", tags.add("Z"))
+ }
+
@Test // #8807
@Ignore(
"Collections.singletonList() triggers infinite recursion. " +
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/export/ExportDialogFragmentTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/export/ExportDialogFragmentTest.kt
index dbf1aec7ed0e..7458bd29fa51 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/export/ExportDialogFragmentTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/export/ExportDialogFragmentTest.kt
@@ -2,6 +2,8 @@
package com.ichi2.anki.export
+import android.content.DialogInterface
+import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.testing.launchFragment
import androidx.test.espresso.Espresso.onData
import androidx.test.espresso.Espresso.onView
@@ -17,6 +19,7 @@ import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.ichi2.anki.CollectionManager.TR
+import com.ichi2.anki.CommonString
import com.ichi2.anki.R
import com.ichi2.anki.RobolectricTest
import com.ichi2.anki.browser.IdsFile
@@ -25,10 +28,45 @@ import org.hamcrest.CoreMatchers.containsString
import org.hamcrest.CoreMatchers.not
import org.junit.Test
import org.junit.runner.RunWith
+import org.robolectric.shadows.ShadowToast
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
import kotlin.test.assertTrue
@RunWith(AndroidJUnit4::class)
class ExportDialogFragmentTest : RobolectricTest() {
+ @Test
+ fun `missing selection cancels export`() = assertUnavailableSelection { assertTrue(delete()) }
+
+ @Test
+ fun `truncated selection cancels export`() = assertUnavailableSelection { writeBytes(readBytes().dropLast(1).toByteArray()) }
+
+ private fun assertUnavailableSelection(changeFile: IdsFile.() -> Unit) =
+ runTest {
+ ensureCollectionLoadIsSynchronous()
+ for (type in ExportDialogFragment.ExportType.entries) {
+ // APKG, notes as text, and cards as text all need the original selection.
+ for (exportFormat in 1..3) {
+ val args = ExportDialogFragment.newInstance(targetContext.cacheDir, type, listOf(1L)).requireArguments()
+ launchFragment(fragmentArgs = args, themeResId = R.style.Theme_Light).use { scenario ->
+ advanceRobolectricLooper()
+ scenario.onFragment { fragment ->
+ fragment.binding.exportTypeSelector.setSelection(exportFormat)
+ // The file can disappear even while the dialog is open.
+ args.requireParcelable(ARG_IDS_FILE).changeFile()
+ val dialog = fragment.requireDialog() as AlertDialog
+ assertTrue(fragment.binding.deckSelector.isEnabled)
+ ShadowToast.reset()
+ dialog.getButton(DialogInterface.BUTTON_POSITIVE).performClick()
+ advanceRobolectricLooper()
+ assertFalse(dialog.isShowing)
+ assertEquals(targetContext.getString(CommonString.something_wrong), ShadowToast.getTextOfLatestToast())
+ }
+ }
+ }
+ }
+ }
+
@Test
fun `collection export options are initialized correctly`() {
onExportDialog {
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/filtered/FilteredDeckOptionsScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/filtered/FilteredDeckOptionsScreenshotTest.kt
new file mode 100644
index 000000000000..592388bee321
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/filtered/FilteredDeckOptionsScreenshotTest.kt
@@ -0,0 +1,96 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.filtered
+
+import androidx.core.view.isVisible
+import anki.decks.copy
+import com.ichi2.anki.ScreenshotTest
+import com.ichi2.anki.databinding.FragmentFilteredDeckOptionsBinding
+import com.ichi2.anki.libanki.DeckId
+import com.ichi2.anki.utils.ConfigAwareSingleFragmentActivity
+import com.ichi2.testutils.simulateSystemBars
+import com.ichi2.utils.dp
+import io.mockk.every
+import io.mockk.mockkObject
+import io.mockk.unmockkObject
+import org.junit.Test
+import org.robolectric.RuntimeEnvironment
+
+/**
+ * Screenshot tests for [FilteredDeckOptionsFragment].
+ *
+ * `./gradlew :AnkiDroid:verifyRoborazziPlayDebug -Pscreenshot --tests "com.ichi2.anki.filtered.FilteredDeckOptionsScreenshotTest"`
+ */
+class FilteredDeckOptionsScreenshotTest : ScreenshotTest() {
+ @Test
+ fun `new filtered deck`() =
+ withOptions { _, _ ->
+ captureScreen("newDeck")
+ }
+
+ @Test
+ fun `existing filtered deck with system bars`() =
+ withOptions(deckId = col.decks.newFiltered("Filtered deck")) { activity, _ ->
+ activity.simulateSystemBars()
+ captureScreen("existingDeck_systemBars")
+ }
+
+ @Test
+ fun `landscape display cutout`() {
+ RuntimeEnvironment.setQualifiers("+land")
+ withOptions { activity, binding ->
+ activity.simulateSystemBars(cutoutLeft = 32.dp)
+ captureScreen("landscapeCutout")
+ captureScrolledToEnd("landscapeCutout_scrolledToEnd", binding)
+ }
+ }
+
+ @Test
+ fun `second filter and preview delays`() =
+ withOptions { activity, binding ->
+ binding.switchSecondFilter.isChecked = true
+ binding.checkBoxReschedule.isChecked = false
+ advanceRobolectricLooperUntil {
+ binding.secondFilterSearchContainer.isVisible && binding.rescheduleDelayAgainLayout.isVisible
+ }
+ binding.secondFilterSearchInput.setText("is:new")
+ binding.switchSecondFilter.jumpDrawablesToCurrentState()
+ binding.checkBoxReschedule.jumpDrawablesToCurrentState()
+ activity.simulateSystemBars()
+ captureScreen("secondFilter_systemBars")
+ captureScrolledToEnd("previewDelays_systemBars_scrolledToEnd", binding)
+ }
+
+ private fun captureScrolledToEnd(
+ name: String,
+ binding: FragmentFilteredDeckOptionsBinding,
+ ) {
+ binding.scrollView.scrollTo(0, binding.scrollView.getChildAt(0).bottom)
+ advanceRobolectricLooper()
+ captureScreen(name)
+ }
+
+ private fun withOptions(
+ deckId: DeckId = 0,
+ block: (ConfigAwareSingleFragmentActivity, FragmentFilteredDeckOptionsBinding) -> Unit,
+ ) {
+ val scheduler = col.sched
+ // The backend includes the current time in new deck names, outside Robolectric's clock.
+ val options = scheduler.getOrCreateFilteredDeck(deckId).copy { name = "Filtered deck" }
+ mockkObject(scheduler)
+ try {
+ every { scheduler.getOrCreateFilteredDeck(deckId) } returns options
+ val activity =
+ startActivityNormallyOpenCollectionWithIntent(
+ ConfigAwareSingleFragmentActivity::class.java,
+ FilteredDeckOptionsFragment.getIntent(targetContext, did = deckId),
+ )
+ val binding = FragmentFilteredDeckOptionsBinding.bind(activity.fragment!!.requireView())
+ // Loading the options uses the collection dispatcher; draining the main looper alone can miss it.
+ advanceRobolectricLooperUntil { binding.scrollView.isVisible }
+ block(activity, binding)
+ } finally {
+ unmockkObject(scheduler)
+ }
+ }
+}
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/filtered/FilteredDeckOptionsViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/filtered/FilteredDeckOptionsViewModelTest.kt
index aeca698fc34c..c0a24ca3e253 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/filtered/FilteredDeckOptionsViewModelTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/filtered/FilteredDeckOptionsViewModelTest.kt
@@ -1,5 +1,4 @@
// SPDX-License-Identifier: GPL-3.0-or-later
-// SPDX-FileCopyrightText: Copyright (c) 2025 lukstbit <52494258+lukstbit@users.noreply.github.com>
package com.ichi2.anki.filtered
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/mediacheck/MediaCheckFragmentTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/mediacheck/MediaCheckFragmentTest.kt
new file mode 100644
index 000000000000..dbb99dd7d673
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/mediacheck/MediaCheckFragmentTest.kt
@@ -0,0 +1,87 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright (c) 2026 Ashish Yadav
+package com.ichi2.anki.mediacheck
+
+import android.widget.Button
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import anki.backend.BackendError
+import anki.backend.backendError
+import anki.media.CheckMediaResponse
+import com.ichi2.anki.CollectionManager
+import com.ichi2.anki.R
+import com.ichi2.anki.RobolectricTest
+import com.ichi2.anki.SingleFragmentActivity
+import com.ichi2.anki.throwOnShowError
+import com.ichi2.utils.positiveButton
+import net.ankiweb.rsdroid.Backend
+import net.ankiweb.rsdroid.BackendFactory
+import net.ankiweb.rsdroid.exceptions.BackendIoException
+import org.junit.After
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.io.File
+import kotlin.test.assertEquals
+
+@RunWith(AndroidJUnit4::class)
+class MediaCheckFragmentTest : RobolectricTest() {
+ private val failure =
+ BackendIoException(
+ backendError {
+ kind = BackendError.Kind.IO_ERROR
+ message = "Operation not permitted (os error 1)"
+ },
+ )
+
+ override fun getCollectionStorageMode() = CollectionStorageMode.ON_DISK
+
+ @After
+ fun resetBackendFactory() {
+ BackendFactory.setOverride(null)
+ }
+
+ /** Issue 22075 */
+ @Test
+ fun `failed media check shows an error`() =
+ runTest {
+ useBackend {
+ object : Backend() {
+ override fun checkMedia(): CheckMediaResponse = throw failure
+ }
+ }
+ throwOnShowError = false
+
+ startMediaCheck()
+
+ assertEquals(failure.localizedMessage, getAlertDialogText(checkDismissed = true))
+ }
+
+ @Test
+ fun `failed deletion shows an error instead of the result`() =
+ runTest {
+ useBackend {
+ object : Backend() {
+ override fun trashMediaFiles(fnames: Iterable): Unit = throw failure
+ }
+ }
+ File(col.media.dir, "unused.png").writeText("not an image")
+ throwOnShowError = false
+
+ val activity = startMediaCheck()
+ activity.findViewById