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 98aa44c6d6c9..3fe003a8fb0c 100644
--- a/.github/workflows/screenshot_compare.yml
+++ b/.github/workflows/screenshot_compare.yml
@@ -42,7 +42,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"
@@ -50,7 +50,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
@@ -66,7 +66,7 @@ jobs:
command: ./gradlew robolectricSdkDownload :AnkiDroid:compilePlayDebugUnitTestKotlin -Pscreenshot --daemon
- name: Download base branch screenshots
- uses: dawidd6/action-download-artifact@d63b86af1b34672e53c440b1b83979861906bad7 # v24
+ uses: dawidd6/action-download-artifact@eab87c9830c39eff17e5a6eadb20bfb4bc880477 # v27
continue-on-error: true
with:
name: screenshot
diff --git a/.github/workflows/screenshot_store.yml b/.github/workflows/screenshot_store.yml
index b775405f895b..dc53a73d5829 100644
--- a/.github/workflows/screenshot_store.yml
+++ b/.github/workflows/screenshot_store.yml
@@ -54,7 +54,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"
@@ -63,7 +63,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 2f62d5f8de6e..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
@@ -161,6 +161,35 @@ jobs:
max_attempts: 3
command: ./gradlew packagePlayRelease packagePlayReleaseAndroidTest --daemon
+ - 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
+ "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
# Comment the if out to generate snapshots on branch for performance testing
@@ -227,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') != ''
@@ -302,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 @@
rerender
resync
retryable
+ scrollshot
searchpreference
servicelayer
sinh
diff --git a/AnkiDroid/build.gradle b/AnkiDroid/build.gradle
index ca6cdea8e422..4faaea5d3f8e 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
@@ -116,13 +113,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=22600100
+ 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.0alpha0"
- 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'
@@ -314,11 +307,6 @@ android {
testOptions {
animationsDisabled = true
- kotlin {
- compilerOptions {
- freeCompilerArgs.add("-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi")
- }
- }
unitTests {
all { test ->
test.useJUnitPlatform {
@@ -365,8 +353,6 @@ android {
}
compileOptions {
- sourceCompatibility JavaVersion.VERSION_17
- targetCompatibility JavaVersion.VERSION_17
coreLibraryDesugaringEnabled = true
}
@@ -377,8 +363,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 {
@@ -664,8 +659,15 @@ afterEvaluate {
}
apply from: "./robolectricDownloader.gradle"
-apply from: "./jacoco.gradle.kts"
-apply from: "../lint.gradle"
+
+android {
+ lint {
+ // Analyze project-local dependencies (:common:android etc.) as part of this
+ // module's lint run.
+ // Required for whole-app UnusedResources and StringFormatMatches coverage
+ checkDependencies = true
+ }
+}
dependencies {
configurations.configureEach {
diff --git a/AnkiDroid/robolectricDownloader.gradle b/AnkiDroid/robolectricDownloader.gradle
index 83756ce8ed29..11bd88d9406e 100644
--- a/AnkiDroid/robolectricDownloader.gradle
+++ b/AnkiDroid/robolectricDownloader.gradle
@@ -1,5 +1,6 @@
/**
- * Downloads all android-all-instrumented dependencies and copies them to the mavenLocal() repository
+ * Downloads android-all-instrumented dependencies into Gradle's cache for offline Robolectric.
+ * Writes a properties file pointing to the cached jars, so worktrees do not need their own copies.
*
* Once applied to your gradle project, can be executed with ./gradlew robolectricSdkDownload
*/
@@ -13,14 +14,11 @@
// Note: the PREINSTRUMENTED_VERSION constant is what you put on the end of the artifact
// Only the versions currently used in AnkiDroid Robolectric tests are active, the rest are commented out
-// To update these versions, open a terminal in the Anki-Android directory and perform the following steps:
-// 1. Run `rm -r ~/.m2` to delete the .m2 directory in your home directory.
-// 2. Run `./gradlew jacocoUnitTestReport` to run all unit tests.
-// 3. Run `find ~/.m2 -type d -name '*-robolectric-*'`.
-// 4. Update the lines below to match the output from `find`.
+// When updating Robolectric, match these versions to DefaultSdkProvider and PREINSTRUMENTED_VERSION.
+// 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"],
@@ -34,15 +32,16 @@ def robolectricAndroidSdkVersions = [
[androidVersion: "15", frameworkSdkBuildVersion: "13954326"],
// TODO: check TarArchiveInputStream.forEachEntry when this is updated
[androidVersion: "16", frameworkSdkBuildVersion: "13921718"], // current targetSdk
+ [androidVersion: "17", frameworkSdkBuildVersion: "15733970"],
]
-// Base, public task - will be displayed in ./gradlew robolectricDownloader:tasks
-tasks.register('robolectricSdkDownload') {
+def robolectricSdkDownload = tasks.register('robolectricSdkDownload', WriteProperties) {
group = "Dependencies"
- description = "Downloads all robolectric SDK dependencies into mavenLocal, for use with offline robolectric"
+ description = "Downloads Robolectric SDKs and writes their cached paths for offline tests"
+ destinationFile = layout.buildDirectory.file("robolectric-deps.properties")
}
-// Generate the configuration and actual copy tasks.
+// Use separate configurations so Gradle retains every SDK version instead of selecting the newest.
robolectricAndroidSdkVersions.forEach { robolectricSdkVersion ->
// the final part of this `-i` comes from PREINSTRUMENTED_VERSION in upstream DefaultSdkProvider
def version = "${robolectricSdkVersion['androidVersion']}-robolectric-${robolectricSdkVersion['frameworkSdkBuildVersion']}-i7"
@@ -52,24 +51,9 @@ robolectricAndroidSdkVersions.forEach { robolectricSdkVersion ->
def sdkConfig = configurations.create(version)
dependencies.add(version, "org.robolectric:android-all-instrumented:${version}")
- // An ArtifactView on the existing configuration gives us the POM alongside the JAR so
- // maven local has both.
- def sdkPomFiles = sdkConfig.incoming.artifactView {
- attributes {
- attribute(ArtifactTypeDefinition.ARTIFACT_TYPE_ATTRIBUTE, "pom")
- }
- // if the POM can't be resolved, return an empty collection rather than failing the build
- lenient = true
- }.files
-
- def mavenLocalFile = new File(this.repositories.mavenLocal().url)
- def mavenRobolectric = new File(mavenLocalFile, "org/robolectric/android-all-instrumented/${version}")
- // Copying all files downloaded for the created configuration into maven local.
- tasks.register("robolectricSdkDownload-${version}", Copy) {
- from sdkConfig
- from sdkPomFiles
- into mavenRobolectric
+ robolectricSdkDownload.configure {
+ inputs.files(sdkConfig).withPropertyName(version)
+ property("org.robolectric:android-all-instrumented:${version}",
+ sdkConfig.elements.map { it.iterator().next().asFile.absolutePath })
}
- robolectricSdkDownload.configure { dependsOn "robolectricSdkDownload-${version}" }
}
-
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 26e2eb0bbb76..18a627e123b5 100644
--- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/pages/DeckOptionsTest.kt
+++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/pages/DeckOptionsTest.kt
@@ -4,6 +4,7 @@ package com.ichi2.anki.pages
import androidx.core.content.edit
import androidx.core.view.isVisible
+import androidx.lifecycle.lifecycleScope
import androidx.test.core.app.ActivityScenario
import androidx.test.platform.app.InstrumentationRegistry
import com.ichi2.anki.CollectionManager.TR
@@ -12,16 +13,18 @@ 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
+import kotlinx.coroutines.async
+import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
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
@@ -67,42 +70,148 @@ 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 {
saveAndOptimize(newPerDay = 43)
+ assertSavedAndReloaded(newPerDay = 43)
+ }
+ }
- waitUntil(timeout = 30.seconds, message = { "optimization did not save the changed limit" }) {
- col.defaultDeckNewCardsPerDay == 43
- }
- val optionsReloaded = AtomicBoolean(false)
- waitUntil(timeout = 30.seconds, message = { "options did not reload after optimization" }) {
- // Reloading can discard an evaluation callback. Retry without waiting for each one.
- InstrumentationRegistry.getInstrumentation().runOnMainSync {
- webViewLayout.evaluateJavascript(
- "globalThis.beforeOptimization === undefined && Array.from(document.querySelectorAll('input[type=number]')).find(input => input.offsetParent !== null)?.value === '43'",
- ) {
- if (it == "true") optionsReloaded.set(true)
- }
+ @Test
+ fun optimizingAllPresetsWaitsForSlowSave() {
+ withDelayedSave {
+ saveAndOptimize(newPerDay = 43)
+ assertSavedAndReloaded(newPerDay = 43)
+ }
+ }
+
+ @Test
+ fun failedOptimizationKeepsEditsAndAllowsRetry() {
+ val originalLimit = col.defaultDeckNewCardsPerDay
+ withDelayedSave(failFirstAttempt = true) {
+ // Capture the error without leaving a dialog blocking the retry.
+ evaluateJavascript("window.alert = message => { globalThis.saveError = String(message); }")
+ saveAndOptimize(newPerDay = 43)
+
+ waitForPageCondition(
+ condition = "globalThis.saveError?.includes('Optimization failed for test') === true",
+ message = "save error was not displayed",
+ )
+ assertEquals("true", evaluateJavascript("globalThis.beforeOptimization === true"), "failed save reloaded the page")
+ assertEquals(originalLimit, col.defaultDeckNewCardsPerDay)
+
+ saveAndOptimize(newPerDay = 43)
+ assertSavedAndReloaded(newPerDay = 43)
+ }
+ }
+
+ /** Exercise a pending response, optionally rejecting the first save so the test can retry. */
+ private fun withDelayedSave(
+ failFirstAttempt: Boolean = false,
+ block: DeckOptions.() -> Unit,
+ ) {
+ val originalHandler = uiMethods.getValue("updateDeckConfigs")
+ var failNextSave = failFirstAttempt
+ uiMethods["updateDeckConfigs"] = { input ->
+ val activity = this
+ lifecycleScope.async {
+ // explicitly reproduce the reload race
+ delay(1.seconds)
+ if (failNextSave) {
+ failNextSave = false
+ error("Optimization failed for test")
}
- optionsReloaded.get()
+ originalHandler.invoke(activity, input).await()
}
- assertFalse(requireActivity().isFinishing)
+ }
+ try {
+ withDeckOptions(block)
+ } finally {
+ uiMethods["updateDeckConfigs"] = originalHandler
}
}
+ private fun DeckOptions.assertSavedAndReloaded(newPerDay: Int) {
+ waitUntil(timeout = 30.seconds, message = { "optimization did not save the changed limit" }) {
+ col.defaultDeckNewCardsPerDay == newPerDay
+ }
+
+ // The edited field already has the expected value. The marker disappearing proves
+ // that a new document was loaded and the value came back from the saved settings.
+ waitForPageCondition(
+ condition =
+ """
+ (() => {
+ const newCardsPerDay = Array.from(document.querySelectorAll('input[type="number"]'))
+ .find(input => input.offsetParent !== null);
+ return globalThis.beforeOptimization === undefined && newCardsPerDay?.value === '$newPerDay';
+ })();
+ """.trimIndent(),
+ message = "options did not reload after optimization",
+ )
+ assertFalse(requireActivity().isFinishing)
+ }
+
private fun DeckOptions.saveAndOptimize(newPerDay: Int) {
val script =
"""
- globalThis.beforeOptimization = true;
- window.confirm = () => true;
- const input = Array.from(document.querySelectorAll('input[type="number"]')).find(input => input.offsetParent !== null);
- input.focus();
- input.value = '$newPerDay';
- input.dispatchEvent(new Event('input', { bubbles: true }));
- input.dispatchEvent(new Event('change', { bubbles: true }));
- Array.from(document.querySelectorAll('button'))
- .find(button => button.textContent.trim() === ${JSONObject.quote(TR.deckConfigSaveAndOptimize())}).click();
+ (() => {
+ globalThis.beforeOptimization = true;
+ window.confirm = () => true;
+ const input = Array.from(document.querySelectorAll('input[type="number"]')).find(input => input.offsetParent !== null);
+ input.focus();
+ input.value = '$newPerDay';
+ input.dispatchEvent(new Event('input', { bubbles: true }));
+ input.dispatchEvent(new Event('change', { bubbles: true }));
+ Array.from(document.querySelectorAll('button'))
+ .find(button => button.textContent.trim() === ${JSONObject.quote(TR.deckConfigSaveAndOptimize())}).click();
+ })();
""".trimIndent()
// The action navigates away from its JavaScript context, which can discard the
// evaluation callback. Observe the persisted settings and the new page instead.
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 67c448947a0d..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
@@ -1061,6 +1053,8 @@ abstract class AbstractFlashcardViewer :
MyWebView(this).apply {
scrollBarStyle = View.SCROLLBARS_OUTSIDE_OVERLAY
with(settings) {
+ // Scale the whole card with CSS instead; text-only zoom breaks MathJax layout.
+ textZoom = 100
displayZoomControls = false
builtInZoomControls = true
setSupportZoom(true)
@@ -1361,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
}
@@ -2001,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
}
@@ -2439,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,
@@ -2452,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,
@@ -2791,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/AndroidTtsPlayer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AndroidTtsPlayer.kt
index 03a94c8c82fe..3c9ed3d2bfda 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/AndroidTtsPlayer.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/AndroidTtsPlayer.kt
@@ -93,7 +93,9 @@ class AndroidTtsPlayer(
TtsVoices.ttsEngine?.let { defaultEngine -> getOrCreateTts(defaultEngine) }
}
- override fun getAvailableVoices(): List = this.voices
+ // Prefer installed voices for language fallback without changing the shared matcher.
+ // Keep uninstalled voices selectable: an explicit request can trigger their download.
+ override fun getAvailableVoices(): List = voices.sortedBy { it.unavailable() }
/**
* Returns a ready [TextToSpeech] bound to [engine], creating and caching one if necessary.
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt
index 8a2691a03cac..b9577f959eb4 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt
@@ -115,7 +115,7 @@ open class AnkiActivity(
) : AppCompatActivity(contentLayoutId ?: 0),
ShortcutGroupProvider,
AnkiActivityProvider {
- val exportReadyViewModel by viewModels()
+ val exportReadyViewModel by viewModels { ExportReadyViewModel.factory }
/**
* Receiver that informs us when a broadcast listen in [broadcastsActions] is received.
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt
index d87518e7a39d..ade3addae50e 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt
@@ -62,6 +62,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
@@ -667,7 +668,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/AppLifecycleObserver.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AppLifecycleObserver.kt
index c91ade3ac086..b985b5572da2 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/AppLifecycleObserver.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/AppLifecycleObserver.kt
@@ -6,7 +6,10 @@ import android.content.Context
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.lifecycleScope
import com.ichi2.widget.WidgetStatus
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.launch
import timber.log.Timber
class AppLifecycleObserver(
@@ -15,9 +18,17 @@ class AppLifecycleObserver(
override fun onStop(owner: LifecycleOwner) {
super.onStop(owner)
- if (owner.lifecycle.currentState != Lifecycle.State.DESTROYED && CollectionManager.isOpenUnsafe()) {
+ if (owner.lifecycle.currentState == Lifecycle.State.DESTROYED) return
+
+ // A sync may hold the collection queue. Keep the main thread free to handle
+ // WorkManager's foreground service timeout while waiting for the collection.
+ owner.lifecycleScope.launch {
try {
- WidgetStatus.updateInBackground(context)
+ if (CollectionManager.withOpenColOrNull { true } == true) {
+ WidgetStatus.updateInBackground(context)
+ }
+ } catch (e: CancellationException) {
+ throw e
} catch (e: Exception) {
Timber.w(e)
}
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 374c8ae2d3f5..120d4c1ce794 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt
@@ -176,6 +176,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
@@ -286,6 +287,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
@@ -541,7 +545,7 @@ open class DeckPicker :
// create inherited navigation drawer layout here so that it can be used by parent class
initNavigationDrawer()
- if (Prefs.devBottomNavEnabled && !fragmented) {
+ if (bottomNavigationEnabled) {
disableDrawerSwipe()
disableDrawerIndicator()
}
@@ -572,7 +576,7 @@ open class DeckPicker :
},
)
deckPickerBinding.decks.adapter = deckListAdapter
- if (Prefs.devBottomNavEnabled) {
+ if (bottomNavigationEnabled) {
deckPickerBinding.decks.addItemDecoration(
DeckHierarchyLinesDecoration(this, deckListAdapter),
)
@@ -583,12 +587,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)
@@ -1189,7 +1197,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.
@@ -1457,14 +1467,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")
@@ -1511,6 +1523,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()
@@ -1528,7 +1541,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)
@@ -1537,7 +1550,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
@@ -1617,7 +1629,7 @@ open class DeckPicker :
}
override fun dispatchKeyEvent(event: KeyEvent): Boolean {
- if (!Prefs.devBottomNavEnabled || fragmented || event.action != KeyEvent.ACTION_DOWN || !event.isAltPressed) {
+ if (!bottomNavigationEnabled || event.action != KeyEvent.ACTION_DOWN || !event.isAltPressed) {
return super.dispatchKeyEvent(event)
}
@@ -2357,7 +2369,7 @@ open class DeckPicker :
fun bottomNavShortcut(
keys: String,
destination: NavigationItem,
- ) = if (Prefs.devBottomNavEnabled && !fragmented) shortcut(keys, destination.shortcutLabel) else null
+ ) = if (bottomNavigationEnabled) shortcut(keys, destination.shortcutLabel) else null
return ShortcutGroup(
listOfNotNull(
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 81ec254396d0..7bfae56bf7f9 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/HomeScreenNavigation.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/HomeScreenNavigation.kt
@@ -23,7 +23,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.
@@ -38,7 +37,7 @@ fun setupBottomNavigation() {
if (deckPicker.supportFragmentManager.findFragmentByTag(NavigationItem.BROWSER.tag) != null) {
ensureBrowserViewModel()
}
- if (!Prefs.devBottomNavEnabled || deckPicker.fragmented) return
+ if (!deckPicker.bottomNavigationEnabled) return
val bottomNav = deckPicker.findViewById(R.id.bottom_navigation)
val fragmentContainer = deckPicker.findViewById(R.id.bottom_nav_fragment_container)
@@ -80,14 +79,14 @@ fun setupBottomNavigation() {
context(deckPicker: DeckPicker)
fun showRestoredBottomNavTab() {
- 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 132aa3c4f565..727b59e8085c 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt
@@ -64,6 +64,7 @@ import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.isVisible
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
+import androidx.core.widget.doAfterTextChanged
import androidx.draganddrop.DropHelper
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
@@ -74,6 +75,7 @@ import anki.notes.NoteFieldsCheckResponse
import com.google.android.material.color.MaterialColors
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
+import com.google.android.material.textfield.TextInputLayout
import com.ichi2.anki.CollectionManager.TR
import com.ichi2.anki.CollectionManager.withCol
import com.ichi2.anki.NoteEditorFragment.Companion.NoteEditorCaller.Companion.fromValue
@@ -2228,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() ?: "" }
+ }
}
}
}
@@ -2426,18 +2444,66 @@ class NoteEditorFragment :
requireContext().openUrl(R.string.link_manual_note_format_toolbar)
}.negativeButton(CommonString.dialog_cancel)
+ @NeedsTest("Create button disabled until name + one HTML field are filled")
private fun displayAddToolbarDialog() {
val v = layoutInflater.inflate(R.layout.dialog_note_editor_toolbar_add_custom_item, null)
- toolbarDialog.show {
- title(CommonString.add_toolbar_item)
- setView(v)
- positiveButton(CommonString.dialog_positive_create) {
- val etIcon = v.findViewById(R.id.note_editor_toolbar_item_icon)
- val et = v.findViewById(R.id.note_editor_toolbar_before)
- val et2 = v.findViewById(R.id.note_editor_toolbar_after)
- addToolbarButton(etIcon.text.toString(), et.text.toString(), et2.text.toString())
+
+ val etIcon = v.findViewById(R.id.note_editor_toolbar_item_icon)
+ val etBefore = v.findViewById(R.id.note_editor_toolbar_before)
+ val etAfter = v.findViewById(R.id.note_editor_toolbar_after)
+
+ val etIconLayout = v.findViewById(R.id.note_editor_toolbar_item_icon_layout)
+ val etBeforeLayout = v.findViewById(R.id.note_editor_toolbar_before_layout)
+ val etAfterLayout = v.findViewById(R.id.note_editor_toolbar_after_layout)
+
+ // Both HTML fields share one error message, displayed under etAfterLayout only.
+ // etBeforeLayout still receives the message so accessibility services report the field as
+ // invalid, but its text is hidden and it is not a live region, which would announce the message twice
+ etBeforeLayout.setErrorTextAppearance(R.style.TextAppearance_AnkiDroid_HiddenError)
+ etBeforeLayout.errorAccessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_NONE
+
+ val dialog =
+ toolbarDialog.show {
+ title(CommonString.add_toolbar_item)
+ setView(v)
+ positiveButton(CommonString.dialog_positive_create) {
+ addToolbarButton(etIcon.text.toString(), etBefore.text.toString(), etAfter.text.toString())
+ }
}
+
+ val posButton = dialog.positiveButton
+ posButton.isEnabled = false
+
+ // Setting an unchanged error would re-announce it to screen readers on every keystroke
+ fun TextInputLayout.setErrorIfChanged(message: String) {
+ if (error == message) return
+ error = message
}
+
+ fun updateToolbarItemValidationState() {
+ val hasIcon = etIcon.text.toString().isNotBlank()
+ val hasHtml = etBefore.text.toString().isNotBlank() || etAfter.text.toString().isNotBlank()
+ posButton.isEnabled = hasIcon && hasHtml
+
+ if (hasIcon) {
+ etIconLayout.isErrorEnabled = false
+ } else {
+ etIconLayout.setErrorIfChanged(getString(R.string.note_editor_toolbar_icon_requirement_error))
+ }
+
+ if (hasHtml) {
+ etBeforeLayout.isErrorEnabled = false
+ etAfterLayout.isErrorEnabled = false
+ } else {
+ val message = getString(R.string.before_after_text_requirement_error)
+ etBeforeLayout.setErrorIfChanged(message)
+ etAfterLayout.setErrorIfChanged(message)
+ }
+ }
+
+ etIcon.doAfterTextChanged { updateToolbarItemValidationState() }
+ etBefore.doAfterTextChanged { updateToolbarItemValidationState() }
+ etAfter.doAfterTextChanged { updateToolbarItemValidationState() }
}
private fun displayEditToolbarDialog(currentButton: CustomToolbarButton) {
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NoteTypeFieldEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NoteTypeFieldEditor.kt
index b24855f06423..4ae9410e5dd8 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/NoteTypeFieldEditor.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/NoteTypeFieldEditor.kt
@@ -22,6 +22,7 @@ import androidx.core.os.BundleCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsCompat.Type.displayCutout
+import androidx.core.view.WindowInsetsCompat.Type.ime
import androidx.core.view.WindowInsetsCompat.Type.systemBars
import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
@@ -103,7 +104,7 @@ class NoteTypeFieldEditor : AnkiActivity(R.layout.activity_note_type_field_edito
enableToolbar()
enableEdgeToEdge(statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT))
ViewCompat.setOnApplyWindowInsetsListener(binding.rootLayout) { _, insets ->
- val constraints = insets.getInsets(systemBars() or displayCutout())
+ val constraints = insets.getInsets(systemBars() or displayCutout() or ime())
binding.toolbarContainer.updatePadding(left = constraints.left, right = constraints.right, top = constraints.top)
binding.btnAdd.updateLayoutParams {
bottomMargin = constraints.bottom + 32.dp.toPx(this@NoteTypeFieldEditor)
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/StudyOptionsFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt
index c4f18b8e9b03..dbfbab4c7e60 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt
@@ -24,6 +24,7 @@ import androidx.core.text.parseAsHtml
import androidx.core.view.MenuProvider
import androidx.core.view.ViewCompat
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.doOnAttach
import androidx.core.view.isVisible
@@ -125,13 +126,13 @@ class StudyOptionsFragment :
refreshInterface()
}
- /** Keeps the content clear of the system bars and any display cutout. */
+ /** Keeps the content clear of the system bars, display cutout and keyboard. */
private fun setupContentInsets(view: View) {
view.doOnApplyWindowInsets { v, insets, _ ->
// Both hosts display this fragment below a toolbar, so don't update the top padding.
// Note: the left-padding is 0 when fragmented - handled by DeckPicker
- val bars = insets.getInsets(systemBars() or displayCutout())
- v.updatePadding(left = bars.left, right = bars.right, bottom = bars.bottom)
+ val contentInsets = insets.getInsets(systemBars() or displayCutout() or ime())
+ v.updatePadding(left = contentInsets.left, right = contentInsets.right, bottom = contentInsets.bottom)
}
// the view may be added after the insets were dispatched (returning from the review
// reminders screen), so request them again
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 e76394367369..5932a16432d3 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt
@@ -3,7 +3,6 @@
package com.ichi2.anki.browser
import android.os.Bundle
-import android.os.Parcel
import android.os.Parcelable
import androidx.annotation.CheckResult
import androidx.core.os.BundleCompat
@@ -70,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
@@ -90,16 +90,13 @@ 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
import org.jetbrains.annotations.VisibleForTesting
import timber.log.Timber
-import java.io.DataInputStream
-import java.io.DataOutputStream
import java.io.File
-import java.io.FileInputStream
-import java.io.FileOutputStream
import java.util.Collections
import kotlin.math.max
import kotlin.math.min
@@ -242,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
*
@@ -501,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()
@@ -550,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) }
@@ -608,7 +614,7 @@ class CardBrowserViewModel(
setSelectedDeck(initialDeckId)
refreshBackendColumns()
- flowOfReverseDirection.update { (SortType.build(cardsOrNotes) as? SortType.CollectionOrdering)?.reverse }
+ refreshSortState()
Timber.i("initCompleted")
@@ -628,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
*
@@ -655,6 +682,7 @@ class CardBrowserViewModel(
// if the language has changed, the backend column labels may have changed
viewModelScope.launch {
refreshBackendColumns()
+ refreshSortState()
}
}
@@ -824,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) }
@@ -974,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()
}
@@ -1006,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
*
@@ -1454,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))
@@ -1744,70 +1784,6 @@ enum class CardBrowserProgress {
DELETING_NOTES,
}
-/**
- * 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.
- */
-class IdsFile(
- path: String,
-) : File(path),
- Parcelable {
- /**
- * @param directory parent directory of the file. Generally it should be the cache directory
- * @param ids ids to store
- */
- constructor(directory: File, ids: List, prefix: String = "ids") : this(path = createTempFile(prefix, ".tmp", directory).path) {
- DataOutputStream(FileOutputStream(this)).use { outputStream ->
- outputStream.writeInt(ids.size)
- for (id in ids) {
- outputStream.writeLong(id)
- }
- }
- }
-
- fun getIds(): List =
- DataInputStream(FileInputStream(this)).use { inputStream ->
- val size = inputStream.readInt()
- List(size) { inputStream.readLong() }
- }
-
- override fun describeContents(): Int = 0
-
- override fun writeToParcel(
- dest: Parcel,
- flags: Int,
- ) {
- dest.writeString(path)
- }
-
- companion object {
- @JvmField
- @Suppress("unused")
- val CREATOR =
- object : Parcelable.Creator {
- override fun createFromParcel(source: Parcel?): IdsFile = IdsFile(source!!.readString()!!)
-
- override fun newArray(size: Int): Array = arrayOf()
- }
- }
-}
-
-/** Attempt to delete the associated [IdsFile] and logs the result */
-fun IdsFile.removeSafely(owner: String) {
- runCatching { delete() }
- .onFailure { throwable ->
- Timber.w(
- throwable,
- "Exception when removing IdsFile of $owner",
- )
- }.onSuccess { status ->
- Timber.i(
- "$owner associated IdsFile was deleted: $status",
- )
- }
-}
-
/**
* Determines if a card can be repositioned.
*
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
new file mode 100644
index 000000000000..766d3510560a
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/IdsFile.kt
@@ -0,0 +1,93 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.browser
+
+import android.os.Parcel
+import android.os.Parcelable
+import timber.log.Timber
+import java.io.DataInputStream
+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,
+) : File(path),
+ Parcelable {
+ /**
+ * @param directory parent directory of the file. Generally it should be the cache directory
+ * @param ids ids to store
+ */
+ constructor(directory: File, ids: List, prefix: String = "ids") : this(path = createTempFile(prefix, ".tmp", directory).path) {
+ DataOutputStream(FileOutputStream(this)).use { outputStream ->
+ outputStream.writeInt(ids.size)
+ for (id in ids) {
+ outputStream.writeLong(id)
+ }
+ }
+ }
+
+ /**
+ * Reads the Ids in the file. This may be an empty list.
+ *
+ * @throws IOException if the file is missing or corrupt.
+ */
+ fun getIds(): List =
+ 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() }
+ }
+
+ override fun describeContents(): Int = 0
+
+ override fun writeToParcel(
+ dest: Parcel,
+ flags: Int,
+ ) {
+ dest.writeString(path)
+ }
+
+ companion object {
+ @JvmField
+ @Suppress("unused")
+ val CREATOR =
+ object : Parcelable.Creator {
+ override fun createFromParcel(source: Parcel?): IdsFile = IdsFile(source!!.readString()!!)
+
+ override fun newArray(size: Int): Array = arrayOf()
+ }
+ }
+}
+
+/** Attempt to delete the associated [IdsFile] and logs the result */
+fun IdsFile.removeSafely(owner: String) {
+ runCatching { delete() }
+ .onFailure { throwable ->
+ Timber.w(
+ throwable,
+ "Exception when removing IdsFile of $owner",
+ )
+ }.onSuccess { status ->
+ Timber.i(
+ "$owner associated IdsFile was deleted: $status",
+ )
+ }
+}
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/AndroidCardRenderContext.kt b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/AndroidCardRenderContext.kt
index a0f007eac77e..ad3a9b2bf769 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/AndroidCardRenderContext.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/AndroidCardRenderContext.kt
@@ -118,7 +118,7 @@ class AndroidCardRenderContext(
typeAnswer: TypeAnswer,
): AndroidCardRenderContext {
val preferences = context.sharedPrefs()
- val cardAppearance = CardAppearance.create(ReviewerCustomFonts(), preferences)
+ val cardAppearance = CardAppearance.create(ReviewerCustomFonts(), preferences, context.resources.configuration.fontScale)
val cardHtmlTemplate = CardTemplate.load(context)
val showAudioPlayButtons = !col.config.getBool(ConfigKey.Bool.HIDE_AUDIO_PLAY_BUTTONS)
return AndroidCardRenderContext(
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/CardAppearance.kt b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/CardAppearance.kt
index 32598206120e..f72fb69eebd7 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/CardAppearance.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/CardAppearance.kt
@@ -15,9 +15,15 @@ class CardAppearance(
private val cardZoom: Int,
private val imageZoom: Int,
private val centerVertically: Boolean,
+ private val fontScale: Float,
) {
/** Below could be in a better abstraction. */
fun appendCssStyle(style: StringBuilder) {
+ // Preserve the system font size while scaling text and em-based layout together.
+ if (fontScale != 1f) {
+ style.append("html { zoom: $fontScale }\n")
+ }
+
// Zoom cards
if (cardZoom != 100) {
style.append("body { zoom: ${cardZoom / 100.0} }\n")
@@ -68,11 +74,12 @@ class CardAppearance(
fun create(
customFonts: ReviewerCustomFonts,
preferences: SharedPreferences,
+ fontScale: Float,
): CardAppearance {
val cardZoom = preferences.getInt("cardZoom", 100)
val imageZoom = preferences.getInt("imageZoom", 100)
val centerVertically = preferences.getBoolean("centerVertically", false)
- return CardAppearance(customFonts, cardZoom, imageZoom, centerVertically)
+ return CardAppearance(customFonts, cardZoom, imageZoom, centerVertically, fontScale)
}
fun fixBoldStyle(content: String): String {
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/OnRenderProcessGoneDelegate.kt b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/OnRenderProcessGoneDelegate.kt
index 05fd97a54ad8..98a1eca274b6 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/OnRenderProcessGoneDelegate.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/OnRenderProcessGoneDelegate.kt
@@ -56,6 +56,13 @@ open class OnRenderProcessGoneDelegate(
Timber.e("WebView Renderer process terminated. Crashed: %b", detail.didCrash())
target.destroyWebViewFrame()
+ // #6244: finish() can invalidate the window before the lifecycle drops below STARTED.
+ if (target.isFinishing || target.isDestroyed) {
+ // don't show a dialog in this case
+ Timber.w("Activity is finishing or destroyed; skipping WebView recovery/crash dialog")
+ return true
+ }
+
// Only show one message per branch
when {
!canRecoverFromWebViewRendererCrash() -> {
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 9201f5dc2f64..64cae0c09831 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 6d6a7c7a0f92..db5a4e166632 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 24bab21cc278..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
@@ -5,6 +5,7 @@ package com.ichi2.anki.dialogs.tags
import android.app.Dialog
import android.content.Context
import android.content.DialogInterface
+import android.content.res.Configuration
import android.os.Bundle
import android.os.Parcelable
import android.text.InputFilter
@@ -12,6 +13,7 @@ import android.text.InputType
import android.text.Spanned
import android.view.MenuItem
import android.view.View
+import android.view.Window
import android.view.WindowManager
import android.widget.EditText
import android.widget.RadioGroup
@@ -37,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
@@ -63,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 {
/**
@@ -83,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
@@ -95,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,
@@ -170,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 =
@@ -250,9 +266,7 @@ class TagsDialog : AnalyticsDialogFragment {
positiveButton?.isEnabled = true
}
- dialog.window?.let {
- resizeWhenSoftInputShown(it)
- }
+ dialog.window?.updateSoftInputMode(resources.configuration)
return dialog
}
@@ -280,7 +294,7 @@ class TagsDialog : AnalyticsDialogFragment {
val tags = viewModel.tags.await()
tagsDialogListener.onSelectedTags(
tags.copyOfCheckedTagList(),
- tags.copyOfIndeterminateTagList(),
+ tags.copyOfPartiallySelectedTagList(),
selectedOption,
)
}
@@ -291,6 +305,19 @@ class TagsDialog : AnalyticsDialogFragment {
dialog?.window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM)
}
+ override fun onConfigurationChanged(newConfig: Configuration) {
+ super.onConfigurationChanged(newConfig)
+ dialog?.window?.updateSoftInputMode(newConfig)
+ }
+
+ private fun Window.updateSoftInputMode(configuration: Configuration) {
+ if (configuration.screenHeightDp < COMPACT_HEIGHT_DP) {
+ setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN)
+ } else {
+ resizeWhenSoftInputShown(this)
+ }
+ }
+
private fun radioButtonIdToCardState(id: Int) =
when (id) {
0 -> CardStateFilter.ALL_CARDS
@@ -347,7 +374,7 @@ class TagsDialog : AnalyticsDialogFragment {
val tags = viewModel.tags.await()
val didChange = tags.toggleAllCheckedStatuses()
if (didChange) {
- tagsArrayAdapter?.notifyDataSetChanged()
+ tagsArrayAdapter?.notifyCheckedStatusesChanged()
view?.showMaxTagSelectedNotice(tags)
}
}
@@ -464,13 +491,11 @@ 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"
private const val ARG_CHECKED_TAGS = "checkedTags"
+ private const val COMPACT_HEIGHT_DP = 480
/**
* The filter that constrains the inputted tag.
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 01234d4b6799..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,12 +1,13 @@
-// 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
-import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel
import com.ichi2.anki.AnkiActivity
import com.ichi2.anki.dialogs.ExportReadyDialog
+import com.ichi2.anki.utils.ViewModelSavedStateHandle
+import com.ichi2.anki.utils.savedStateViewModelFactory
import kotlinx.coroutines.flow.StateFlow
import kotlinx.parcelize.Parcelize
import timber.log.Timber
@@ -18,7 +19,7 @@ import timber.log.Timber
* @see AnkiActivity
*/
class ExportReadyViewModel(
- private val savedStateHandle: SavedStateHandle,
+ private val savedStateHandle: ViewModelSavedStateHandle,
) : ViewModel() {
val exportReadyDestination: StateFlow
field = savedStateHandle.getMutableStateFlow(ARG_EXPORT_READY_PARAMS, null)
@@ -41,6 +42,8 @@ class ExportReadyViewModel(
) : Parcelable
companion object {
+ val factory = savedStateViewModelFactory(create = ::ExportReadyViewModel)
+
private const val ARG_EXPORT_READY_PARAMS = "arg_export_ready_params"
}
}
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 001cbc7ed6cf..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,11 +30,11 @@ 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
import timber.log.Timber
+import java.util.concurrent.atomic.AtomicInteger
@NeedsTest("15130: pressing back: icon + button should return to options if the manual is open")
@NeedsTest("17905: pressing back before the webpage is ready closes the screen")
@@ -46,6 +46,7 @@ class DeckOptions : PageFragment() {
"deck-options/$deckId"
}
private var webViewIsReady = false
+ private val saveAndOptimizeReload = SaveAndOptimizeReload()
/**
* Callback enabled when the manual is opened in the deck options.
@@ -164,6 +165,7 @@ class DeckOptions : PageFragment() {
override fun onWebViewCreated() {
// addJavascriptInterface needs to happen before loadUrl
webViewLayout.addJavascriptInterface(ModalJavaScriptInterfaceListener(), "ankidroid")
+ webViewLayout.addJavascriptInterface(saveAndOptimizeReload, "ankidroidSave")
Timber.d("Added JS Interface: 'ankidroid")
}
@@ -175,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
@@ -187,14 +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)
- // Allow JavaScript to reload this page, including when night mode adds a fragment.
- if (request.url.toString().substringBefore('#') == view?.url?.substringBefore('#')) return false
- return if (ankiManualHostRegex.matches(host)) {
- super.shouldOverrideUrlLoading(view, request)
+ val url = request?.url ?: return true
+ if (
+ request.isForMainFrame &&
+ url.toString().substringBefore('#') == view?.url?.substringBefore('#') &&
+ saveAndOptimizeReload.deferReloadIfSaving()
+ ) {
+ return true
+ }
+ return if (request.isForMainFrame && url.scheme == "https" && url.encodedAuthority == "docs.ankiweb.net") {
+ false
} else {
- openUrl(request.url)
- true
+ super.shouldOverrideUrlLoading(view, request)
}
}
}.apply {
@@ -278,29 +282,118 @@ class DeckOptions : PageFragment() {
webViewIsReady = true
webViewLayout.isVisible = true
pageLoadingIndicator.isVisible = false
- trackSaveCompletion()
+ saveAndOptimizeReload.onPageReady()
setParameterUnlockClickTimeout()
}
- /** Track receipt of the full save response, so reloading cannot abort it. */
- private fun trackSaveCompletion() {
- webViewLayout.evaluateJavascript(
- """
- (() => {
- const originalFetch = window.fetch;
- window.fetch = (input, init) => {
- const response = originalFetch(input, init);
- if (input !== "/_anki/updateDeckConfigs") return response;
- const completed = response.then(async (response) => {
- await response.clone().arrayBuffer();
+ /** Request a reload after Save & Optimize's response has reached the page. Called on main. */
+ fun reloadAfterSave() {
+ saveAndOptimizeReload.requestReload()
+ }
+
+ /**
+ * AnkiDroid workaround for the frontend reloading before its save response has been read.
+ *
+ * 1. The fetch wrapper calls [started] before the frontend can request a reload.
+ * 2. [requestReload] schedules navigation; [deferReloadIfSaving] holds premature frontend reloads.
+ * 3. Reading the response body calls [finished]: success releases the reload; failure cancels it.
+ *
+ * Remove this when the bundled frontend awaits state.save() before reloading.
+ */
+ inner class SaveAndOptimizeReload {
+ // JavaScript bridge calls run on a WebView thread; navigation callbacks run on main.
+ private val pendingSaves = AtomicInteger()
+ private var reloadRequested = false // Accessed only on the main thread.
+
+ fun onPageReady() {
+ pendingSaves.set(0)
+ reloadRequested = false
+ trackSaveResponse()
+ }
+
+ @JavascriptInterface
+ fun started() {
+ // Do this synchronously: posting to main could let the reload arrive first.
+ pendingSaves.incrementAndGet()
+ }
+
+ /** Called on main. Returning true keeps the current document and its request alive. */
+ fun deferReloadIfSaving(): Boolean {
+ if (pendingSaves.get() == 0) return false
+ requestReload()
+ return true
+ }
+
+ fun requestReload() {
+ reloadRequested = true
+ }
+
+ @JavascriptInterface
+ fun finished(success: Boolean) {
+ pendingSaves.decrementAndGet()
+ launchCatchingTask {
+ if (!success) {
+ // Keep the edited form on screen so the user can retry.
+ reloadRequested = false
+ return@launchCatchingTask
+ }
+ if (pendingSaves.get() != 0 || !reloadRequested || view == null) return@launchCatchingTask
+
+ reloadRequested = false
+ webViewLayout.reload()
+ }
+ }
+
+ /**
+ * Tracks the HTTP body read, not completion of frontend decoding or state.save().
+ *
+ * The native handler finishes saving before returning its response. Waiting for blob()
+ * also avoids interrupting the frontend's network body read when we reload. Subsequent
+ * decoding is not awaited: the new page reads the saved settings from the backend.
+ */
+ private fun trackSaveResponse() {
+ webViewLayout.evaluateJavascript(
+ """
+ (() => {
+ const originalFetch = window.fetch;
+
+ async function readSaveBody(readBlob) {
+ try {
+ const body = await readBlob();
+ ankidroidSave.finished(true);
+ return body;
+ } catch (error) {
+ ankidroidSave.finished(false);
+ throw error; // Leave error reporting to the frontend.
+ }
+ }
+
+ window.fetch = async (input, init) => {
+ if (input !== "/_anki/updateDeckConfigs") return originalFetch(input, init);
+ ankidroidSave.started();
+
+ let response;
+ try {
+ response = await originalFetch(input, init);
+ } catch (error) {
+ ankidroidSave.finished(false);
+ throw error;
+ }
+ if (!response.ok) {
+ ankidroidSave.finished(false);
+ return response;
+ }
+
+ // fetch() only waits for headers. Anki's postProto reads the body with
+ // blob(); keep navigation blocked until that read actually completes.
+ const readBlob = response.blob.bind(response);
+ response.blob = () => readSaveBody(readBlob);
return response;
- });
- anki.deckOptionsSaveCompleted = completed;
- return completed;
- };
- })();
- """.trimIndent(),
- )
+ };
+ })();
+ """.trimIndent(),
+ )
+ }
}
/**
@@ -372,11 +465,9 @@ suspend fun FragmentActivity.updateDeckConfigsRaw(input: ByteArray): ByteArray {
undoableOp { OpChanges.parseFrom(output) }
withContext(Dispatchers.Main) {
if (UpdateDeckConfigsRequest.parseFrom(input).mode == UpdateDeckConfigsMode.UPDATE_DECK_CONFIGS_MODE_COMPUTE_ALL_PARAMS) {
- // This HTTP request has not returned to the page yet. Wait for its response body
- // before reloading, otherwise fetch can fail and display an alert that blocks navigation.
- requireDeckOptionsFragment().webViewLayout.evaluateJavascript(
- "anki.deckOptionsSaveCompleted.then(() => window.location.reload())",
- )
+ // Anki 26.05 relies on us to request a reload; 26.09 also requests one from JavaScript.
+ // Both requests share a flag, so the page reloads once, after reading this response.
+ requireDeckOptionsFragment().reloadAfterSave()
} else {
finish()
}
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 6b62e880ac0e..2863e31edd7f 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 f9192b41de47..805c6fc4a939 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerFragment.kt
@@ -191,6 +191,8 @@ abstract class CardViewerFragment(
scrollBars = View.SCROLLBARS_OUTSIDE_OVERLAY
setAcceptThirdPartyCookies(true)
with(settings) {
+ // Scale the whole card with CSS instead; text-only zoom breaks MathJax layout.
+ textZoom = 100
javaScriptEnabled = true
loadWithOverviewMode = true
builtInZoomControls = true
@@ -359,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 34bd7a843c03..bb6b7f22c2d9 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerHelpers.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerHelpers.kt
@@ -40,12 +40,13 @@ fun stdHtml(
val canvasColor = ThemeUtils.getThemeAttrColor(context, android.R.attr.colorBackground).toRGBHex()
val fgColor = ThemeUtils.getThemeAttrColor(context, android.R.attr.textColor).toRGBHex()
val colors = ":root$rootNightMode { --canvas: $canvasColor; --fg: $fgColor; }"
+ // Preserve the system font size while scaling text and em-based layout together.
+ val fontScale = context.resources.configuration.fontScale
+ val fontScaleCss = if (fontScale != 1f) "html { zoom: $fontScale }" else ""
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
@@ -65,6 +66,7 @@ fun stdHtml(
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/snackbar/Snackbars.kt b/AnkiDroid/src/main/java/com/ichi2/anki/snackbar/Snackbars.kt
index 554c90f27e21..bb9e1f3f323c 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/snackbar/Snackbars.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/snackbar/Snackbars.kt
@@ -227,10 +227,49 @@ fun View.showSnackbar(
snackbar.anchorView = null
}
+ snackbar.retainUntilShown(rootView)
snackbar.show()
return snackbar
}
+/**
+ * Keeps the latest pending snackbar alive on [host] until shown or dismissed.
+ *
+ * Workaround to fix losing a pending snackbar after a GC (#22076).
+ *
+ * See [SnackbarManager.show and SnackbarRecord](https://github.com/material-components/material-components-android/blob/1.14.0/lib/java/com/google/android/material/snackbar/SnackbarManager.java).
+ */
+private fun Snackbar.retainUntilShown(host: View) {
+ host.retainPendingSnackbar(this)
+ addCallback(
+ object : Snackbar.Callback() {
+ override fun onShown(snackbar: Snackbar) = release(snackbar)
+
+ override fun onDismissed(
+ snackbar: Snackbar,
+ event: Int,
+ ) = release(snackbar)
+
+ private fun release(snackbar: Snackbar) {
+ host.releasePendingSnackbar(snackbar)
+ snackbar.removeCallback(this)
+ }
+ },
+ )
+}
+
+/** Stores a strong reference to [snackbar] on this view, replacing any previously retained snackbar. */
+private fun View.retainPendingSnackbar(snackbar: Snackbar) {
+ setTag(R.id.pending_snackbar, snackbar)
+}
+
+/** Releases the strong reference to [snackbar] only if it is still the retained snackbar. */
+private fun View.releasePendingSnackbar(snackbar: Snackbar) {
+ // A callback from an older snackbar must not release its replacement.
+ if (getTag(R.id.pending_snackbar) !== snackbar) return
+ setTag(R.id.pending_snackbar, null)
+}
+
/**
* Show a snackbar.
*
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/tags/ManageTagsViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt
index 46ba49b5e8c6..31d528a4b9a4 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt
@@ -12,12 +12,13 @@ import com.ichi2.anki.tags.ManageTagsState.Error
import com.ichi2.anki.tags.UserMessage.ClearedUnusedTags
import com.ichi2.anki.tags.UserMessage.TagRemoved
import com.ichi2.anki.tags.UserMessage.TagRenamed
+import com.ichi2.anki.utils.MessageQueue
+import com.ichi2.anki.utils.MessageQueue.MessageId
import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Job
-import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.receiveAsFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import timber.log.Timber
@@ -34,18 +35,26 @@ import anki.tags.TagTreeNode as BackendTagTreeNode
* @see Tags for backend functions.
* @see TagListItemState for display.
* @see ManageTagsState for UI state.
- * @see events for one-shot events.
+ * @see pendingMessages for messages awaiting display.
*/
class ManageTagsViewModel : ViewModel() {
val state: StateFlow
field = MutableStateFlow(ManageTagsState.Loading)
- private val _events = Channel(Channel.BUFFERED)
- val events = _events.receiveAsFlow()
+ /** Search field contents, available even while tags are loading or failed to load. */
+ val searchQuery: StateFlow
+ field = MutableStateFlow("")
+
+ private val messageQueue = MessageQueue()
+
+ /** Messages in display order. Collecting does not consume them; acknowledge with [messageShown]. */
+ val pendingMessages = messageQueue.messages
/** Cached flat list of all tags, rebuilt when the backend tree changes */
private var tagList: List = emptyList()
+ private var tagOperation: Job? = null
+
init {
refreshTags()
}
@@ -63,15 +72,17 @@ class ManageTagsViewModel : ViewModel() {
* of having a filtered tag selected.
*/
fun filter(query: String) {
- if (tagList.isEmpty()) return
+ searchQuery.value = query
updateState { loaded ->
loaded.copy(
- searchQuery = query,
visibleNodes = computeVisibleNodes(query),
)
}
}
+ /** Acknowledges that the UI has finished displaying a message. */
+ fun messageShown(id: MessageId) = messageQueue.acknowledge(id)
+
/** Returns the visible node for [tag], or null with a warning if not found or not loaded */
private fun findVisibleNode(tag: TagName): TagListItemState? {
val loaded = state.value as? ManageTagsState.Content
@@ -95,27 +106,20 @@ class ManageTagsViewModel : ViewModel() {
* @see Tags.setCollapsed
*/
fun toggleCollapsed(tag: TagName) =
- viewModelScope.launch {
- try {
- val node =
- findVisibleNode(tag) ?: run {
- _events.send(DisplayMessage(UserMessage.UnexpectedError))
- return@launch
- }
- val newCollapsed = !node.collapsed
- withCol { tags.setCollapsed(tag, newCollapsed) }
- // Update the in-memory list with the new collapsed state
- tagList =
- tagList.map {
- if (it.fullTag == tag) it.copy(collapsed = newCollapsed) else it
- }
- updateState { it.copy(visibleNodes = computeVisibleNodes(it.searchQuery)) }
- } catch (e: CancellationException) {
- throw e
- } catch (e: Exception) {
- Timber.w(e, "Failed to toggle collapsed state")
- state.value = Error(e)
- }
+ launchTagOperation {
+ val node =
+ findVisibleNode(tag) ?: run {
+ messageQueue.enqueue(UserMessage.UnexpectedError)
+ return@launchTagOperation
+ }
+ val newCollapsed = !node.collapsed
+ withCol { tags.setCollapsed(tag, newCollapsed) }
+ // Update the in-memory list with the new collapsed state
+ tagList =
+ tagList.map {
+ if (it.fullTag == tag) it.copy(collapsed = newCollapsed) else it
+ }
+ updateState { it.copy(visibleNodes = computeVisibleNodes(searchQuery.value)) }
}
/**
@@ -125,7 +129,7 @@ class ManageTagsViewModel : ViewModel() {
fun removeTag(tag: TagName) =
launchTagOpAndRefresh {
val result = undoableOp { tags.remove(tag) }
- _events.send(DisplayMessage(TagRemoved(result.count)))
+ messageQueue.enqueue(TagRemoved(result.count))
}
/**
@@ -137,47 +141,78 @@ class ManageTagsViewModel : ViewModel() {
newName: TagName,
) = launchTagOpAndRefresh {
val result = undoableOp { tags.rename(oldName, newName) }
- _events.send(DisplayMessage(TagRenamed(result.count)))
+ messageQueue.enqueue(TagRenamed(result.count))
}
/**
- * Removes tags not present on any note. Emits a [ClearedUnusedTags] event with the count.
+ * Removes tags not present on any note. Queues a [ClearedUnusedTags] message with the count.
* @see Tags.clearUnusedTags
*/
fun clearUnusedTags() =
launchTagOpAndRefresh {
val result = undoableOp { tags.clearUnusedTags() }
Timber.i("Deleted %d unused tags", result.count)
- _events.send(DisplayMessage(ClearedUnusedTags(result.count)))
+ messageQueue.enqueue(ClearedUnusedTags(result.count))
}
- private suspend fun loadTags(searchQuery: String) {
+ private suspend fun loadTags() {
Timber.i("Loading tags from collection")
tagList = flattenTree(withCol { tags.tree() })
state.value =
ManageTagsState.Content(
- visibleNodes = computeVisibleNodes(searchQuery),
- searchQuery = searchQuery,
+ visibleNodes = computeVisibleNodes(searchQuery.value),
)
}
/**
- * Sets [ManageTagsState.Loading], runs [block], then [reloads][loadTags].
- * On failure, transitions to [Error]
+ * Runs [block], then [reloads][loadTags], keeping any existing content visible.
+ */
+ private fun launchTagOpAndRefresh(block: suspend () -> Unit = { }): Job =
+ launchTagOperation {
+ block()
+ loadTags()
+ }
+
+ /** Starts an operation, or returns the unfinished operation's job without starting another. */
+ private fun launchTagOperation(block: suspend () -> Unit): Job {
+ val currentOperation = tagOperation
+ if (currentOperation != null && !currentOperation.isCompleted) {
+ return currentOperation
+ }
+
+ // set 'tagOperation' before executing
+ val operation = viewModelScope.launch(start = CoroutineStart.LAZY) { runTagOperation(block) }
+ tagOperation = operation
+ // calls from observers will now find this job
+ operation.start()
+ return operation
+ }
+
+ /**
+ * Updates progress and errors while keeping existing content visible.
+ * Uses [ManageTagsState.Loading] and [Error] only when no content is available.
*/
- private fun launchTagOpAndRefresh(block: suspend () -> Unit = { }): Job {
- val previousLoaded = state.value as? ManageTagsState.Content
- state.value = ManageTagsState.Loading
- return viewModelScope.launch {
- try {
- block()
- loadTags(searchQuery = previousLoaded?.searchQuery ?: "")
- } catch (e: CancellationException) {
- throw e
- } catch (e: Exception) {
- Timber.w(e, "launchTagOperation failed")
- state.value = Error(e)
+ private suspend fun runTagOperation(block: suspend () -> Unit) {
+ state.update { current ->
+ when (current) {
+ is ManageTagsState.Content -> current.copy(isWorking = true, error = null)
+ is ManageTagsState.Loading, is Error -> ManageTagsState.Loading
+ }
+ }
+ try {
+ block()
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ Timber.w(e, "Tag operation failed")
+ state.update { current ->
+ when (current) {
+ is ManageTagsState.Content -> current.copy(isWorking = false, error = e)
+ is ManageTagsState.Loading, is Error -> Error(e)
+ }
}
+ } finally {
+ updateState { it.copy(isWorking = false) }
}
}
@@ -195,10 +230,7 @@ class ManageTagsViewModel : ViewModel() {
state.update { current ->
when (current) {
is ManageTagsState.Content -> transform(current)
- else -> {
- Timber.w("updateState called while in %s; ignoring", current::class.simpleName)
- current
- }
+ else -> current
}
}
}
@@ -319,7 +351,10 @@ sealed class ManageTagsState {
data class Content(
val visibleNodes: List,
- val searchQuery: String = "",
+ /** `true` while a tag operation runs, allowing the UI to show progress without hiding the list. */
+ val isWorking: Boolean = false,
+ /** Most recent operation failure, cleared when the next operation starts. */
+ val error: Throwable? = null,
) : ManageTagsState()
data class Error(
@@ -327,10 +362,6 @@ sealed class ManageTagsState {
) : ManageTagsState()
}
-data class DisplayMessage(
- val message: UserMessage,
-)
-
sealed interface UserMessage {
/** @param notesAffected number of notes the tag was removed from */
data class TagRemoved(
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/MessageQueue.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/MessageQueue.kt
new file mode 100644
index 000000000000..b93b0db1d074
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/MessageQueue.kt
@@ -0,0 +1,49 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.utils
+
+import androidx.annotation.MainThread
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.update
+
+/**
+ * Keeps ViewModel messages pending until the UI has finished displaying them.
+ *
+ * e.g. Rotating the screen can remove a snackbar immediately after it's displayed.
+ *
+ * A UI should display [messages] in enqueue order, then call [acknowledge] after each message is handled.
+ *
+ * @see [Android UI event guidance](https://developer.android.com/topic/architecture/ui-layer/events#consuming-events)
+ */
+class MessageQueue {
+ val messages: StateFlow>>
+ field = MutableStateFlow>>(emptyList())
+
+ private var nextMessageId = MessageId(0)
+
+ @MainThread
+ fun enqueue(message: T) {
+ val pending = Message(id = nextMessageId++, message = message)
+ messages.update { it + pending }
+ }
+
+ /** Removes only the acknowledged message. Repeated or stale acknowledgements are harmless. */
+ @MainThread
+ fun acknowledge(id: MessageId) {
+ messages.update { messages -> messages.filterNot { it.id == id } }
+ }
+
+ /** [id] distinguishes messages with identical contents within this queue. */
+ data class Message(
+ val id: MessageId,
+ val message: T,
+ )
+
+ @JvmInline
+ value class MessageId(
+ val value: Int,
+ ) {
+ operator fun inc() = MessageId(value + 1)
+ }
+}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/OnlyOnce.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/OnlyOnce.kt
index d399d17510d2..8f71752d7a4a 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/utils/OnlyOnce.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/OnlyOnce.kt
@@ -2,6 +2,7 @@
package com.ichi2.anki.utils
+import androidx.annotation.VisibleForTesting
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
@@ -11,13 +12,17 @@ import timber.log.Timber
* Prevent multiple instances of a method being executed simultaneously
*/
object OnlyOnce {
- private val blockedFunctions = mutableSetOf()
+ private val blockedFunctions = mutableSetOf()
enum class Method {
ANSWER_CARD,
UNIT_TEST,
}
+ @get:VisibleForTesting
+ internal val pendingMethods: Set
+ get() = blockedFunctions.toSet()
+
/**
* Prevents multiple instances of a method being executed simultaneously
*
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/SavedStateViewModelFactory.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/SavedStateViewModelFactory.kt
new file mode 100644
index 000000000000..75e586a10a84
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/SavedStateViewModelFactory.kt
@@ -0,0 +1,52 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.utils
+
+import android.os.Bundle
+import androidx.lifecycle.DEFAULT_ARGS_KEY
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.ViewModelProvider
+import androidx.lifecycle.createSavedStateHandle
+import androidx.lifecycle.viewmodel.MutableCreationExtras
+import androidx.lifecycle.viewmodel.initializer
+import androidx.lifecycle.viewmodel.viewModelFactory
+
+/**
+ * Avoids duplicating large launch arguments in saved instance state, which can cause
+ * [android.os.TransactionTooLargeException] when the activity stops.
+ *
+ * AndroidX normally copies all intent extras or fragment arguments into each ViewModel's saved
+ * state. This factory creates a new [ViewModelSavedStateHandle] (or returns one which restores
+ * the previously saved state).
+ *
+ * Pass launch inputs directly to the ViewModel constructor & use ViewModel state as normal.
+ *
+ * ```kt
+ * class EditorViewModel(
+ * state: ViewModelSavedStateHandle,
+ * val noteId: NoteId,
+ * ) : ViewModel() {
+ * val selectedField = state.getMutableStateFlow("selectedField", 0)
+ * }
+ *
+ * // In a Fragment:
+ * private val viewModel by viewModels {
+ * savedStateViewModelFactory { state ->
+ * EditorViewModel(state, requireArguments().getLong("noteId"))
+ * }
+ * }
+ * ```
+ *
+ * Here, `noteId` comes from the fragment's arguments without being copied into the handle.
+ * Edits to `selectedField.value` are saved and restored after process death.
+ */
+inline fun savedStateViewModelFactory(
+ crossinline create: (ViewModelSavedStateHandle) -> VM,
+): ViewModelProvider.Factory =
+ viewModelFactory {
+ initializer {
+ val extras = MutableCreationExtras(this)
+ extras[DEFAULT_ARGS_KEY] = Bundle.EMPTY
+ create(extras.createSavedStateHandle())
+ }
+ }
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ViewModelSavedStateHandle.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ViewModelSavedStateHandle.kt
new file mode 100644
index 000000000000..a698c21d87fc
--- /dev/null
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ViewModelSavedStateHandle.kt
@@ -0,0 +1,12 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.utils
+
+import androidx.lifecycle.SavedStateHandle
+
+/**
+ * A [SavedStateHandle] created without automatically copying intent extras or fragment arguments.
+ *
+ * @see savedStateViewModelFactory
+ */
+typealias ViewModelSavedStateHandle = SavedStateHandle
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 ddf554c25035..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,10 +32,11 @@ 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.CoroutineScope
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import timber.log.Timber
@@ -81,7 +81,7 @@ class SyncWorker(
inputData.getString(ENDPOINT_KEY)?.let {
endpoint = it
}
- }
+ }.let(::SyncAuth)
val shouldSyncMedia = inputData.getBoolean(SYNC_MEDIA_KEY, false)
try {
@@ -116,29 +116,31 @@ class SyncWorker(
syncMedia: Boolean,
) {
Timber.v("SyncWorker::syncCollection")
- val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
- val monitor =
- scope.launch {
- val backend = CollectionManager.getBackend()
- var syncProgress: Progress.NormalSync? = null
- while (true) {
- val progress = backend.latestProgress() // avoid sending repeated notifications
- if (progress.hasNormalSync() && syncProgress != progress.normalSync) {
- syncProgress = progress.normalSync
- val text = syncProgress.run { "$added\n$removed" }
- notify(getProgressNotification(text))
- }
- delay(SyncMediaWorker.NOTIFICATION_UPDATE_RATE_MS)
- }
- }
val response =
- try {
- withCol {
- syncCollection(auth, syncMedia = false)
+ coroutineScope {
+ val monitor =
+ launch(Dispatchers.IO) {
+ val backend = CollectionManager.getBackend()
+ var syncProgress: Progress.NormalSync? = null
+ while (true) {
+ val progress = backend.latestProgress() // avoid sending repeated notifications
+ if (progress.hasNormalSync() && syncProgress != progress.normalSync) {
+ syncProgress = progress.normalSync
+ val text = syncProgress.run { "$added\n$removed" }
+ notify(getProgressNotification(text))
+ }
+ delay(SyncMediaWorker.NOTIFICATION_UPDATE_RATE_MS)
+ }
+ }
+ try {
+ withCol {
+ syncCollection(auth, syncMedia = false)
+ }
+ } finally {
+ Timber.d("Collection sync completed. Cancelling monitor...")
+ // this doesn't wait for completion; the outer `coroutineScope` does.
+ monitor.cancel()
}
- } finally {
- Timber.d("Collection sync completed. Cancelling monitor...")
- monitor.cancel()
}
Timber.i("Sync required: %s", response.required)
when (response.required) {
@@ -149,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 @@
+ android:padding="24dp">
+ android:paddingBottom="16dp"/>
+ android:layout_height="wrap_content"/>
+ android:layout_height="wrap_content"/>
+ android:layout_height="wrap_content"/>
diff --git a/AnkiDroid/src/main/res/layout/dialog_note_editor_toolbar_edit_custom_item.xml b/AnkiDroid/src/main/res/layout/dialog_note_editor_toolbar_edit_custom_item.xml
index 703128b63690..a874b3bef807 100644
--- a/AnkiDroid/src/main/res/layout/dialog_note_editor_toolbar_edit_custom_item.xml
+++ b/AnkiDroid/src/main/res/layout/dialog_note_editor_toolbar_edit_custom_item.xml
@@ -8,7 +8,7 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
- android:padding="16dp">
+ android:padding="24dp">
+ android:paddingBottom="16dp"/>
+ android:layout_height="wrap_content"/>
@@ -80,25 +80,25 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
+ android:layout_marginTop="8dp"
android:hint="@string/before_text">
+ android:layout_height="wrap_content"/>
+ android:layout_height="wrap_content"/>
diff --git a/AnkiDroid/src/main/res/layout/dialog_tags.xml b/AnkiDroid/src/main/res/layout/dialog_tags.xml
index 7625d3248b43..4763b4e9292e 100644
--- a/AnkiDroid/src/main/res/layout/dialog_tags.xml
+++ b/AnkiDroid/src/main/res/layout/dialog_tags.xml
@@ -7,14 +7,6 @@
-
-
-
+ android:layout_below="@id/toolbar">
+
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
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 item
Voer 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 verwyder
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
Die beeld is te groot, voeg asseblief die beeld handmatig in
Die videolêer is te groot, voeg asseblief die video handmatig in
Die klanklêer is te groot, voeg asseblief die klank handmatig in
@@ -270,6 +272,11 @@
Import deck
Something went wrong, please try again
Aflaai het misluk
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
Jy kan ander toepassings gebruik terwyl die aflaai loop
Kontroleer asseblief jou netwerkverbinding
Aflaai 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 @@
About
Contributors
contributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>
License
GPL-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 item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
@@ -270,6 +272,11 @@
Import deck
Something went wrong, please try again
Download failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
You can use other apps while the download is running
Please check your network connection
Download 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 @@
About
Contributors
contributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>
License
GPL-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 item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
@@ -270,6 +272,11 @@
Import deck
Something went wrong, please try again
Download failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
You can use other apps while the download is running
Please check your network connection
Download 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 @@
About
Contributors
contributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>
License
GPL-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 item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The 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 item
Entreu 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-hp
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
La imatge és massa gran per enganxar-la. Inseriu-la manualment si us plau
El vídeo és massa gran per enganxar-lo. Inseriu-lo manualment si us plau
L\'àudio és massa gran per enganxar-lo. Inseriu-lo manualment si us plau
@@ -270,6 +272,11 @@
Import deck
Alguna cosa ha anat malament. Torneu a provar-ho si us plau
Error al baixar
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
Es poden usar altres aplicacions mentre es fa la baixada
Comproveu la vostra connexió de xarxa si us plau
Download 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 a
Contribuïdors
col·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ència
GPL-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 item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
@@ -270,6 +272,11 @@
Import deck
Something went wrong, please try again
Download failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
You can use other apps while the download is running
Please check your network connection
Download 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 @@
About
Contributors
contributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>
License
GPL-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šty
Zadejte HTML, které se vloží před a za vybraný text\n\nDlouze stiskněte položku nástrojové lišty, abyste ji upravili nebo odstranili
Odstranit položku nástrojové lišty?
+ Button text is required
+ At least one HTML field is required
Obrá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íček
Něco se pokazilo, zkuste to prosím znovu
Stahování se nezdařilo
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
Během stahování můžete používat jiné aplikace
Zkontrolujte prosím připojení k síti
Stahová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 aplikaci
Př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!]]>
Licence
GPL-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 item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
@@ -270,6 +272,11 @@
Import deck
Something went wrong, please try again
Download failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
You can use other apps while the download is running
Please check your network connection
Download 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 @@
Om
Contributors
bidragsydere. 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!]]>
License
GPL-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 bearbeiten
Geben 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 entfernen
Werkzeugleisten Element entfernen?
+ Button text is required
+ At least one HTML field is required
Das Bild ist zu groß, bitte fügen Sie das Bild manuell ein
Die Videodatei ist zu groß, bitte fügen Sie das Video manuell ein
Die Audiodatei ist zu groß, bitte fügen Sie das Audio manuell ein
@@ -270,6 +272,11 @@
Deck importieren
Ein Fehler ist aufgetreten, bitte versuchen Sie es erneut
Herunterladen gescheitert
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
Während des Herunterladens können Sie andere Apps verwenden
Bitte überprüfen Sie die Netzverbindung
Download 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 @@
Über
Mitwirkende
Mitwirkenden. Sie können AnkiDroid auch durch programmieren, übersetzen, spenden und mehr helfen!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>
Lizenz
GPL-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 ilarstrio
Enigu 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 required
La bildo estas tro granda, enmetu la bildon permane
La filmeto estas tro granda enmetu la filmeton permane
La sonaĵo estas tro granda, enmetu la sonaĵon permane
@@ -270,6 +272,11 @@
Import deck
Problemo okazis, reprovu
Elŝuto malsukcesis
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
Vi povas uzi aliajn aplikaĵojn kiam la dosiero(j) estas elŝutata(j)
Kontrolu vian retkonekton
Elŝ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 @@
Pri
Kontribuantoj
kontribuantoj. Vi ankaŭ povas helpi al AnkiDroid per programi, traduki, donaci kaj pli!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>
Permesilo
GPL-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 item
Introduce HTML para insertarse antes y después del texto seleccionado\n\nManten presionado un elemento de la barra de herramientas para eliminarlo
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
La imagen es demasiado grande, por favor inserta la imagen manualmente
El archivo de vídeo es demasiado grande, por favor inserta el vídeo manualmente
El archivo de audio es demasiado grande, por favor inserta el audio manualmente
@@ -270,6 +272,11 @@
Import deck
Ha ocurrido un error, por favor inténtalo de nuevo
Descarga fallida
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
Puedes usar otras aplicaciones mientras se ejecuta la descarga
Por favor revisa tu conexión de red
Descarga 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 de
Colaboradores
colaboradores. ¡También puedes ayudar a AnkiDroid programando, traduciendo, donando y más!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>
Licencia
GPL-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 item
Introduce HTML para insertarse antes y después del texto seleccionado\n\nManten presionado un elemento de la barra de herramientas para eliminarlo
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
La imagen es demasiado grande, por favor inserta la imagen manualmente
El archivo de vídeo es demasiado grande, por favor inserta el vídeo manualmente
El archivo de audio es demasiado grande, por favor inserta el audio manualmente
@@ -270,6 +272,11 @@
Import deck
Ha ocurrido un error, por favor inténtalo de nuevo
Descarga fallida
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
Puedes usar otras aplicaciones mientras se ejecuta la descarga
Por favor revisa tu conexión de red
Descarga 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 de
Colaboradores
colaboradores. ¡También puedes ayudar a AnkiDroid programando, traduciendo, donando y más!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>
Licencia
GPL-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 kirjet
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Kas eemaldada tööriista kirje?
+ Button text is required
+ At least one HTML field is required
Pildifail on liiga suur. Palun sisesta pilt käsitsi
Videofail on liiga suur. Palun sisesta video käsitsi
Helifail on liiga suur. Palun sisesta heli käsitsi
@@ -270,6 +272,11 @@
Impordi kaardipakk
Midagi läks valesti. Palun proovi uuesti
Allalaadimine ebaõnnestus
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
You can use other apps while the download is running
Palun kontrolli oma internetiühendust
Allalaadimine 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 @@
Teave
Panustajad
kaastöö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!]]>
Litsents
GPL-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 item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
@@ -270,6 +272,11 @@
Import deck
Something went wrong, please try again
Download failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
You can use other apps while the download is running
Please check your network connection
Download 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 @@
About
Contributors
contributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>
License
GPL-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 item
Kirjoita HTML, joka lisätään ennen ja jälkeen valitun tekstin\n\nPaina työkalupalkin kohdetta pitkään muokataksesi sitä tai poistaaksesi sen
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
Kuva on liian suuri, aseta kuva käsin
Videotiedosto on liian suuri, aseta video käsin
Äänitiedosto on liian suuri, aseta ääni käsin
@@ -270,6 +272,11 @@
Import deck
Jokin meni pieleen, yritä uudelleen
Lataus epäonnistui
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
Voit käyttää muita sovelluksia latauksen aikana
Tarkista verkkoyhteytesi
Lataus 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 @@
Tietoja
Kehittäjät ja avustajat
kehittä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!]]>
Lisenssi
GPL-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 item
Magtala ng HTML na ilalagay bago at pagkatapos ng piniling teksto\n\nIdiin ang toolbar item upang baguhin o tanggalin ito
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
Ang larawan ay masyadong malaki, pakilagay ang larawan nang mano-mano
Ang video file ay masyadong malaki, pakilagay ang video nang mano-mano
Ang audio file ay masyadong malaki, pakilagay ang audio nang mano-mano
@@ -270,6 +272,11 @@
Import deck
Mayroong nangyaring mali. Mangyaring subukan muli
Bigo ang pag-download
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
Maari kang kumamit ng iba pang apps habang tumatakbo ang download
Mangyaring 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 Amin
Mga Nag-Ambag
nag-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!]]>
Lisensya
GPL-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’outils
Entrez 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 supprimer
Supprimer l\'élément de la barre d\'outils ?
+ Button text is required
+ At least one HTML field is required
L’image est trop grande, veuillez insérer l’image manuellement
Le 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 paquet
Quelque chose s\'est mal passé, veuillez réessayer
Le téléchargement a échoué
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
Vous pouvez utiliser d\'autres applications pendant que le téléchargement est en cours d\'exécution
Veuillez vérifier votre connexion réseau
Té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 @@
À propos
Contributeurs
contributeurs géniaux. Vous pouvez aussi aider AnkiDroid en programmant, traduisant, donnant et plus !]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>
Licence
GPL-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 item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
@@ -270,6 +272,11 @@
Import deck
Something went wrong, please try again
Download failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
You can use other apps while the download is running
Please check your network connection
Download 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 @@
About
Contributors
contributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>
License
GPL-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 item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
@@ -279,6 +281,11 @@
Import deck
Something went wrong, please try again
Download failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
You can use other apps while the download is running
Please check your network connection
Download 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 @@
About
Contributors
contributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>
License
GPL-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 ferramentas
Insire o HTML para introducir antes e despois do texto seleccionado\n\nMantén premido un botón da barra de ferramentas para editalo ou eliminalo
Eliminar elemento da barra de ferramentas?
+ Button text is required
+ At least one HTML field is required
A imaxe é moi grande. Insire a imaxe manualmente
O ficheiro de vídeo é moi grande. Insire o vídeo manualmente
O ficheiro de son é moi grande. Insire o son manualmente
@@ -270,6 +272,11 @@
Importar baralla
Algo saíu mal. Téntao de novo
Fallo ao descargar
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
Podes utilizar outras aplicacións mentres a descarga está en curso
Comproba a túa conexión de rede
Descarga 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 en
Repasar 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 cheo
Almacenamento 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 engadidas
Eliminando 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 @@
Sobre
Contribuíntes
contribuí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!]]>
Licenza
GPL-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 resposta
Pantalla
Also assign to ‘%1$s’?
- Skip
- Assign
+ Omitir
+ Atribuír
Abrir configuración
Configuració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 item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
𐍆𐍂𐌹𐍃𐌰𐌷𐍄𐍃 𐌼𐌹𐌺𐌹𐌻𐌰 𐌹𐌽 𐌿𐍆𐌰𐍂𐌰𐍃𐍃𐌰𐌿 𐌹𐍃𐍄, 𐌱𐌹𐌳𐌾𐌰𐌼 𐌸𐌿𐌺 𐌹𐌽𐌻𐌰𐌲𐌴𐌹 𐌸𐍉 𐍆𐍂𐌹𐍃𐌰𐌷𐍄 𐌼𐌰𐌽𐌽𐌰𐍅𐌰𐌿𐍂𐌷𐍄𐌰𐌱𐌰
𐌸𐌰𐍄𐌰 𐍆𐌰𐌾𐌻 𐌵𐌹𐍅𐌰𐍆𐍂𐌹𐍃𐌰𐌷𐍄𐌰𐌹𐍃 𐌿𐍆𐌰𐍂𐌼𐌹𐌺𐌹𐌻 𐌹𐍃𐍄, 𐌱𐌹𐌳𐌾𐌰𐌼 𐌸𐌿𐌺 𐌹𐌽𐌻𐌰𐌲𐌾𐌰𐌽 𐌵𐌹𐍅𐌰𐍆𐍂𐌹𐍃𐌰𐌷𐍄 𐌷𐌰𐌽𐌳𐌿𐍅𐌰𐌿𐍂𐌷𐍄𐌰𐌱𐌰
𐌸𐌰𐍄𐌰 𐍆𐌰𐌾𐌻 𐌰𐍅𐌳𐌾𐍉𐌽𐍃 𐌿𐍆𐌰𐍂𐌼𐌹𐌺𐌹𐌻 𐌹𐍃𐍄, 𐌱𐌹𐌳𐌾𐌰𐌼 𐌸𐌿𐌺 𐌹𐌽𐌻𐌰𐌲𐌾𐌰𐌽 𐌰𐍅𐌳𐌾𐍉𐌽 𐌷𐌰𐌽𐌳𐌿𐍅𐌰𐌿𐍂𐌷𐍄𐌰𐌱𐌰
@@ -270,6 +272,11 @@
Import deck
Something 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/01-core.xml b/AnkiDroid/src/main/res/values-hi/01-core.xml
index e5b801a6f53e..59bda09ea1cd 100644
--- a/AnkiDroid/src/main/res/values-hi/01-core.xml
+++ b/AnkiDroid/src/main/res/values-hi/01-core.xml
@@ -59,7 +59,7 @@
कठिन
अच्छा
आसान
- \'%s\' is enabled
+ \'%s\' सक्रिय हो गया है।
पूर्ववत् करें
रीडू
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 item
Unesi HTML koji će biti umetnut ispred i iza odabranog teksta\n\nPritisnute dugo na stavku alatne trake kako biste je uredili ili uklonili
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
Slika je prevelika, umetnite sliku ručno
Video datoteka je prevelika, umetnite video ručno
Zvučna datoteka je prevelika, umetnite zvuk ručno
@@ -273,6 +275,11 @@
Import deck
Nešto je krenulo po krivu, pokušajte ponovno
Preuzimanje neuspješno
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
Možete koristi druge aplikacije za vrijeme preuzimanja
Provjerite svoju vezu s internetom
Preuzimanje 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 programu
Contributors
contributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>
License
GPL-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 item
Add 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éshez
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
@@ -270,6 +272,11 @@
Import deck
Valami hiba történt, kérjük, próbáld újra.
Letöltés sikertelen
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
Má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évjegy
Kö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!]]>
Licensz
GPL-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 item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
@@ -270,6 +272,11 @@
Import deck
Something went wrong, please try again
Download failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
You can use other apps while the download is running
Please check your network connection
Download 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 @@
About
Contributors
contributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>
License
GPL-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 item
Masukkan 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 required
Gambar ini terlalu besar, mohon masukkan gambar secara manual
File video terlalu besar, mohon masukkan video secara manual
File audio terlalu besar, mohon masukkan audio secara manual
@@ -267,6 +269,11 @@
Import deck
Ada yang tidak beres, coba lagi
Gagal mengunduh
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
Anda dapat menggunakan aplikasi lain selagi unduhan berjalan
Harap periksa koneksi jaringan Anda
Unduhan 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 @@
Tentang
Kontributor
para 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!]]>
Lisensi
GPL-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 item
Inserisci l\'HTML da inserire prima e dopo il testo selezionato\n\nTieni premuto un elemento della barra per modificarlo o rimuoverlo
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
L\'immagine è troppo grande, per favore inserisci l\'immagine manualmente
Il 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 deck
Qualcosa è andato storto, si prega di riprovare
Scaricamento non riuscito
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
È possibile utilizzare altre applicazioni mentre lo scaricamento è in esecuzione
Controlla la tua connessione di rete
Download 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 @@
Informazioni
Contributori
contributori. Anche tu puoi aiutare AnkiDroid programmando, traducendo e altro!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>
Licenza
GPL-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 303ce999cf75..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レンダラーがクラッシュしました。原因: %s
Fatal error: WebView renderer crashed. Cause: %s
@@ -233,6 +233,8 @@
アイテムを編集
選択した文字列の前後に挿入するHTMLをそれぞれ入力してください\n\n作成したツールバーアイテムを編集または削除したい場合は、そのアイテムのボタンを長押ししてください
このツールバーアイテムを削除しますか?
+ Button text is required
+ At least one HTML field is required
画像が大きすぎます。手動で画像を挿入してください
動画ファイルが大きすぎます。手動で動画を挿入してください
音声ファイルが大きすぎます。手動で音声を挿入してください
@@ -255,7 +257,7 @@
有効なメールアドレスを入力してください
パスワードが必要です
- もう一度「戻る」操作で終了
+ もう一度「戻る」操作で終了します
%s%%
デッキをダウンロード
@@ -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 a97ab9518218..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 倍にする場合、これを有効にします
@@ -229,15 +229,15 @@
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
+ リマインダーが機能していません
+ 設定変更
+ 新しい学習リマインダーを追加しました
+ 学習リマインダーを削除しました
+ 学習リマインダーを編集しました
学習リマインダーを追加
学習リマインダーを編集
@@ -252,37 +252,37 @@
Delete this reminder?
This action cannot be undone.
- 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
+ 許可を与える
+ 上記カテゴリの通知を許可
+ このアプリには適用しない
+ バッテリー設定を開く
学習する時間です
%s を学習する時間です
@@ -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’?
Skip
Assign
@@ -448,5 +449,5 @@
Not set
Custom path
Enter 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 manually
The 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 item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
@@ -270,6 +272,11 @@
Import deck
Something went wrong, please try again
Download failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
You can use other apps while the download is running
Please check your network connection
Download 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 @@
About
Contributors
contributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>
License
GPL-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 item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
@@ -267,6 +269,11 @@
Import deck
Something 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 @@
អំពី
Contributors
contributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>
License
GPL-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 required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The 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 3afa228db020..bdd971f16bae 100644
--- a/AnkiDroid/src/main/res/values-ko/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-ko/02-strings.xml
@@ -112,7 +112,7 @@
목표 학습 시간에 도달했습니다.
- - %2$s분 동안 카드 %1$d개를 복습했습니다
+ - %2$s 동안 카드 %1$d개를 복습했습니다
@@ -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 item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
@@ -270,6 +272,11 @@
Import deck
Something went wrong, please try again
Download failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
You can use other apps while the download is running
Please check your network connection
Download 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 @@
About
Contributors
contributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>
License
GPL-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 item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
@@ -270,6 +272,11 @@
Import deck
Something went wrong, please try again
Download failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
You can use other apps while the download is running
Please check your network connection
Download 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 @@
About
Contributors
contributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>
License
GPL-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ėte
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
Vaizdas yra per didelis, įdėkite jį rankiniu būdu
Vaizdo failas yra per didelis, įdėkite vaizdo įrašą rankiniu būdu
Garso failas yra per didelis, įdėkite garso įrašą rankiniu būdu
@@ -276,6 +278,11 @@
Import deck
Kažkas nepavyko, pabandykite dar kartą
Atsisiųsti nepavyko
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
Atsisiuntimo metu galite naudoti kitas programas
Patikrinkite 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 @@
Apie
Bendraautoriai
contributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>
Licencija
GPL-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 item
Jāievada HTML, ko ievietot pirms un pēc iezīmētā teksta\n\nRīkjoslas vienums ilgi jāpiespiež, lai to labotu vai noņemtu
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
Attēls ir pārāk liels, lūgums pašrocīgi ievietot attēlu
Video datne ir pārāk liela, lūgums pašrocīgi ievietot video
Skaņu datne ir pārāk liela, lūgums ievietot skaņu pašrocīgi
@@ -273,6 +275,11 @@
Import deck
Kaut kas nogāja greizi, lūgums mēģināt vēlreiz
Lejupielāde neizdevās
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
Var izmantot citas lietotnes, kamēr notiek lejupielāde
Lūgums pārbaudīt savu tīkla savienojumu
Lejupielā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 @@
Par
Līdzdalībnieki
lī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!]]>
Licence
GPL-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 item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
@@ -271,6 +273,11 @@
Import deck
Something went wrong, please try again
Download failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
You can use other apps while the download is running
Please check your network connection
Download 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 @@
About
Contributors
contributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>
License
GPL-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 item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The 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 @@
About
Contributors
contributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>
License
GPL-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 required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The 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 item
Masukkan HTML sebelum dan selepas pilihan teks\n\nTekan lama item bar alat untuk sunting atau buangnya
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
@@ -267,6 +269,11 @@
Import deck
kesulitan berlaku, sila cuba lagi
Memuat turun gagal
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
Anda boleh guna aplikasi lain sementara memuat turun
Sila semak sambungan rangkaian anda
Muat 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 @@
Perihal
Para Penyumbang
para 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!]]>
Lesen
GPL-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 item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
@@ -267,6 +269,11 @@
Import deck
Something went wrong, please try again
Download failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
You can use other apps while the download is running
Please check your network connection
Download 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 @@
About
Contributors
contributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>
License
GPL-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 aan
Voer 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 required
De afbeelding is te groot, voeg de afbeelding handmatig in
Het videobestand is te groot, voeg de video handmatig in
Het audiobestand is te groot, voeg de audio handmatig in
@@ -270,6 +272,11 @@
Importeer leerset
Er is iets mis gegaan, probeer het alstublief opnieuw.
Download mislukt
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
U kunt andere apps gebruiken terwijl de download bezig is
Controleer alsrublief uw internetconnectie
Download 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 @@
Over
Bijdragers
medewerkers. 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!]]>
Licentie
GPL-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 item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
@@ -270,6 +272,11 @@
Import deck
Noko gjekk gale. Prøv på nytt.
Nedlasting feilet
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
You can use other apps while the download is running
Please check your network connection
Download 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 @@
Om
Medvirkende
contributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>
Lisens
GPL-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 item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
@@ -270,6 +272,11 @@
Import deck
Noe gikk galt. Vennligst prøv igjen
Nedlasting feilet
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
You can use other apps while the download is running
Please check your network connection
Download 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 @@
Om
Medvirkende
contributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>
Lisens
GPL-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 required
The 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ędzi
Wprowadź 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 required
Obraz jest zbyt duży, wstaw obraz ręcznie
Plik wideo jest za duży, wstaw go ręcznie
Plik audio jest za duży, wstaw go ręcznie
@@ -276,6 +278,11 @@
Importuj talię
Coś poszło nie tak, spróbuj ponownie
Pobieranie nie powiodło się
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
Możesz używać innych aplikacji podczas pobierania
Sprawdź swoje połączenie internetowe
Pobieranie 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 programie
Współtwórcy
współ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!]]>
Licencja
GPL-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 Ferramentas
Insira o código HTML que deve ser inserido antes e depois do texto selecionado\n\nMantenha o item pressionado para editá-lo ou removê-lo
Remover item na Barra de Ferramentas
+ Button text is required
+ At least one HTML field is required
A imagem é muito grande, por favor insira a imagem manualmente
O arquivo de vídeo é muito grande, por favor insira o vídeo manualmente
O arquivo de áudio é muito grande, por favor insira o áudio manualmente
@@ -270,6 +272,11 @@
Import deck
Algo deu errado. Por favor, tente novamente
Download falhou
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
Você pode usar outros apps enquanto o download está rodando
Por favor, verifique sua conexão de rede
Transferê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 @@
Sobre
Colaboradores
colaboradores. Você também pode ajudar o AnkiDroid programando, traduzindo, doando e mais!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>
Licença
GPL-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 item
Introduza 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 required
A 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 deck
Ocorreu um erro. Tente novamente
A transferência falhou
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
Pode utilizar outras aplicações enquanto a transferência decorre.
Por favor, verifique a sua ligação à rede
Transferê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 @@
Sobre
Colaboradores
colaboradores. 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ça
GPL-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 item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
@@ -273,6 +275,11 @@
Import deck
Something went wrong, please try again
Download failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
You can use other apps while the download is running
Please check your network connection
Download 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 @@
About
Contributors
contributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>
License
GPL-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 item
Inserta 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 bogare
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
S\'immàgine est tropu manna, inserta·la in manera manuale
Custu documentu vìdeu est tropu mannu, inserta·lu in manera manuale
Custu documentu àudio est tropu mannu, inserta·lu in manera manuale
@@ -272,6 +274,11 @@
Import deck
B\'at àpidu carchi problema. Torra a proare
Iscarrigamentu fallidu
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
Podes impreare àteras aplicatziones in su mentres chi s\'iscarrigamentu est in cursu
Verìfica sa connessione de retze tua
Iscarrigamentu 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 @@
Informatziones
Contribuidores
contribuidores 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èntzia
GPL-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ástrojov
Vlož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 vymazanie
Vymazať položku lišty nástrojov?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
Video 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 deck
Niečo sa pokazilo, prosím, skúste znovu
Sťahovanie zlyhalo
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
Môžete používať iné aplikácie kým prebieha sťahovanie
Prosím, skontrolujte vaše sieťové pripojenie
Download aborted, the external storage is not available
diff --git a/AnkiDroid/src/main/res/values-sk/03-dialogs.xml b/AnkiDroid/src/main/res/values-sk/03-dialogs.xml
index ebffb7dde89f..9d9dd2c2a1d5 100644
--- a/AnkiDroid/src/main/res/values-sk/03-dialogs.xml
+++ b/AnkiDroid/src/main/res/values-sk/03-dialogs.xml
@@ -134,7 +134,7 @@
Chyba pri odstránení obrázka
Nepodarilo sa použiť obrázok pozadia %s
Pozadie Deck Picker je príliš veľké
- Maximum image size %d MB allowed
+ Najväčšia povolená veľkosť obrázka je %d MB
Rozmery obrázka sú priveľké (%1$d x %2$d)
diff --git a/AnkiDroid/src/main/res/values-sk/10-preferences.xml b/AnkiDroid/src/main/res/values-sk/10-preferences.xml
index 7ec9a48b9025..413491d467f0 100644
--- a/AnkiDroid/src/main/res/values-sk/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-sk/10-preferences.xml
@@ -52,12 +52,12 @@
Upravovanie
Zabezpečenie
- Globally allow dangerous JavaScript APIs
- Allows any card or template to read and modify other cards, or your Anki collection data.\nLeave off unless you trust all cards and templates in your collection.
+ Globálne povoliť nebezpečné JS API
+ Umožňuje akejkoľvek kartičke alebo šablóne čítať a meniť kartičky alebo údaje vašej Anki kolekcie.\nPonechajte vypnuté s výnimkou že veríte všetkým kartičkám a šablónam vo vašej kolekcii.
Security: Unsafe JavaScript call blocked.\nIf you trust all cards and templates in your collection, this can be unblocked in the advanced settings
- Allow cards to launch other apps
- Cards can open other apps on your device, triggering potentially unwanted actions.\nLeave off unless you trust all cards and templates in your collection.
+ Povoliť kartičkám spúšťanie iných aplikácií
+ 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 settings
Hrúbka ťahu
@@ -147,9 +147,9 @@
Zvoliť jazyk
Vypnúť hardvérové renderovanie kartičky
Hardvérové renderovanie je rýchlejšie, ale môže spôsobiť problémy, najmä na Android 8/8.1. Ak nemôžete vidieť časti užívateľského rozhrania pri opakovaní kartičiek, skúste toto nastavenie.
- Disable single-field edit mode
+ Zakázať režim úpravy jedného poľa
- Allows \'Cloze deletion\' context menu when in landscape mode.
+ Povoliť kontextové menu doplňovačky pri horizontálnom zobrazení.
Zarovnanie na stred
Vycentrovať obsah kartičiek vertikálne
Čas oneskorenia dvojitého dotyku
@@ -160,8 +160,8 @@
Zobraziť čas ďalšieho opakovania nad tlačidlami odpovedí
Ukázať veľké tlačidla pre odpovede
Ukázať tlačidlá odpovede vo dvoch riadkoch
- Remove animations
- Also enabled if animations are removed on the system settings
+ Odstrániť animácie
+ Je povolené aj ak sú animácie odstránené systémovými nastaveniami
Zobraziť hornú lištu
Show card counts, answer timer, flag and mark in top bar
Zobraziť zostávajúce
@@ -310,7 +310,7 @@
Zdieľať používanie prvkov
Môžete prispieť pre AnkiDroid tak, že umožníte vývojovému tímu sledovať, ktoré prvky sa používajú
Nahradiť nové riadky s HTML
- In the note editor, convert any instances of <br> to newlines when editing a card.
+ Pri upravovaní poznámky zmení všetky prvky <br> na nové riadky počas upravovania kartičky.
Vybrať „napíšte odpoveď“
Automaticky vyberie pole „napíšte odpoveď“ na obrazovke študovania.
@@ -318,6 +318,7 @@
O nás
Prispievatelia
dobrovoľ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!]]>
Licencia
GPL-3.0 a AGPL-3.0, so zdrojovým kódom dostupným na GitHube]]>
licenciami]]>
@@ -358,8 +359,8 @@
Experimental
AnkiDroid has introduced a better TTS mechanism which is compatible with other Anki clients and includes more voices and improvements to language playback!\n\nPlease upgrade as soon as possible, as this setting will be removed soon
Please upgrade to the new text to speech format
- Allow templates to record audio
- This template is trying to record audio
+ Povoliť šablónam nahrávať zvuk
+ Táto šablóna sa pokúša nahrávať zvuk
localStorage na obrazovke študovania
Povoliť JavaScript localStorage (lokálne úložisko) použitím fixného portu servera na obrazovke študovania
diff --git a/AnkiDroid/src/main/res/values-sk/11-arrays.xml b/AnkiDroid/src/main/res/values-sk/11-arrays.xml
index 852cc8588fa9..0a40c7747ffa 100644
--- a/AnkiDroid/src/main/res/values-sk/11-arrays.xml
+++ b/AnkiDroid/src/main/res/values-sk/11-arrays.xml
@@ -35,7 +35,7 @@
Svetlý
Jednoduchý
- E-Ink
+ E-Tuš
Čierny
Tmavý
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 item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
@@ -276,6 +278,11 @@
Import deck
Something went wrong, please try again
Download failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
You can use other apps while the download is running
Please check your network connection
Download 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 @@
About
Contributors
contributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>
License
GPL-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 item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
@@ -270,6 +272,11 @@
Import deck
Something went wrong, please try again
Download failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
You can use other apps while the download is running
Please check your network connection
Download 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 @@
About
Contributors
contributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>
License
GPL-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 item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
@@ -273,6 +275,11 @@
Import deck
Something went wrong, please try again
Download failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
You can use other apps while the download is running
Please check your network connection
Download 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 @@
About
Contributors
contributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>
License
GPL-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ältet
Ange 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 det
Ta bort objekt från verktygsfältet?
+ Button text is required
+ At least one HTML field is required
Bilden är för stor, lägg till bilden manuellt
Videofilen är för stor, lägg till videon manuellt
Ljudfilen är för stor, lägg till ljudet manuellt
@@ -270,6 +272,11 @@
Import deck
Något gick fel, vänligen försök igen
Hämtning misslyckades
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
Du kan använda andra appar medan nedladdningen pågår
Kontrollera din nätverksanslutning
Nedladdningen 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 appen
Bidragsgivare
bidragsgivare. 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!]]>
Programvarulicens
GPL-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 item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
@@ -270,6 +272,11 @@
Import deck
Something went wrong, please try again
Download failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
You can use other apps while the download is running
Please check your network connection
Download 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 @@
About
Contributors
contributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>
License
GPL-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 item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
@@ -267,6 +269,11 @@
Import deck
Something went wrong, please try again
Download failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
You can use other apps while the download is running
Please check your network connection
Download 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 @@
About
Contributors
contributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>
License
GPL-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 item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
@@ -270,6 +272,11 @@
Import deck
Something went wrong, please try again
Download failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
You can use other apps while the download is running
Please check your network connection
Download 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 @@
About
Contributors
contributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>
License
GPL-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 yaz
varolan notlara, kartlara, not türlerine ve destelere erişmek, ve yenilerini oluşturmak
- Card browser
+ Kart tarayıcısı
Anki card
Koleksiyonlar 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 boyutu
Araç çubuğunu göster
- Show preview
- Hide preview
+ Önizlemeyi göster
+ Önizlemeyi gizle
Kalın olarak şekillendir
İtalik olarak şekillendir
Altı çizili olarak şekillendir
@@ -236,6 +236,8 @@
Araç çubuğu öğesini düzenle
Seç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ı tutun
Araç ç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 ekleyin
Bu video dosyası fazla büyük, lütfen videoyu elle ekleyin
Bu ses dosyası fazla büyük, lütfen sesi elle ekleyin
@@ -270,6 +272,11 @@
Deste aktar
Bir ş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ı kullanabilirsiniz
Lü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 eklendi
Medya 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
+ Etkin
Devredışı
- %d dk
@@ -108,7 +108,7 @@
Üç parmakla dokunma
Dört parmakla dokunma
Cihazı salla
- ‘%s’ menu
+ ‘%s’ menüsü
\"%s\" durum menüsünü uygulama çapında etkinleştirir
Hızlı kaydırma
eOkuyucu\'yla kaydırma mesafesini iki katına çıkarmak istiyorsanız bunu etkinleştirin
@@ -223,7 +223,7 @@
####################################################################################### -->
Review reminders
- App-wide
+ Uygulama geneli
Deck-specific
An 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 yok
Troubleshoot
- Notification settings
+ Bildirim ayarları
List of scheduled review reminders
- Deck not found
+ Deste bulunamadı
Reminders are unavailable
Fix
Successfully added new review reminder
@@ -243,15 +243,15 @@
Çalışma hatırlatıcısı ekle
Çalışma hatırlatıcısını değiştir
Time:
- Deck:
- Advanced settings
+ Deste:
+ Gelişmiş ayarlar
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.
+ 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 modu
Alarms & reminders permission
Granted
Denied
- On
- Off
+ Açık
+ Kapalı
Unrestricted
Optimized
Restricted
@@ -310,6 +310,7 @@
Hakkında
Katkıda Bulunanlar
insanlar. 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!]]>
Lisans
GPL-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 yol
Enter 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 item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
@@ -268,6 +270,11 @@
Import deck
Something went wrong, please try again
Download failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
You can use other apps while the download is running
Please check your network connection
Download 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/01-core.xml b/AnkiDroid/src/main/res/values-ug/01-core.xml
index 21d261445901..3adf3cc3eda8 100644
--- a/AnkiDroid/src/main/res/values-ug/01-core.xml
+++ b/AnkiDroid/src/main/res/values-ug/01-core.xml
@@ -127,8 +127,8 @@
AnkiDroid ساندانىنى ئوقۇيدۇ ۋە يازىدۇ
مەۋجۇت خاتىرە، كارتا، خاتىرە تۈرى ۋە دەستىنى زىيارەت قىلىپلا قالماي يەنە يېڭىسىنى قۇرىدۇ
- Card browser
- Anki card
+ كارتا كۆرگۈچ
+ Anki كارتا
توپلامنى بىرلەشتۈرگىلى بولمايدۇ.\nقايسى توپلامنى ساقلاپ قالىسىز؟
AnkiDroid
diff --git a/AnkiDroid/src/main/res/values-ug/02-strings.xml b/AnkiDroid/src/main/res/values-ug/02-strings.xml
index 3c3dfc822322..b02ceb6f1247 100644
--- a/AnkiDroid/src/main/res/values-ug/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-ug/02-strings.xml
@@ -220,8 +220,8 @@
خەت چوڭلۇقى
قورال بالداقنى كۆرسەت
- Show preview
- Hide preview
+ ئالدىن كۆزىتىشنى كۆرسەت
+ ئالدىن كۆزىتىشنى يوشۇر
توم پىچىم
يانتۇ پىچىم
ئاستى سىزىق پىچىم
@@ -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/03-dialogs.xml b/AnkiDroid/src/main/res/values-ug/03-dialogs.xml
index 601708e3a98d..b9238fe480f8 100644
--- a/AnkiDroid/src/main/res/values-ug/03-dialogs.xml
+++ b/AnkiDroid/src/main/res/values-ug/03-dialogs.xml
@@ -49,7 +49,7 @@
بۈگۈنكى تەكرارلايدىغان كارتا چېكىنى («3-»)ئاشۇرىدۇ/كېمەيتىدۇ
ئۆتكەن x كۈندە ئۇنتۇلغان كارتىنى تەكرارلايدۇ:
كېيىنكى كارتىنى تەكرارلاش مۆھلىتى:
- Preview new cards added in the last:
+ يېقىنقى كۈندە قوشقان يېڭى كارتىنى ئالدىن كۆزىتىدۇ:
دەستىدىن x كارتا تاللىنىدۇ:
ساقلىغۇچ لىق
ساقلىغۇچ ئاساسەن لىق
@@ -78,7 +78,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 گۇۋاھنامىسىنى بىر تەرەپ قىلالمىدى
گۇۋاھنامە يېشىش خاتالىقى
@@ -87,7 +87,7 @@
چوڭ ۋاسىتە توپلىمىغا ئۇزۇن ۋاقىت كېتىشى مۇمكىن. داۋاملاشتۇرامدۇ؟
ۋاسىتە تەكشۈرۈۋاتىدۇ…
بەلگە قوش…
- Tags added
+ بەلگە قوشۇلدى
ۋاسىتە ئۆچۈرۈۋاتىدۇ…
ئۆچۈرۈش نەتىجىسى
diff --git a/AnkiDroid/src/main/res/values-ug/10-preferences.xml b/AnkiDroid/src/main/res/values-ug/10-preferences.xml
index 8c590afe6a10..8f2616080bcc 100644
--- a/AnkiDroid/src/main/res/values-ug/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-ug/10-preferences.xml
@@ -24,7 +24,7 @@
-->
- Enabled
+ قوزغىتىلدى
چەكلەنگەن
- %d مىنۇت
@@ -108,7 +108,7 @@
ئۈچ بارماقتا چېكىش
تۆت بارماقتا چېكىش
ئۈسكۈنىنى سىلكى
- ‘%s’ menu
+ «%s» تىزىملىكى
«%s» مەزمۇن تىزىملىكىنى پۈتكۈل دائىرىدە قوزغىتىدۇ
قوش دومىلات
ئېلېكتىرونلۇق ئوقۇغۇچتا قوش دومىلىتىدۇ
@@ -224,75 +224,75 @@
################################## 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 card due
- - %d cards due
+ - %d كارتا مۆھلىتى
+ - %d كارتا مۆھلىتى
بەلگە تاللىنىدۇ
@@ -312,6 +312,7 @@
ھەققىدە
تۆھپىكار
تۆھپىكارلىرىمىز. پىروگرامما تۈزۈش، تەرجىمە قىلىش، ئىئانە قىلىش ۋە تېخىمۇ كۆپ ئۇسۇللار ئارقىلىق AnkiDroid غا ياردەم قىلالايسىز!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>
ئىجازەتنامە
GPL-3.0 ۋە AGPL-3.0 ئىجازىتىدە تارقىتىدۇ، ئەسلى مەنبە كودى GitHub تا بار]]>
ئىجازەتتە تارقىتىلىدىغان ئۈچىنچى تەرەپ ئامبار ۋە سىنبەلگىنىمۇ ئىشلىتىدۇ]]>
@@ -443,15 +444,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-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 @@
About
Contributors
contributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>
License
GPL-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 tahrirlash
Tanlangan 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 required
Rasm juda katta, rasmni qoʻlda kiriting
Video juda katta, videoni qoʻlda kiriting
Audio fayl juda katta, audio faylni qoʻlda kiriting
@@ -270,6 +272,11 @@
Dastani import qilish
Nimadir xato ketdi, iltimos, qayta urinib koʻring
Yuklab olib boʻlmadi
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
Yuklab olish jarayonida boshqa ilovalardan foydalanishingiz mumkin
Iltimos, tarmoqqa ulanganingizni tekshiring
Yuklab 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 haqida
Hissa qoʻshganlar
hissa 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!]]>
Litsenziya
GPL-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 item
Nhậ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 required
Hình ảnh quá lớn, vui lòng chèn hình ảnh theo cách thủ công
Tập tin video quá lớn, vui lòng chèn video theo cách thủ công
Tệ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ại
Tải xuống thất bại
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
Bạ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ạn
Tả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ệu
Những người đóng góp
cộ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ép
GPL-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 271234bce2d1..0eba17b9c243 100644
--- a/AnkiDroid/src/main/res/values/02-strings.xml
+++ b/AnkiDroid/src/main/res/values/02-strings.xml
@@ -259,6 +259,8 @@
Edit toolbar item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
@@ -303,6 +305,11 @@
Import deck
Something went wrong, please try again
Download failed
+ %1$s/s
+ -/s
+ -
+ Speed
+ Time left
You can use other apps while the download is running
Please check your network connection
Download 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 @@
About
Contributors
contributors. You can help AnkiDroid too by programming, translating, donating and more!]]>
+ contributors. You can help AnkiDroid too by programming, translating and more!]]>
License
GPL-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/main/res/values/ids.xml b/AnkiDroid/src/main/res/values/ids.xml
index f70272303cae..e2d4feb53b21 100644
--- a/AnkiDroid/src/main/res/values/ids.xml
+++ b/AnkiDroid/src/main/res/values/ids.xml
@@ -43,4 +43,6 @@
+
+
\ No newline at end of file
diff --git a/AnkiDroid/src/main/res/values/styles.xml b/AnkiDroid/src/main/res/values/styles.xml
index 2e64bb90f128..f5ba1bb96faa 100644
--- a/AnkiDroid/src/main/res/values/styles.xml
+++ b/AnkiDroid/src/main/res/values/styles.xml
@@ -291,6 +291,11 @@
- ?attr/editTextHighlightColor
+
+
+