diff --git a/.github/check_alpha_release.js b/.github/check_alpha_release.js
index 332305a69137..30d732d3659b 100644
--- a/.github/check_alpha_release.js
+++ b/.github/check_alpha_release.js
@@ -3,12 +3,21 @@
const fs = require("node:fs");
module.exports = async function checkAlphaRelease({ github, context, core }) {
+ async function skipRelease(reason) {
+ core.info(`Skipping scheduled release: ${reason}`);
+ await github.rest.actions.cancelWorkflowRun({
+ ...context.repo,
+ run_id: context.runId,
+ });
+ // Cancellation is asynchronous. Keep this step alive so cancelWorkflowRun can kill it.
+ await keepProcessAlive();
+ }
+
const metadata = JSON.parse(
fs.readFileSync("AnkiDroid/build/outputs/release-metadata.json", "utf8"),
);
if (metadata.releaseType !== "alpha") {
- core.info("Skipping scheduled release: main is not on an alpha version.");
- return false;
+ return await skipRelease("main is not on an alpha version.");
}
const releases = await github.paginate(github.rest.repos.listReleases, {
@@ -22,18 +31,16 @@ module.exports = async function checkAlphaRelease({ github, context, core }) {
release => !release.draft && today - Math.floor(Date.parse(release.published_at) / day) < 7,
);
if (recentRelease) {
- core.info(
- `Skipping scheduled release: ${recentRelease.tag_name} was published fewer than seven UTC calendar days ago.`,
+ return await skipRelease(
+ `${recentRelease.tag_name} was published fewer than seven UTC calendar days ago.`,
);
- return false;
}
const latestAlpha = releases
.filter(release => !release.draft && /^v\d+\.\d+\.\d+alpha\d+$/.test(release.tag_name))
.sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at))[0];
if (!latestAlpha) {
- core.info("Skipping scheduled release: no published alpha to compare against.");
- return false;
+ return await skipRelease("no published alpha to compare against.");
}
const { data: comparison } = await github.rest.repos.compareCommitsWithBasehead({
@@ -41,10 +48,7 @@ module.exports = async function checkAlphaRelease({ github, context, core }) {
basehead: `${latestAlpha.tag_name}...${context.sha}`,
});
if (comparison.status !== "ahead" || comparison.ahead_by === 0) {
- core.info(
- `Skipping scheduled release: no new commits descending from ${latestAlpha.tag_name}.`,
- );
- return false;
+ return await skipRelease(`no new commits descending from ${latestAlpha.tag_name}.`);
}
core.info(
@@ -52,3 +56,9 @@ module.exports = async function checkAlphaRelease({ github, context, core }) {
);
return true;
};
+
+async function keepProcessAlive() {
+ // Fail after five minutes if cancellation never arrives.
+ await new Promise(resolve => setTimeout(resolve, 5 * 60 * 1000));
+ throw new Error("Timed out waiting for workflow cancellation.");
+}
diff --git a/.github/report_test_failures.py b/.github/report_test_failures.py
index 718bcdbbfe3a..7acc7ed803a3 100644
--- a/.github/report_test_failures.py
+++ b/.github/report_test_failures.py
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
-"""Report failures and skipped tests from JUnit XML using the standard library."""
+"""Report failures, skipped tests, and overview summary from JUnit XML using the standard library."""
import argparse
import html
@@ -12,6 +12,19 @@
import xml.etree.ElementTree as ET
+TABLE_HEADER = (
+ "## Test Results\n\n"
+ "| Suite | Result | Duration | Tests | Passed | Failed | Skipped |\n"
+ "| ----- | ------ | -------- | ----- | ------ | ------ | ------- |\n"
+)
+
+
+def format_duration(seconds):
+ minutes = int(seconds // 60)
+ rem_seconds = seconds % 60
+ return f"{minutes:02d}m {rem_seconds:06.3f}s"
+
+
def read_ignored_tests(log_file):
ignored = set()
failed = set()
@@ -26,22 +39,61 @@ def read_ignored_tests(log_file):
return ignored - failed
-def main(results_dir, log_file=None, pattern="TEST-*.xml", title="Test failures"):
+def get_report_duration(root):
+ suites = root.findall("testsuite")
+ if suites:
+ total = 0.0
+ for s in suites:
+ try:
+ total += float(s.get("time", 0) or 0)
+ except (ValueError, TypeError):
+ pass
+ if total > 0.0:
+ return total
+ try:
+ root_time = float(root.get("time", 0) or 0)
+ if root_time > 0.0:
+ return root_time
+ except (ValueError, TypeError):
+ pass
+ total = 0.0
+ for tc in root.iter("testcase"):
+ try:
+ total += float(tc.get("time", 0) or 0)
+ except (ValueError, TypeError):
+ pass
+ return total
+
+
+def main(results_dir, log_file=None, pattern="TEST-*.xml", title="Test failures", suite=None):
failures = []
skipped = []
ignored = read_ignored_tests(log_file)
+ total_duration = 0.0
+ passed_count = 0
+ failed_count = 0
+ skipped_count = 0
+
for report in sorted(results_dir.rglob(pattern)):
try:
root = ET.parse(report).getroot()
except (ET.ParseError, OSError) as error:
- failures.append(f"Could not read {report}: {error}")
+ if os.environ.get("GITHUB_ACTIONS") == "true":
+ print(f"::warning::Could not read {report}: {error}")
+ else:
+ print(f"Warning: Could not read {report}: {error}", file=sys.stderr)
continue
+ total_duration += get_report_duration(root)
+
for test in root.iter("testcase"):
+ name = f"{test.get('classname', '')}.{test.get('name', '')}"
+ has_failure = False
+ has_skipped = False
+
for result in test:
if result.tag not in ("failure", "error", "skipped"):
continue
- name = f"{test.get('classname', '')}.{test.get('name', '')}"
details = "".join(result.itertext()).strip()
message = result.get("message", "")
# Android can report assumption violations as failures or errors.
@@ -56,10 +108,30 @@ def main(results_dir, log_file=None, pattern="TEST-*.xml", title="Test failures"
"org.junit.AssumptionViolatedException",
"org.junit.internal.AssumptionViolatedException",
):
+ has_skipped = True
skipped.append(f"{name}\n{details or result.get('type', 'Test skipped')}")
else:
+ has_failure = True
failures.append(f"{name}\n{details or result.get('type', 'Test failed')}")
+ if has_failure:
+ failed_count += 1
+ elif has_skipped:
+ skipped_count += 1
+ else:
+ passed_count += 1
+
+ total_tests = passed_count + failed_count + skipped_count
+ is_successful = failed_count == 0 and total_tests > 0
+ result_text = "SUCCESS" if is_successful else "FAILURE"
+ elapsed = format_duration(total_duration)
+
+ if suite:
+ print(
+ f"{suite}: {result_text} ({elapsed}) - "
+ f"{total_tests} tests: {passed_count} passed, {failed_count} failed, {skipped_count} skipped"
+ )
+
for failure in failures:
if os.environ.get("GITHUB_ACTIONS") == "true":
# GitHub renders the escaped message as a multiline error in the log.
@@ -79,19 +151,27 @@ def main(results_dir, log_file=None, pattern="TEST-*.xml", title="Test failures"
if os.environ.get("GITHUB_ACTIONS") == "true":
print("::endgroup::")
- if (failures or skipped) and os.environ.get("GITHUB_STEP_SUMMARY"):
- with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as summary:
+ if os.environ.get("GITHUB_STEP_SUMMARY"):
+ with open(os.environ["GITHUB_STEP_SUMMARY"], "a+", encoding="utf-8") as summary:
+ summary.seek(0)
+ existing_content = summary.read()
+ if suite:
+ if "## Test Results" not in existing_content:
+ summary.write(TABLE_HEADER)
+ summary.write(f"| {suite} | **{result_text}** | {elapsed} | {total_tests} | {passed_count} | {failed_count} | {skipped_count} |\n\n")
+
if failures:
summary.write(f"### {title}\n\n")
for failure in failures:
summary.write(f"
{html.escape(failure)}\n\n")
if skipped:
+ skipped_title = f"Skipped tests ({len(skipped)})"
summary.write(f"{skipped_title}
\n\n")
for skip in skipped:
summary.write(f"{html.escape(skip)}\n\n")
summary.write(" \n\n")
- return 1 if failures else 0
+ return 1 if (failures or (suite and not is_successful)) else 0
if __name__ == "__main__":
@@ -100,5 +180,6 @@ def main(results_dir, log_file=None, pattern="TEST-*.xml", title="Test failures"
parser.add_argument("log_file", type=Path, nargs="?")
parser.add_argument("--pattern", default="TEST-*.xml", help="Recursive report glob relative to results_dir")
parser.add_argument("--title", default="Test failures", help="Failure heading in the job summary")
+ parser.add_argument("--suite", help="Suite name for markdown summary table")
args = parser.parse_args()
- sys.exit(main(args.results_dir, args.log_file, args.pattern, args.title))
+ sys.exit(main(args.results_dir, args.log_file, args.pattern, args.title, args.suite))
diff --git a/.github/workflows/label.yml b/.github/workflows/label.yml
index 2fd5dbe5b228..9fad62b93163 100644
--- a/.github/workflows/label.yml
+++ b/.github/workflows/label.yml
@@ -14,6 +14,7 @@ jobs:
steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
+ retries: 3
script: |
let removeLabelsList = [
"Pending Merge",
@@ -42,6 +43,7 @@ jobs:
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
+ request: { retries: 0 },
body: `Maintainers: Please [Sync Translations](https://github.com/ankidroid/Anki-Android/actions/workflows/sync_translations.yml) to produce a commit with only the automated changes from this PR.
Read more about updating strings on the wiki,
@@ -80,6 +82,7 @@ jobs:
steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
+ retries: 3
script: |
const I18N_FILES = [
"01-core",
@@ -133,6 +136,7 @@ jobs:
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
+ request: { retries: 0 },
body: `> [!IMPORTANT]
> **Maintainers**: This PR contains https://github.com/ankidroid/Anki-Android/labels/Strings changes
@@ -207,6 +211,7 @@ jobs:
steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
+ retries: 3
script: |
const creator = context.payload.sender.login
const opts = github.rest.issues.listForRepo.endpoint.merge({
@@ -240,6 +245,7 @@ jobs:
steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
+ retries: 3
script: |
await github.rest.issues.addLabels({
issue_number: context.issue.number,
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 7080bae85db7..0dc8bf3241e3 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -27,6 +27,9 @@ concurrency:
jobs:
check_release:
name: 'Check whether a release is due'
+ permissions:
+ contents: read
+ actions: write # Cancel this run when a scheduled publish is skipped.
if: github.event_name == 'schedule' && github.repository == 'ankidroid/Anki-Android' && github.ref == 'refs/heads/main'
timeout-minutes: 20
runs-on: ubuntu-latest
diff --git a/.github/workflows/screenshot_comment.yml b/.github/workflows/screenshot_comment.yml
index 1e994a187b84..a9c8698c0a81 100644
--- a/.github/workflows/screenshot_comment.yml
+++ b/.github/workflows/screenshot_comment.yml
@@ -64,7 +64,7 @@ jobs:
name: Check if there are valid files
shell: bash
run: |
- # Find Roborazzi diff PNGs in the downloaded artifact (in /).
+ # Find Roborazzi diff PNGs in the downloaded artifact (in //).
mapfile -t files_to_add < <(find . -name "*_compare.png" -type f)
if [ ${#files_to_add[@]} -gt 0 ]; then
exist_valid_files="true"
@@ -82,17 +82,18 @@ jobs:
BASE_REF: ${{ github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.pull_requests[0].base.ref || github.event.repository.default_branch }}
shell: bash
run: |
- # Find Roborazzi diff PNGs to mention in the comment (in /).
+ # Find Roborazzi diff PNGs to mention in the comment (in //).
mapfile -t files < <(find . -name "*_compare.png" -type f | sort)
total=${#files[@]}
# Cap the full list so we remain within GitHub's comment limit
max_details=100
- # Group by test class - the file's immediate parent directory.
+ # Group by module and test class, after the artifact's per-run directory.
declare -A class_counts class_files
for file in "${files[@]}"; do
class_dir="${file%/*}"
- class="${class_dir##*/}"
+ class="${class_dir#./screenshot-diff/}"
+ class="${class#*/}"
class_counts[$class]=$(( ${class_counts[$class]:-0} + 1 ))
class_files[$class]+="$(basename "$file")"$'\n'
done
diff --git a/.github/workflows/screenshot_compare.yml b/.github/workflows/screenshot_compare.yml
index 0d9599f012d4..60595f5d2c13 100644
--- a/.github/workflows/screenshot_compare.yml
+++ b/.github/workflows/screenshot_compare.yml
@@ -57,13 +57,14 @@ jobs:
gradle-version: wrapper
- name: Warm Gradle Cache
- # This makes sure we fetch gradle network resources with a retry
+ # Keep compilation here: robolectricSdkDownload only downloads SDK jars.
+ # Compiling resolves test dependencies within the retry (Issue 21682).
uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
with:
timeout_minutes: 10
retry_wait_seconds: 60
max_attempts: 3
- command: ./gradlew robolectricSdkDownload :AnkiDroid:compilePlayDebugUnitTestKotlin -Pscreenshot --daemon
+ command: ./gradlew robolectricSdkDownload compileScreenshotTests -Pscreenshot --daemon
- name: Download base branch screenshots
uses: dawidd6/action-download-artifact@eab87c9830c39eff17e5a6eadb20bfb4bc880477 # v27
@@ -72,11 +73,12 @@ jobs:
name: screenshot
workflow: screenshot_store.yml
branch: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref || github.event.repository.default_branch }}
+ event: push
- name: Compare screenshots
id: compare-screenshot-test
run: |
- ./gradlew compareRoborazziPlayDebug -Pscreenshot --stacktrace
+ ./gradlew compareScreenshots -Pscreenshot --stacktrace
- name: Stage screenshot diffs
if: ${{ always() }}
@@ -84,18 +86,15 @@ jobs:
# Folder name inside the artifact (e.g. "screenshot-diff-pr-12345" on PRs, "screenshot-diff-main" on push)
SUBDIR: ${{ github.event_name == 'pull_request' && format('screenshot-diff-pr-{0}', github.event.number) || format('screenshot-diff-{0}', github.ref_name) }}
run: |
- # Trim 'AnkiDroid/build/outputs/roborazzi' from the artifact
- # Only keep the _compare images of failing cases: /diffs/*_compare.png moves to /
+ # Preserve module paths so identically named tests cannot overwrite one another.
mkdir -p "screenshot-diff/$SUBDIR"
- shopt -s nullglob
- for diffs_dir in AnkiDroid/build/outputs/roborazzi/*/diffs; do
- class_name="$(basename "$(dirname "$diffs_dir")")"
- compares=("$diffs_dir"/*_compare.png)
- if (( ${#compares[@]} )); then
- mkdir -p "screenshot-diff/$SUBDIR/$class_name"
- mv "${compares[@]}" "screenshot-diff/$SUBDIR/$class_name/"
- fi
- done
+ while IFS= read -r -d '' compare; do
+ module="${compare%%/build/reports/screenshot-diffs/*}"
+ relative="${compare#*/build/reports/screenshot-diffs/}"
+ destination="screenshot-diff/$SUBDIR/${module#./}/$relative"
+ mkdir -p "$(dirname "$destination")"
+ cp "$compare" "$destination"
+ done < <(find . -type f -path '*/build/reports/screenshot-diffs/*_compare.png' -print0)
- name: Upload screenshot diffs
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -132,4 +131,4 @@ jobs:
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: pr # downloaded by screenshot_comment.yml
- path: pr/
\ No newline at end of file
+ path: pr/
diff --git a/.github/workflows/screenshot_store.yml b/.github/workflows/screenshot_store.yml
index dc53a73d5829..56781679b1b1 100644
--- a/.github/workflows/screenshot_store.yml
+++ b/.github/workflows/screenshot_store.yml
@@ -26,14 +26,18 @@ on:
push:
branches:
- main
+ - dependency-updates
# On PRs, only run this when structural changes occur, to be sure 'main' can still store baselines
pull_request:
paths:
- '.github/workflows/screenshot_*.yml'
- 'gradle/libs.versions.toml'
- - 'AnkiDroid/build.gradle'
- - 'AnkiDroid/build.gradle.kts' # future-proof
+ - '**/build.gradle'
+ - '**/build.gradle.kts'
+ - 'settings.gradle.kts'
- '**/ScreenshotTest.kt'
+ - '**/ScreenshotTestCategory.kt'
+ - 'buildSrc/src/main/kotlin/ankidroid.plugins.screenshots.gradle.kts'
- 'tools/compare-screenshot-test.sh'
permissions: {}
@@ -41,7 +45,7 @@ permissions: {}
jobs:
store-screenshot-test:
# On PRs, this verifies the workflow runs on `main`, it's not a store
- name: ${{ github.ref == 'refs/heads/main' && 'store' || 'test' }}
+ name: ${{ github.event_name == 'push' && 'store' || 'test' }}
runs-on: ubuntu-latest
timeout-minutes: 20
@@ -70,20 +74,21 @@ jobs:
gradle-version: wrapper
- name: Warm Gradle Cache
- # This makes sure we fetch gradle network resources with a retry
- # TODO: --rerun-tasks in 'Record screenshots' discards the compilation performed here
+ # Keep compilation here: robolectricSdkDownload only downloads SDK jars.
+ # Compiling resolves test dependencies within the retry (Issue 21682).
+ # --rerun-tasks repeats compilation but reuses downloaded dependencies.
uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
with:
timeout_minutes: 10
retry_wait_seconds: 60
max_attempts: 3
- command: ./gradlew robolectricSdkDownload :AnkiDroid:compilePlayDebugUnitTestKotlin -Pscreenshot --daemon
+ command: ./gradlew robolectricSdkDownload compileScreenshotTests -Pscreenshot --daemon
- name: Record screenshots
id: record-test
run: |
# Use --rerun-tasks to disable cache for test task
- ./gradlew recordRoborazziPlayDebug -Pscreenshot --stacktrace --rerun-tasks
+ ./gradlew recordScreenshots -Pscreenshot --stacktrace --rerun-tasks
- name: Upload screenshot baseline
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
@@ -110,4 +115,4 @@ jobs:
name: screenshot-test-results
path: |
**/build/test-results
- retention-days: 30
\ No newline at end of file
+ retention-days: 30
diff --git a/.github/workflows/tests_emulator.yml b/.github/workflows/tests_emulator.yml
index a8e5dd5abd77..c6ed46ca5f78 100644
--- a/.github/workflows/tests_emulator.yml
+++ b/.github/workflows/tests_emulator.yml
@@ -68,13 +68,23 @@ jobs:
// try different architectures, targets, delays etc
let arch = ['x86_64'];
let target = ['google_apis', 'google_apis_ps16k'];
- // Keep API 37 and the 16 KB emulator off the per-change CI path.
- // Scheduled and manual runs exercise both API levels and targets.
+ let include = [];
+ // Keep API 37, the 16 KB emulator and the tablet off the per-change CI path.
+ // Scheduled and manual runs exercise both API levels and targets, plus a tablet.
if (context.eventName !== 'schedule' && context.eventName !== 'workflow_dispatch') {
target = ['google_apis'];
} else {
// Keep the minor version: SDK 37 packages use android-37.0.
apiLevel.push('37.0');
+ include = iterationArray.map((iteration) => ({
+ "iteration": iteration,
+ "api-level": 36,
+ "arch": "x86_64",
+ "target": "google_apis",
+ "first-boot-delay": 600,
+ "device": "tablet",
+ "profile": "pixel_tablet"
+ }));
}
let firstBootDelay = [600];
@@ -83,7 +93,9 @@ jobs:
"api-level": apiLevel,
"arch": arch,
"target": target,
- "first-boot-delay": firstBootDelay
+ "first-boot-delay": firstBootDelay,
+ "device": ["phone"],
+ "include": include
}
- name: Debug Output
run: echo "${{ steps.build-matrix.outputs.result }}"
@@ -91,7 +103,7 @@ jobs:
# This uses the matrix generated from the matrix-prep stage
# it will run unit tests on whatever OS combinations are desired
emulator_test:
- name: Android Emulator Test (${{ matrix.api-level }}, ${{ matrix.arch }}, ${{ matrix.target }}, ${{ matrix.first-boot-delay }}, ${{ matrix.iteration }})
+ name: Android Emulator Test (${{ matrix.api-level }}, ${{ matrix.arch }}, ${{ matrix.target }}, ${{ matrix.first-boot-delay }}, ${{ matrix.iteration }}${{ matrix.device == 'tablet' && ', tablet' || '' }})
runs-on: ubuntu-latest
needs: matrix_prep
timeout-minutes: 75
@@ -152,7 +164,7 @@ jobs:
# Cache only the per-change emulator: the 16 KB snapshot costs about 2.5 GiB.
- name: AVD cache
- if: matrix.target == 'google_apis'
+ if: matrix.target == 'google_apis' && matrix.device == 'phone'
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
id: avd-cache
with:
@@ -163,6 +175,9 @@ jobs:
restore-keys: |
avd-${{ matrix.api-level }}-${{ matrix.arch }}-${{matrix.target}}-v2
+ - name: Pin Build Time
+ run: echo "ORG_GRADLE_PROJECT_buildTime=$(date +%s000)" >> "$GITHUB_ENV"
+
- name: Warm Gradle Cache
# This makes sure we fetch gradle network resources with a retry
uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
@@ -202,15 +217,16 @@ jobs:
"${ANDROID_HOME:?}/system-images/android-${SDK_API_LEVEL:?}/${SDK_TARGET:?}/${SDK_ARCH:?}/.installer"
- name: AVD Boot and Snapshot Creation
- # Warm up the uncached 16 KB emulator on every run; otherwise only on main cache misses.
+ # Warm up the uncached 16 KB and tablet emulators on every run; otherwise only on main cache misses.
# Comment the if out to generate snapshots on branch for performance testing
- if: "${{ matrix.target == 'google_apis_ps16k' || (steps.avd-cache.outputs.cache-hit != 'true' && github.ref == 'refs/heads/main') }}"
+ if: "${{ matrix.target == 'google_apis_ps16k' || matrix.device == 'tablet' || (steps.avd-cache.outputs.cache-hit != 'true' && github.ref == 'refs/heads/main') }}"
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0
with:
api-level: ${{ matrix.api-level }}
force-avd-creation: false
target: ${{ matrix.target }}
arch: ${{ matrix.arch }}
+ profile: ${{ matrix.profile }}
emulator-options: ${{ env.EMULATOR_OPTIONS }}
ram-size: ${{ env.EMULATOR_RAM_SIZE }}
pre-emulator-launch-script: ${{ env.EMULATOR_PRE_LAUNCH_SCRIPT }}
@@ -225,10 +241,10 @@ jobs:
# This step is separate so pure install time may be calculated as a step
- name: Emulator Snapshot After Firstboot Warmup
- # Always warm up the 16 KB emulator. The default emulator only needs it on main cache misses.
+ # Always warm up the 16 KB and tablet emulators. The default emulator only needs it on main cache misses.
# Switch the if statements via comment if generating snapshots for performance testing
# if: matrix.first-boot-delay != '0'
- if: "${{ matrix.target == 'google_apis_ps16k' || (steps.avd-cache.outputs.cache-hit != 'true' && github.ref == 'refs/heads/main') }}"
+ if: "${{ matrix.target == 'google_apis_ps16k' || matrix.device == 'tablet' || (steps.avd-cache.outputs.cache-hit != 'true' && github.ref == 'refs/heads/main') }}"
env:
FIRST_BOOT_DELAY: ${{ matrix.first-boot-delay }}
uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0
@@ -237,6 +253,7 @@ jobs:
force-avd-creation: false
target: ${{ matrix.target }}
arch: ${{ matrix.arch }}
+ profile: ${{ matrix.profile }}
emulator-options: ${{ env.EMULATOR_OPTIONS }}
ram-size: ${{ env.EMULATOR_RAM_SIZE }}
pre-emulator-launch-script: ${{ env.EMULATOR_PRE_LAUNCH_SCRIPT }}
@@ -260,6 +277,7 @@ jobs:
force-avd-creation: false
target: ${{ matrix.target }}
arch: ${{ matrix.arch }}
+ profile: ${{ matrix.profile }}
emulator-options: -no-snapshot-save ${{ env.EMULATOR_OPTIONS }}
ram-size: ${{ env.EMULATOR_RAM_SIZE }}
pre-emulator-launch-script: ${{ env.EMULATOR_PRE_LAUNCH_SCRIPT }}
@@ -274,11 +292,31 @@ jobs:
bash .github/adb_retry.sh emu screenrecord start --time-limit 1800 video.webm
sleep 5
bash .github/with_emulator_log.sh adb-log.txt python3 .github/run_emulator_tests.py
- TEST_RELEASE_BUILD=false ./gradlew :baselineprofile:connectedBenchmarkBenchmarkAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.ichi2.anki.baselineprofile.ReleaseSmokeTest -Pandroid.testInstrumentationRunnerArguments.releaseSmokeTest=true --daemon
- name: Report Emulator Test Results
if: always() && hashFiles('AnkiDroid/build/outputs/androidTest-results/connected/**/TEST-*.xml') != ''
- run: python3 .github/report_test_failures.py AnkiDroid/build/outputs/androidTest-results/connected adb-log.txt --title "Emulator test failures"
+ run: python3 .github/report_test_failures.py AnkiDroid/build/outputs/androidTest-results/connected adb-log.txt --title "Emulator test failures" --suite ":AnkiDroid:connectedPlayReleaseAndroidTest"
+
+ - name: Build Release Smoke Test
+ run: TEST_RELEASE_BUILD=false ./gradlew packagePlayRelease :baselineprofile:packageBenchmarkBenchmark --daemon
+
+ - name: Run Release Smoke Test
+ uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0
+ timeout-minutes: 10
+ with:
+ api-level: ${{ matrix.api-level }}
+ force-avd-creation: false
+ target: ${{ matrix.target }}
+ arch: ${{ matrix.arch }}
+ emulator-options: -no-snapshot-save ${{ env.EMULATOR_OPTIONS }}
+ ram-size: ${{ env.EMULATOR_RAM_SIZE }}
+ pre-emulator-launch-script: ${{ env.EMULATOR_PRE_LAUNCH_SCRIPT }}
+ sdcard-path-or-size: 100M
+ disable-animations: true
+ script: |
+ bash .github/disable_art_metrics.sh
+ bash .github/adb_retry.sh shell settings put global hide_error_dialogs 1
+ TEST_RELEASE_BUILD=false ./gradlew :baselineprofile:connectedBenchmarkBenchmarkAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.ichi2.anki.baselineprofile.ReleaseSmokeTest -Pandroid.testInstrumentationRunnerArguments.releaseSmokeTest=true --daemon
- name: Configure NDK tools PATH for alignment checking
run: |
@@ -332,21 +370,21 @@ jobs:
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
- name: ${{ matrix.api-level }}-${{ matrix.arch }}-${{matrix.target}}-${{matrix.first-boot-delay}}-${{matrix.iteration}}-jacocoAndroidTestReport
+ name: ${{ matrix.api-level }}-${{ matrix.arch }}-${{matrix.target}}-${{matrix.first-boot-delay}}-${{matrix.iteration}}-${{ matrix.device }}-jacocoAndroidTestReport
path: ~/work/Anki-Android/Anki-Android/AnkiDroid/build/reports/jacoco/
- name: Upload Emulator Log
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
- name: ${{ matrix.api-level }}-${{ matrix.arch }}-${{matrix.target}}-${{matrix.first-boot-delay}}-${{matrix.iteration}}-adb_logs
+ name: ${{ matrix.api-level }}-${{ matrix.arch }}-${{matrix.target}}-${{matrix.first-boot-delay}}-${{matrix.iteration}}-${{ matrix.device }}-adb_logs
path: adb-log.txt
- name: Upload Emulator Screen Record
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
- name: ${{ matrix.api-level }}-${{ matrix.arch }}-${{matrix.target}}-${{matrix.first-boot-delay}}-${{matrix.iteration}}-adb_video
+ name: ${{ matrix.api-level }}-${{ matrix.arch }}-${{matrix.target}}-${{matrix.first-boot-delay}}-${{matrix.iteration}}-${{ matrix.device }}-adb_video
path: video.webm
- uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
diff --git a/AnkiDroid/build.gradle.kts b/AnkiDroid/build.gradle.kts
index ebcf51b3d8e0..772789c94c5b 100644
--- a/AnkiDroid/build.gradle.kts
+++ b/AnkiDroid/build.gradle.kts
@@ -12,13 +12,13 @@ plugins {
// Gradle plugin portal
alias(libs.plugins.tripletPlay)
id("ankidroid.android.app")
+ id("ankidroid.android.compose")
id("ankidroid.plugins.jacoco")
id("ankidroid.plugins.robolectric-downloader")
alias(libs.plugins.androidx.baselineprofile)
alias(libs.plugins.kotlin.serialization)
- alias(libs.plugins.kotlin.compose)
alias(libs.plugins.keeper)
- alias(libs.plugins.roborazzi)
+ id("ankidroid.plugins.screenshots")
id("idea")
}
@@ -89,7 +89,6 @@ android {
aidl = true
viewBinding = true
resValues = true
- compose = true
}
if (testReleaseBuild) {
@@ -131,9 +130,9 @@ android {
//
// This ensures the correct ordering between the various types of releases (dev < alpha < beta < release) which is
// needed for upgrades to be offered correctly.
- versionCode = 22600104
+ versionCode = 22600106
// If you change this to a new version, you probably also want to update .gradle/workflows/milestone.yml for the new version...
- versionName = "2.26.0alpha4"
+ versionName = "2.26.0alpha6"
testApplicationId = "com.ichi2.anki.tests"
vectorDrawables.useSupportLibrary = true
testInstrumentationRunner = "com.ichi2.testutils.NewCollectionPathTestRunner"
@@ -334,8 +333,6 @@ android {
test.useJUnitPlatform {
// ./gradlew testFullDebugUnitTest -Pscreenshot
if (project.hasProperty("screenshot")) {
- includeTags("com.ichi2.anki.ScreenshotTestCategory")
-
// ./gradlew :AnkiDroid:recordRoborazziPlayDebug -Pscreenshot -Ptheme=black -Pdevice=tablet,foldable
val theme = project.findProperty("theme")?.takeIf { it.toString().isNotEmpty() } ?: "light"
val device = project.findProperty("device")?.takeIf { it.toString().isNotEmpty() } ?: "phone"
@@ -358,8 +355,6 @@ android {
test.systemProperty("screenshot.theme", theme)
test.systemProperty("screenshot.device", device)
- } else {
- excludeTags("com.ichi2.anki.ScreenshotTestCategory")
}
// Ensures that no EmptyApplication test relies on AnkiDroidApp
// by only running EmptyApplication tests
@@ -751,6 +746,7 @@ dependencies {
implementation(project(":common:android"))
implementation(project(":compat"))
implementation(project(":libanki"))
+ implementation(project(":note-editor"))
implementation(project(":vbpd"))
"baselineProfile"(project(":baselineprofile"))
implementation(project(":widgets"))
@@ -758,17 +754,9 @@ dependencies {
implementation(libs.google.analytics.kt)
implementation(libs.androidx.activity)
- // TODO: hoist the Compose enablement (kotlin.compose plugin, buildFeatures.compose,
- // and the dependencies below) into a convention plugin under `buildSrc/` once the
- // Kotlin-DSL migration of this build script lands
implementation(libs.androidx.activity.compose)
- implementation(platform(libs.androidx.compose.bom))
- implementation(libs.androidx.compose.ui)
- implementation(libs.androidx.compose.ui.tooling.preview)
- implementation(libs.androidx.compose.material3)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
- debugImplementation(libs.androidx.compose.ui.tooling)
implementation(libs.androidx.annotation)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.browser)
@@ -855,9 +843,6 @@ dependencies {
// ankidroid.plugins.robolectric-downloader may need a new SDK jar entry
// when Robolectric or targetSdk changes. See the instructions in that plugin.
testImplementation(libs.robolectric)
- testImplementation(libs.androidx.compose.ui.test.junit4)
- // Needed for createComposeRule(), but not for createAndroidComposeRule()
- debugImplementation(libs.androidx.compose.ui.test.manifest)
testImplementation(libs.androidx.test.core)
testImplementation(libs.androidx.test.junit)
testImplementation(libs.kotlin.reflect)
@@ -908,10 +893,4 @@ dependencies {
// Required so the ExperimentalCoroutinesApi opt-in (applied globally) doesn't cause
// an "unresolved" warning, which is treated as an error due to allWarningsAsErrors
testFixturesImplementation(libs.kotlinx.coroutines.core)
- // The Kotlin Compose Compiler plugin attaches to every Kotlin compilation in the
- // module including testFixtures, which has no @Composable code and refuses to
- // run unless the Compose Runtime is on the classpath. compileOnly satisfies the
- // plugin's version check without shipping the runtime in the testFixtures output.
- testFixturesCompileOnly(platform(libs.androidx.compose.bom))
- testFixturesCompileOnly(libs.androidx.compose.runtime)
}
diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/DeckPickerImportTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/DeckPickerImportTest.kt
new file mode 100644
index 000000000000..bfba6bbbd07a
--- /dev/null
+++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/DeckPickerImportTest.kt
@@ -0,0 +1,140 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki
+
+import androidx.core.content.edit
+import androidx.test.core.app.ActivityScenario
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.assertion.ViewAssertions.matches
+import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
+import androidx.test.espresso.matcher.ViewMatchers.withText
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.ichi2.anki.CollectionManager.withCol
+import com.ichi2.anki.common.preferences.sharedPrefs
+import com.ichi2.anki.dialogs.DialogHandler
+import com.ichi2.anki.tests.InstrumentedTest
+import com.ichi2.anki.testutil.GrantStoragePermission.storagePermission
+import com.ichi2.anki.testutil.disableIntroductionSlide
+import com.ichi2.anki.testutil.grantPermissions
+import com.ichi2.anki.testutil.notificationPermission
+import com.ichi2.anki.testutil.waitUntil
+import com.ichi2.utils.ImportUtils
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
+import org.junit.After
+import org.junit.Assume.assumeTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+@RunWith(AndroidJUnit4::class)
+class DeckPickerImportTest : InstrumentedTest() {
+ @get:Rule
+ val runtimePermissionRule = grantPermissions(storagePermission, notificationPermission)
+
+ private val preferences get() = testContext.sharedPrefs()
+ private val hadBackupPromptPreference = preferences.contains("backupPromptDisabled")
+ private val backupPromptDisabled = preferences.getBoolean("backupPromptDisabled", false)
+
+ @Test
+ fun importConfirmationSurvivesDelayedTabletStartup() {
+ DialogHandler.storeMessage(ImportUtils.CollectionImportAdd("deck.apkg").toMessage())
+
+ withDelayedStartup { scenario, resumeStartup ->
+ assertImportConfirmationVisible()
+ scenario.assertStartupWaiting()
+
+ resumeStartup()
+ scenario.awaitStartup()
+
+ assertImportConfirmationVisible()
+ }
+ }
+
+ @Before
+ fun prepareTablet() {
+ // The study options side panel is selected by layout-sw600dp.
+ assumeTrue("Requires the tablet layout", testContext.resources.configuration.smallestScreenWidthDp >= 600)
+ disableIntroductionSlide()
+ InitialActivity.setUpgradedToLatestVersion(preferences)
+ preferences.edit { putBoolean("backupPromptDisabled", true) }
+ addNoteUsingBasicNoteType()
+ }
+
+ @After
+ fun restorePreferencesAndPendingDialogs() {
+ DialogHandler.storeMessage(null)
+ preferences.edit {
+ if (hadBackupPromptPreference) {
+ putBoolean("backupPromptDisabled", backupPromptDisabled)
+ } else {
+ remove("backupPromptDisabled")
+ }
+ }
+ }
+
+ private fun withDelayedStartup(test: suspend (scenario: ActivityScenario, resumeStartup: suspend () -> Unit) -> Unit) =
+ runBlocking {
+ val collectionQueueHeld = CountDownLatch(1)
+ val releaseStartup = CountDownLatch(1)
+ val pendingCollectionOperation =
+ launch(Dispatchers.IO) {
+ withCol {
+ collectionQueueHeld.countDown()
+ check(releaseStartup.await(30, TimeUnit.SECONDS)) { "Startup was not released" }
+ }
+ }
+ var scenario: ActivityScenario? = null
+ try {
+ assertTrue(collectionQueueHeld.await(10, TimeUnit.SECONDS), "The collection queue must be held before launching")
+ val activityScenario = ActivityScenario.launch(DeckPicker::class.java)
+ scenario = activityScenario
+ test(activityScenario) {
+ releaseStartup.countDown()
+ pendingCollectionOperation.join()
+ }
+ } finally {
+ releaseStartup.countDown()
+ withContext(NonCancellable) {
+ pendingCollectionOperation.join()
+ scenario?.close()
+ }
+ }
+ }
+
+ private fun ActivityScenario.assertStartupWaiting() {
+ onActivity { activity ->
+ assertTrue(activity.fragmented, "The tablet side panel must be present")
+ assertNull(activity.fragment, "Study options must not have been initialized yet")
+ assertTrue(activity.viewModel.startupJob?.isActive == true, "Startup must still be waiting")
+ }
+ }
+
+ private fun ActivityScenario.awaitStartup() {
+ waitUntil(message = { "Startup did not finish initializing study options" }) {
+ var finished = false
+ onActivity { activity ->
+ finished =
+ activity.viewModel.startupJob?.isCompleted == true &&
+ activity.viewModel.loadDeckCounts?.isCompleted == true &&
+ activity.fragment != null
+ }
+ finished
+ }
+ }
+
+ private fun assertImportConfirmationVisible() {
+ onView(withText(testContext.getString(CommonString.import_dialog_message_add, "deck.apkg")))
+ .check(matches(isDisplayed()))
+ onView(withText(CommonString.import_message_add))
+ .check(matches(isDisplayed()))
+ }
+}
diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/cardviewer/HtmlTypeAnswerReviewerTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/cardviewer/HtmlTypeAnswerReviewerTest.kt
index 8dd588fb39a0..23f26fae8e1f 100644
--- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/cardviewer/HtmlTypeAnswerReviewerTest.kt
+++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/cardviewer/HtmlTypeAnswerReviewerTest.kt
@@ -2,6 +2,7 @@
package com.ichi2.anki.cardviewer
+import android.app.Activity
import android.text.InputType
import android.view.inputmethod.EditorInfo
import android.webkit.WebView
@@ -17,6 +18,7 @@ import com.ichi2.anki.Reviewer
import com.ichi2.anki.common.preferences.sharedPrefs
import com.ichi2.anki.libanki.DeckId
import com.ichi2.anki.libanki.NoteTypeId
+import com.ichi2.anki.previewer.CardViewerActivity
import com.ichi2.anki.tests.InstrumentedTest
import com.ichi2.anki.tests.checkWithTimeout
import com.ichi2.anki.testutil.AvoidDayRolloverRule
@@ -27,6 +29,7 @@ import com.ichi2.anki.testutil.ensureWebViewIsSupported
import com.ichi2.anki.testutil.focusInput
import com.ichi2.anki.testutil.grantPermissions
import com.ichi2.anki.testutil.notificationPermission
+import com.ichi2.anki.ui.windows.reviewer.ReviewerFragment
import org.junit.After
import org.junit.Before
import org.junit.Rule
@@ -34,13 +37,18 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
import java.util.UUID
+import kotlin.test.assertIs
import kotlin.test.assertNotNull
-/** Checks the legacy reviewer's HTML, script loading, and WebView keyboard support together. */
+/** Checks the real reviewers' HTML, script loading, and WebView keyboard support together. */
@RunWith(Parameterized::class)
class HtmlTypeAnswerReviewerTest : InstrumentedTest() {
@JvmField // required for Parameter
@Parameterized.Parameter
+ var newReviewer = false
+
+ @JvmField // required for Parameter
+ @Parameterized.Parameter(1)
var autoFocus = false
@get:Rule
@@ -72,7 +80,7 @@ class HtmlTypeAnswerReviewerTest : InstrumentedTest() {
originalPreferences =
listOf(newReviewerKey, "useInputTag", "autoFocusTypeInAnswer", doubleTapKey).associateWith { preferences.all[it] }
preferences.edit {
- putBoolean(newReviewerKey, false)
+ putBoolean(newReviewerKey, newReviewer)
putBoolean("useInputTag", true)
putBoolean("autoFocusTypeInAnswer", autoFocus)
// These tests intentionally grade immediately after Done.
@@ -134,6 +142,8 @@ class HtmlTypeAnswerReviewerTest : InstrumentedTest() {
@Test
fun onlyTheMarkedAnswerDisablesSuggestions() =
withReviewer { webView ->
+ // Let reviewer autofocus finish before moving to another field.
+ focusAnswer(webView, noSuggest = true)
webView.focusInput("ordinary")
val baseline = keyboard.awaitEditor().inputType
webView.focusInput("typeans")
@@ -165,7 +175,7 @@ class HtmlTypeAnswerReviewerTest : InstrumentedTest() {
keyboard.perform("done")
webView.awaitJavascript("document.querySelector('#typeans .typeGood')?.textContent === 'Γ©tΓ©'")
if (question != "Third") {
- onView(withId(R.id.flashcard_layout_ease3))
+ onView(withId(if (newReviewer) R.id.good_button else R.id.flashcard_layout_ease3))
.also { it.checkWithTimeout(matches(isDisplayed())) }
.perform(click())
}
@@ -190,10 +200,16 @@ class HtmlTypeAnswerReviewerTest : InstrumentedTest() {
}
private fun withReviewer(block: (WebView) -> Unit) {
- ActivityScenario.launch(Reviewer.getIntent(testContext)).use { scenario ->
+ ActivityScenario.launch(Reviewer.getIntent(testContext)).use { scenario ->
lateinit var webView: WebView
scenario.onActivity { activity ->
- webView = assertNotNull(activity.webView)
+ webView =
+ if (newReviewer) {
+ val fragment = assertIs(assertIs(activity).fragment)
+ assertIs(fragment.binding.webViewLayout.getChildAt(0))
+ } else {
+ assertNotNull(assertIs(activity).webView)
+ }
}
webView.awaitJavascript("document.getElementById('typeans') !== null")
block(webView)
@@ -202,7 +218,7 @@ class HtmlTypeAnswerReviewerTest : InstrumentedTest() {
companion object {
@JvmStatic // required for Parameters
- @Parameterized.Parameters(name = "autoFocus={0}")
- fun focusModes() = listOf(false, true)
+ @Parameterized.Parameters(name = "newReviewer={0}, autoFocus={1}")
+ fun reviewers() = listOf(arrayOf(false, false), arrayOf(false, true), arrayOf(true, false), arrayOf(true, true))
}
}
diff --git a/AnkiDroid/src/main/assets/changelog.html b/AnkiDroid/src/main/assets/changelog.html
index 161320a39136..ea5c5d394647 100644
--- a/AnkiDroid/src/main/assets/changelog.html
+++ b/AnkiDroid/src/main/assets/changelog.html
@@ -464,6 +464,605 @@
AnkiDroid Changelog
+
+
Version 2.26.0alpha6 is a test build. Its changelog comes with the stable release. Until then, here are the changes in the latest stable version.
+
+
+
Version 2.25.3 (20261017)
+
+
+
Hello everyone out there in AnkiDroid-land π
+
+
+
We’re already moving right along on work for 2.26 but we are always listening for and
+fixing issues when we hear about them - hopefully this patch release gets rid of some
+pain points for everyone
+
+
+
+
+-
+
Deck Picker: Fix the import confirmation disappearing on tablets during startup
+
+-
+
Reviewing: Re-enable HTTP(s) links - our security fixes went too far
+
+-
+
Note Editor: Fix filename normalization issues when pasting media
+
+-
+
Text-to-speech: Report network errors and timeouts
+
+-
+
App-wide: Fix crashes if widgets are unsupported
+
+
+
+
+
+
+
+
Version 2.25.2 (20261013)
+
+
+
…aaaaand we’re back! Google has officially blessed our releases and allowed them
+out in the wild again after our donation link kerfuffle. In fact, literally millions
+of you (nearly 3 million and counting) are already on the new 2.25 release series
+
+
+
For any of you that have opted in to crash reporting, thank you! That’s one of the main
+ways we detect new problems, and your reward is this fresh batch of fixes
+
+
+
+
+-
+
Deck Picker: Fix 'one way sync' disappearing on tablets
+
+-
+
Text-to-speech: Handle AnkiDroid crashing when accessing faulty TTS Engines
+
+-
+
Reviewer: Fix TTS freezes when leaving the study screen
+
+-
+
Performance: Fix various freezes while syncing
+
+-
+
Sync: Fix errors when closing the app
+
+-
+
App-wide: Fix some Android region settings blocking backend translations
+
+-
+
App-wide: Fix random freezes on some devices (Custom Tabs issue)
+
+-
+
App-wide: Fix crashes when closing/rotating screens
+
+-
+
Internationalization: Fix tablet panes resizing in the opposite direction
+
+
+
+
+
+
+
+
Version 2.25.1 (20261006)
+
+
+
Wait what!? What is this? A 2.25.1 and you didn’t even get 2.25.0 yet!
+
+
+
That’s right! 2.25.0 did ship but Google wouldn’t release it because…well, honestly
+we’re not sure, their approval / rejection process is completely opaque! All we know is that
+it originally had to do with them rejecting our donations link. And now it appears they’re
+rejecting old versions! But we do what we can. And we can keep trying. So here’s 2.25.1!
+
+
+
With sincere wishes all of our lovely AnkiDroid users get to enjoy it βΊοΈ π«Ά
+
+
+
+
Here’s our 2.25.1 bugfix list:
+
+
+
+-
+
Study screen: Preserve scroll position when flipping a card
+
+-
+
Study screen: Reset zoom when changing cards
+
+-
+
Reviewing: Fix MathJax font scaling (may change the layout of some cards)
+
+-
+
Reviewing: Fix crash on close after a WebView failure
+
+-
+
Text-to-speech: Prefer installed voices (fixes issues on Pixel devices)
+
+-
+
Text-to-speech: Performance improvements for legacy TTS
+
+-
+
Card Browser: Fix crashes while scrolling
+
+-
+
Card Browser: Preserve selection in more cases
+
+-
+
Card Browser: Fix icon tint being applied globally
+
+-
+
Tags: Fix parent tags remaining selected after being unchecked
+
+-
+
Tags: Fix checkbox states when editing multiple notes or filtering tags
+
+-
+
Tags: Fix ordering (partially selected tags at the top)
+
+-
+
Tags: Fix tags being changed on scroll
+
+-
+
Tags: Better performance
+
+-
+
Note Editor: Handle 'Restore to Default' in the template editor
+
+-
+
Note Editor: Match Anki Desktop when switching to a note type without an deck
+
+-
+
Note Types: Update state after undo/redo
+
+-
+
Various screens: Better edge to edge UI support
+
+-
+
Sync: Fix crash on long background syncs
+
+-
+
Sync: Fix undismissable notification sometimes appearing after a completed sync
+
+-
+
Deck Options: Prevent scrolling from changing Easy Days
+
+-
+
Deck Picker Widget: Make the whole deck row tappable
+
+-
+
Widgets: Improve layout with large fonts and small widget sizes
+
+-
+
Widgets: Hide cut-off rows and refresh the deck list when resized
+
+-
+
Widgets: Fix incorrect colors on load (Android 12+)
+
+-
+
App-wide: Reduce unnecessary data saved when moving app data to disk
+
+-
+
App-wide: Fix crash when TMPDIR is unavailable
+
+
+
+
+
+
+
+
Version 2.25.0 (20260927)
+
+
+
Hey hey hey! It’s new AnkiDroid version time ππ. We’ve got a bunch of new features for you, plus the usual
+mix of security fixes, bug fixes and quality of life improvements. We hope you enjoy it.
+
+
+
Removal of donation links: Please see GitHub Issue 21656 for why this has happened and how to help us with this.
+
+
+
Google Summer of Code 2026
+
+
We participated in Google Summer of Code 2026!
+
+
+
+
+
Security
+
+
A special thank you to the following people for reporting the security issues fixed in this release:
+
+
+
+
+-
+
Reviewer: cards can no longer open dangerous links [GHSL-2026-085, GHSA-54q9-5c5p-9rxg]
+
+-
+
Reviewer: cards require permission to launch apps [GHSL-2026-085, GHSA-54q9-5c5p-9rxg]
+
+
+-
+
JavaScript API: blocked calls which could read and modify cards (other than the currently selected card) [GHSL-2026-084, GHSA-54q9-5c5p-9rxg]
+
+
+-
+
Reviewer: fix path traversal when loading media [GHSL-2026-083, GHSA-qjjx-px4w-4986]
+
+-
+
Import: sanitize filenames to block path traversal [GHSA-q29p-h3pp-mh3v]
+
+-
+
Note Editor: reject untrusted files from the image picker [GHSA-cr2g-gf75-cm6r]
+
+
+
+
+
+
AnkiDroid features
+
+
+-
+
Design: All screens now support edge to edge (Android requirement)
+
+
+-
+
Themes: E-ink theme & improved theme selection.
+
+-
+
Card Browser: Find & Replace support.
+
+-
+
Card Browser: Improved sort screen, with new columns to sort by.
+
+-
+
TTS: Support all installed engines.
+
+-
+
Analytics: Analytics are fixed. Analytics are opt-in only, and all opt-in flags have been reset.
+
+-
+
Deck Picker: Better designed floating action button.
+
+-
+
New Study Screen: Card templates can record audio.
+
+-
+
Performance: Whiteboard and drawing now save 'webp' files.
+
+-
+
Change note type: Huge UI improvements.
+
+
+
+
+
+
Anki 26.05 features
+
+
+-
+
New Cloze syntax: {{c1,2,3::text}} to hide text on cards 1, 2 and 3. (manual)
+
+-
+
Reviewing: Card content determines RTL direction (dir="auto") (source)
+
+-
+
Search: syntax for ignoring accents: tag:nc:.
+
+-
+
Image Occlusion: improved RTL layout.
+
+-
+
Image Occlusion: display text annotations during review.
+
+-
+
CSV import: supplying a note-type column allows differing field counts.
+
+-
+
Deck Options: UI improvements on smaller screens.
+
+-
+
FSRS: faster calculations and better spacing of siblings.
+
+-
+
FSRS: suspended cards are excluded from rescheduling.
+
+-
+
Media: better handling of case-sensitive filenames.
+
+-
+
See more in the Anki 26.05 changelog.
+
+
+
+
+
+
Fixes and improvements
+
+
+-
+
Google Play: 'Donate' links removed to comply with Play policies
+
+-
+
Custom study: Fix 'due cards' not working when tag selection is skipped.
+
+-
+
Deck Picker: A deck is selected when it is created.
+
+-
+
Deck Picker: Better navigation via arrow keys.
+
+-
+
Deck Picker: Improved 'Rename deck' dialog UX.
+
+-
+
Deck Picker: Fix counts when totals above 9,999.
+
+-
+
Shared Deck Download: Improved retry logic.
+
+-
+
Card Browser: UX: better selection restore and RTL design.
+
+-
+
Filtered Decks: UX Improvements.
+
+-
+
Filtered Decks: Handle state restoration after process death.
+
+-
+
Reviewer: Fix the shake gesture firing rapidly.
+
+-
+
Previewer: Fixed background in night mode.
+
+-
+
Study Options: Fix 'undo' opening the reviewer.
+
+-
+
Deck Options: Improved progress reporting.
+
+-
+
Note Editor: Fewer 'unsaved changes' prompts.
+
+-
+
Card Template Editor: Better focus handling.
+
+-
+
Set Due Date: Fixed crash when opening.
+
+-
+
WebViews: Improved outdated WebView detection.
+
+-
+
Deck Picker: Fixed "deck not found in limits map" error.
+
+-
+
API: Fixed a crash in answerCard.
+
+-
+
New Study Screen: better handling of custom scheduling and app restoration.
+
+-
+
Widgets: UI improvements.
+
+-
+
Note Editor: Fix out of memory crashes.
+
+-
+
Accessibility: Improvements for Card Browser and Reviewer.
+
+
+
+
+
+
+
+
Version 2.24.1 (20260831)
+
+
+
Urgent update to our 2.24.0 release: security updates, and temporarily removing donation links to
+comply with Play Store policies (more below).
+
+
+
A special thank you to the following people for reporting the security issues fixed in this release:
+
+
+
+
Removal of donation links: Please see GitHub Issue 21656 for why this has happened and how
+to help us with this.
+
+
+
Please see the 2.24.0 changelog below for the main 2.24 features announcement π
+
+
+
+-
+
Security
+
+
+-
+
Reviewer: cards can no longer open dangerous links [GHSL-2026-085, GHSA-54q9-5c5p-9rxg]
+
+-
+
Reviewer: cards require permission to launch apps [GHSL-2026-085, GHSA-54q9-5c5p-9rxg]
+
+
+-
+
JavaScript API: blocked calls which could read and modify cards (other than the currently selected card) [GHSL-2026-084, GHSA-54q9-5c5p-9rxg]
+
+
+-
+
Reviewer: fix path traversal when loading media [GHSL-2026-083, GHSA-qjjx-px4w-4986]
+
+-
+
Import: sanitize filenames to block path traversal [GHSA-q29p-h3pp-mh3v]
+
+-
+
Note Editor: reject untrusted files from the image picker [GHSA-cr2g-gf75-cm6r]
+
+
+
+
+-
+
Improvements
+
+
+-
+
Privacy: removed a log entry which could hint at your timezone
+
+-
+
Card Browser: better 'invalid search' messages
+
+-
+
Google Play: 'Donate' links removed to comply with Play policies
+
+
+
+
+-
+
Fixes
+
+
+-
+
Deck Picker & Card Browser: Fix memory leaks
+
+-
+
Fix crash when sharing an image to the Note Editor
+
+-
+
Fix crash when exporting a large number of notes
+
+-
+
Fix 'Auto Advance' not following the review queue
+
+-
+
Improve error names in error messages
+
+-
+
Whiteboard: fix flickering [Samsung/OnePlus]
+
+-
+
Whiteboard: fix crash on rotation when 'type in the answer' is open
+
+-
+
Note Editor: fix 'MathJax block' syntax
+
+-
+
Note Types: only enable 'Save' when there are unsaved changes
+
+-
+
Sync: fix conflict dialog only showing its title [Samsung-only]
+
+-
+
Export: sharing text is now a text/plain file
+
+-
+
Settings: fix crash when dragging reviewer actions and switching screens
+
+-
+
Settings: include 'Themes' in search results
+
+-
+
Reviewer: fix snackbar position when answer buttons are hidden
+
+
+
+
+
+
+
+
+
Version 2.24.0 (20260502)
+
Version 2.19.5 (20250711)
+
Version 2.16.5 (20230906)