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/config.yml b/.github/config.yml
index 22c2f778b71b..32f22e6eacd2 100644
--- a/.github/config.yml
+++ b/.github/config.yml
@@ -5,12 +5,14 @@
# Comment to be posted to on first time issues
newIssueWelcomeComment: >
Hello! 👋 Thanks for logging this issue. Please remember we are all volunteers here, so some patience may be required before we can get to the issue. Also remember that the fastest way to get resolution on an issue is to propose a change directly, https://github.com/ankidroid/Anki-Android/wiki/Contributing
+ Please read our [AI Policy](https://github.com/ankidroid/Anki-Android/blob/main/AI_POLICY.md) before contributing.
# Configuration for new-pr-welcome - https://github.com/behaviorbot/new-pr-welcome
# Comment to be posted to on PRs from first time contributors in your repository
newPRWelcomeComment: >
First PR! 🚀 We sincerely appreciate that you have taken the time to propose a change to AnkiDroid! Please have patience with us as we are all volunteers - we will get to this as soon as possible.
+ Please ensure your contribution meets our [AI Policy](https://github.com/ankidroid/Anki-Android/blob/main/AI_POLICY.md) before contributing.
# Configuration for first-pr-merge - https://github.com/behaviorbot/first-pr-merge
diff --git a/.github/report_test_failures.py b/.github/report_test_failures.py
index 718bcdbbfe3a..7acc7ed803a3 100644
--- a/.github/report_test_failures.py
+++ b/.github/report_test_failures.py
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later
-"""Report failures and skipped tests from JUnit XML using the standard library."""
+"""Report failures, skipped tests, and overview summary from JUnit XML using the standard library."""
import argparse
import html
@@ -12,6 +12,19 @@
import xml.etree.ElementTree as ET
+TABLE_HEADER = (
+ "## Test Results\n\n"
+ "| Suite | Result | Duration | Tests | Passed | Failed | Skipped |\n"
+ "| ----- | ------ | -------- | ----- | ------ | ------ | ------- |\n"
+)
+
+
+def format_duration(seconds):
+ minutes = int(seconds // 60)
+ rem_seconds = seconds % 60
+ return f"{minutes:02d}m {rem_seconds:06.3f}s"
+
+
def read_ignored_tests(log_file):
ignored = set()
failed = set()
@@ -26,22 +39,61 @@ def read_ignored_tests(log_file):
return ignored - failed
-def main(results_dir, log_file=None, pattern="TEST-*.xml", title="Test failures"):
+def get_report_duration(root):
+ suites = root.findall("testsuite")
+ if suites:
+ total = 0.0
+ for s in suites:
+ try:
+ total += float(s.get("time", 0) or 0)
+ except (ValueError, TypeError):
+ pass
+ if total > 0.0:
+ return total
+ try:
+ root_time = float(root.get("time", 0) or 0)
+ if root_time > 0.0:
+ return root_time
+ except (ValueError, TypeError):
+ pass
+ total = 0.0
+ for tc in root.iter("testcase"):
+ try:
+ total += float(tc.get("time", 0) or 0)
+ except (ValueError, TypeError):
+ pass
+ return total
+
+
+def main(results_dir, log_file=None, pattern="TEST-*.xml", title="Test failures", suite=None):
failures = []
skipped = []
ignored = read_ignored_tests(log_file)
+ total_duration = 0.0
+ passed_count = 0
+ failed_count = 0
+ skipped_count = 0
+
for report in sorted(results_dir.rglob(pattern)):
try:
root = ET.parse(report).getroot()
except (ET.ParseError, OSError) as error:
- failures.append(f"Could not read {report}: {error}")
+ if os.environ.get("GITHUB_ACTIONS") == "true":
+ print(f"::warning::Could not read {report}: {error}")
+ else:
+ print(f"Warning: Could not read {report}: {error}", file=sys.stderr)
continue
+ total_duration += get_report_duration(root)
+
for test in root.iter("testcase"):
+ name = f"{test.get('classname', '')}.{test.get('name', '')}"
+ has_failure = False
+ has_skipped = False
+
for result in test:
if result.tag not in ("failure", "error", "skipped"):
continue
- name = f"{test.get('classname', '')}.{test.get('name', '')}"
details = "".join(result.itertext()).strip()
message = result.get("message", "")
# Android can report assumption violations as failures or errors.
@@ -56,10 +108,30 @@ def main(results_dir, log_file=None, pattern="TEST-*.xml", title="Test failures"
"org.junit.AssumptionViolatedException",
"org.junit.internal.AssumptionViolatedException",
):
+ has_skipped = True
skipped.append(f"{name}\n{details or result.get('type', 'Test skipped')}")
else:
+ has_failure = True
failures.append(f"{name}\n{details or result.get('type', 'Test failed')}")
+ if has_failure:
+ failed_count += 1
+ elif has_skipped:
+ skipped_count += 1
+ else:
+ passed_count += 1
+
+ total_tests = passed_count + failed_count + skipped_count
+ is_successful = failed_count == 0 and total_tests > 0
+ result_text = "SUCCESS" if is_successful else "FAILURE"
+ elapsed = format_duration(total_duration)
+
+ if suite:
+ print(
+ f"{suite}: {result_text} ({elapsed}) - "
+ f"{total_tests} tests: {passed_count} passed, {failed_count} failed, {skipped_count} skipped"
+ )
+
for failure in failures:
if os.environ.get("GITHUB_ACTIONS") == "true":
# GitHub renders the escaped message as a multiline error in the log.
@@ -79,19 +151,27 @@ def main(results_dir, log_file=None, pattern="TEST-*.xml", title="Test failures"
if os.environ.get("GITHUB_ACTIONS") == "true":
print("::endgroup::")
- if (failures or skipped) and os.environ.get("GITHUB_STEP_SUMMARY"):
- with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as summary:
+ if os.environ.get("GITHUB_STEP_SUMMARY"):
+ with open(os.environ["GITHUB_STEP_SUMMARY"], "a+", encoding="utf-8") as summary:
+ summary.seek(0)
+ existing_content = summary.read()
+ if suite:
+ if "## Test Results" not in existing_content:
+ summary.write(TABLE_HEADER)
+ summary.write(f"| {suite} | **{result_text}** | {elapsed} | {total_tests} | {passed_count} | {failed_count} | {skipped_count} |\n\n")
+
if failures:
summary.write(f"### {title}\n\n")
for failure in failures:
summary.write(f"
{html.escape(failure)}\n\n")
if skipped:
+ skipped_title = f"Skipped tests ({len(skipped)})"
summary.write(f"{skipped_title}
\n\n")
for skip in skipped:
summary.write(f"{html.escape(skip)}\n\n")
summary.write(" \n\n")
- return 1 if failures else 0
+ return 1 if (failures or (suite and not is_successful)) else 0
if __name__ == "__main__":
@@ -100,5 +180,6 @@ def main(results_dir, log_file=None, pattern="TEST-*.xml", title="Test failures"
parser.add_argument("log_file", type=Path, nargs="?")
parser.add_argument("--pattern", default="TEST-*.xml", help="Recursive report glob relative to results_dir")
parser.add_argument("--title", default="Test failures", help="Failure heading in the job summary")
+ parser.add_argument("--suite", help="Suite name for markdown summary table")
args = parser.parse_args()
- sys.exit(main(args.results_dir, args.log_file, args.pattern, args.title))
+ sys.exit(main(args.results_dir, args.log_file, args.pattern, args.title, args.suite))
diff --git a/.github/with_emulator_cleanup.sh b/.github/with_emulator_cleanup.sh
new file mode 100644
index 000000000000..1152597454cb
--- /dev/null
+++ b/.github/with_emulator_cleanup.sh
@@ -0,0 +1,27 @@
+#!/bin/bash
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+set -euo pipefail
+
+# Only for dedicated Linux CI runners with one emulator and -no-snapshot-save.
+# Forced shutdown must not interrupt saving a snapshot for subsequent jobs.
+stop_emulator() {
+ # #22613 `emu kill` can report success while QEMU keeps running and
+ # holds the emulator action's output pipes open. Bound both ADB and QEMU.
+ timeout -s KILL 5s "$ANDROID_HOME/platform-tools/adb" -s "emulator-$EMULATOR_PORT" emu kill || true
+
+ local emulator_pattern="^$ANDROID_HOME/emulator/qemu/"
+ local deadline=$((SECONDS + 20))
+ while pgrep -f "$emulator_pattern" > /dev/null; do
+ if ((SECONDS >= deadline)); then
+ echo "Emulator did not stop within 20 seconds; killing QEMU." >&2
+ pkill -KILL -f "$emulator_pattern" || true
+ break
+ fi
+ sleep 1
+ done
+}
+
+# Preserve the exit status
+trap stop_emulator EXIT
+"$@"
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index a4361bf82d18..e4d72dc5f713 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -20,6 +20,9 @@ on:
schedule:
- cron: '42 12 * * 1'
+env:
+ JAVA_TOOL_OPTIONS: -Dkotlin.daemon.options=autoshutdownIdleSeconds=10
+
jobs:
analyze:
name: Analyze (${{ matrix.language }})
diff --git a/.github/workflows/compare_apk_size.yml b/.github/workflows/compare_apk_size.yml
index 29127679994e..0385fa82352d 100644
--- a/.github/workflows/compare_apk_size.yml
+++ b/.github/workflows/compare_apk_size.yml
@@ -18,6 +18,9 @@ permissions:
pull-requests: write
+env:
+ JAVA_TOOL_OPTIONS: -Dkotlin.daemon.options=autoshutdownIdleSeconds=10
+
jobs:
sizeCheck:
name: APK Size Check
diff --git a/.github/workflows/conflict.yaml b/.github/workflows/conflict.yaml
index ac85a1f3b8d3..251f771638f4 100644
--- a/.github/workflows/conflict.yaml
+++ b/.github/workflows/conflict.yaml
@@ -2,6 +2,8 @@ name: 🛠️ Conflict Scan
on:
push:
+ branches-ignore:
+ - 'gh-readonly-queue/**'
schedule:
- cron: "0 * * * *"
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/lint.yml b/.github/workflows/lint.yml
index d5596bdd4277..0eaf7d866b58 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -20,6 +20,9 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
+env:
+ JAVA_TOOL_OPTIONS: -Dkotlin.daemon.options=autoshutdownIdleSeconds=10
+
jobs:
lintKotlin:
name: Lint Kotlin
diff --git a/.github/workflows/merge_requirements.yml b/.github/workflows/merge_requirements.yml
index 81302dff8745..dfad37a8f666 100644
--- a/.github/workflows/merge_requirements.yml
+++ b/.github/workflows/merge_requirements.yml
@@ -9,13 +9,13 @@ on:
permissions: {}
-concurrency:
- group: ${{ github.workflow }}-${{ github.ref }}
- cancel-in-progress: true
+# Don't cancel runs: this is a fast check which shows as a failing check when canceled.
jobs:
squashMerge:
name: Squash merge
+ # perf: stops 'merge_group' from allocating a runner
+ if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 7080bae85db7..ae28495b4f4c 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -24,9 +24,15 @@ concurrency:
group: ${{ github.workflow }}
cancel-in-progress: false
+env:
+ JAVA_TOOL_OPTIONS: -Dkotlin.daemon.options=autoshutdownIdleSeconds=10
+
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..8b28f9a291a1 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,13 +82,14 @@ 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 test class, module prefix is dropped for readability.
+ # Reconsider it once we are heavily mutlimodule.
declare -A class_counts class_files
for file in "${files[@]}"; do
class_dir="${file%/*}"
diff --git a/.github/workflows/screenshot_compare.yml b/.github/workflows/screenshot_compare.yml
index 0d9599f012d4..7070e6c74962 100644
--- a/.github/workflows/screenshot_compare.yml
+++ b/.github/workflows/screenshot_compare.yml
@@ -27,6 +27,9 @@ on:
permissions: {}
+env:
+ JAVA_TOOL_OPTIONS: -Dkotlin.daemon.options=autoshutdownIdleSeconds=10
+
jobs:
compare-screenshot-test:
name: compare
@@ -38,6 +41,17 @@ jobs:
actions: write # for upload-artifact
steps:
+ # Work around Ubuntu 26.04's RAM-backed /tmp until the image fix rolls out.
+ # https://github.com/actions/runner-images/pull/14814
+ - name: Keep /tmp on disk
+ if: runner.os == 'Linux'
+ run: |
+ if [ "$(findmnt -n -o FSTYPE --target /tmp)" = "tmpfs" ]; then
+ sudo systemctl mask tmp.mount
+ sudo umount -l /tmp
+ fi
+ df -hT /tmp
+
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -57,13 +71,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 +87,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 +100,19 @@ 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/}"
+ # Flatten local /diffs/ directory: keeps baselines apart from
+ # diffs while testing, and means nothing inside artifact
+ class="${relative%%/*}"
+ name="${relative##*/}"
+ destination="screenshot-diff/$SUBDIR/${module#./}/$class/$name"
+ 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 +149,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..5ac3539a4bc0 100644
--- a/.github/workflows/screenshot_store.yml
+++ b/.github/workflows/screenshot_store.yml
@@ -26,22 +26,29 @@ 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: {}
+env:
+ JAVA_TOOL_OPTIONS: -Dkotlin.daemon.options=autoshutdownIdleSeconds=10
+
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
@@ -50,6 +57,17 @@ jobs:
actions: write # for upload-artifact
steps:
+ # Work around Ubuntu 26.04's RAM-backed /tmp until the image fix rolls out.
+ # https://github.com/actions/runner-images/pull/14814
+ - name: Keep /tmp on disk
+ if: runner.os == 'Linux'
+ run: |
+ if [ "$(findmnt -n -o FSTYPE --target /tmp)" = "tmpfs" ]; then
+ sudo systemctl mask tmp.mount
+ sudo umount -l /tmp
+ fi
+ df -hT /tmp
+
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
@@ -70,20 +88,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 +129,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..617148033b75 100644
--- a/.github/workflows/tests_emulator.yml
+++ b/.github/workflows/tests_emulator.yml
@@ -29,6 +29,9 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name == 'schedule' && 'scheduled' || 'ci' }}
cancel-in-progress: true
+env:
+ JAVA_TOOL_OPTIONS: -Dkotlin.daemon.options=autoshutdownIdleSeconds=10
+
jobs:
# We want to generate our matrix dynamically
# Initial job generates the matrix as a JSON, and following job will use deserialize and use the result
@@ -68,13 +71,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 +96,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 +106,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 +167,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 +178,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 +220,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 +244,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 +256,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 +280,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 +295,31 @@ jobs:
bash .github/adb_retry.sh emu screenrecord start --time-limit 1800 video.webm
sleep 5
bash .github/with_emulator_log.sh adb-log.txt python3 .github/run_emulator_tests.py
- TEST_RELEASE_BUILD=false ./gradlew :baselineprofile:connectedBenchmarkBenchmarkAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.ichi2.anki.baselineprofile.ReleaseSmokeTest -Pandroid.testInstrumentationRunnerArguments.releaseSmokeTest=true --daemon
- name: Report Emulator Test Results
if: always() && hashFiles('AnkiDroid/build/outputs/androidTest-results/connected/**/TEST-*.xml') != ''
- run: python3 .github/report_test_failures.py AnkiDroid/build/outputs/androidTest-results/connected adb-log.txt --title "Emulator test failures"
+ run: python3 .github/report_test_failures.py AnkiDroid/build/outputs/androidTest-results/connected adb-log.txt --title "Emulator test failures" --suite ":AnkiDroid:connectedPlayReleaseAndroidTest"
+
+ - name: Build Release Smoke Test
+ run: TEST_RELEASE_BUILD=false ./gradlew packagePlayRelease :baselineprofile:packageBenchmarkBenchmark --daemon
+
+ - name: Run Release Smoke Test
+ uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0
+ timeout-minutes: 10
+ with:
+ api-level: ${{ matrix.api-level }}
+ force-avd-creation: false
+ target: ${{ matrix.target }}
+ arch: ${{ matrix.arch }}
+ emulator-options: -no-snapshot-save ${{ env.EMULATOR_OPTIONS }}
+ ram-size: ${{ env.EMULATOR_RAM_SIZE }}
+ pre-emulator-launch-script: ${{ env.EMULATOR_PRE_LAUNCH_SCRIPT }}
+ sdcard-path-or-size: 100M
+ disable-animations: true
+ script: |
+ bash .github/disable_art_metrics.sh
+ bash .github/adb_retry.sh shell settings put global hide_error_dialogs 1
+ TEST_RELEASE_BUILD=false bash .github/with_emulator_cleanup.sh ./gradlew :baselineprofile:connectedBenchmarkBenchmarkAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.ichi2.anki.baselineprofile.ReleaseSmokeTest -Pandroid.testInstrumentationRunnerArguments.releaseSmokeTest=true --daemon
- name: Configure NDK tools PATH for alignment checking
run: |
@@ -332,21 +373,21 @@ jobs:
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
- name: ${{ matrix.api-level }}-${{ matrix.arch }}-${{matrix.target}}-${{matrix.first-boot-delay}}-${{matrix.iteration}}-jacocoAndroidTestReport
+ name: ${{ matrix.api-level }}-${{ matrix.arch }}-${{matrix.target}}-${{matrix.first-boot-delay}}-${{matrix.iteration}}-${{ matrix.device }}-jacocoAndroidTestReport
path: ~/work/Anki-Android/Anki-Android/AnkiDroid/build/reports/jacoco/
- name: Upload Emulator Log
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
- name: ${{ matrix.api-level }}-${{ matrix.arch }}-${{matrix.target}}-${{matrix.first-boot-delay}}-${{matrix.iteration}}-adb_logs
+ name: ${{ matrix.api-level }}-${{ matrix.arch }}-${{matrix.target}}-${{matrix.first-boot-delay}}-${{matrix.iteration}}-${{ matrix.device }}-adb_logs
path: adb-log.txt
- name: Upload Emulator Screen Record
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
if: always()
with:
- name: ${{ matrix.api-level }}-${{ matrix.arch }}-${{matrix.target}}-${{matrix.first-boot-delay}}-${{matrix.iteration}}-adb_video
+ name: ${{ matrix.api-level }}-${{ matrix.arch }}-${{matrix.target}}-${{matrix.first-boot-delay}}-${{matrix.iteration}}-${{ matrix.device }}-adb_video
path: video.webm
- uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
diff --git a/.github/workflows/tests_unit.yml b/.github/workflows/tests_unit.yml
index 13b2b4071a82..50709462a9b9 100644
--- a/.github/workflows/tests_unit.yml
+++ b/.github/workflows/tests_unit.yml
@@ -41,6 +41,9 @@ concurrency:
group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }}
cancel-in-progress: true
+env:
+ JAVA_TOOL_OPTIONS: -Dkotlin.daemon.options=autoshutdownIdleSeconds=10
+
jobs:
# We want to generate our matrix dynamically
# Initial job generates the matrix as a JSON, and following job will use deserialize and use the result
@@ -115,8 +118,8 @@ jobs:
# Do not run the scheduled jobs on forks
if: (github.event_name == 'schedule' && github.repository == 'ankidroid/Anki-Android') || (github.event_name != 'schedule')
needs: matrix_prep
- # Windows takes 30-34 minutes when the Gradle cache misses (#21834)
- timeout-minutes: 45
+ # Windows can take 40m+ on tests
+ timeout-minutes: 60
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
strategy:
@@ -126,6 +129,17 @@ jobs:
#env:
# CODACY_TOKEN: ${{ secrets.CODACY_TOKEN }}
steps:
+ # Work around Ubuntu 26.04's RAM-backed /tmp until the image fix rolls out.
+ # https://github.com/actions/runner-images/pull/14814
+ - name: Keep /tmp on disk
+ if: runner.os == 'Linux'
+ run: |
+ if [ "$(findmnt -n -o FSTYPE --target /tmp)" = "tmpfs" ]; then
+ sudo systemctl mask tmp.mount
+ sudo umount -l /tmp
+ fi
+ df -hT /tmp
+
- name: Configure Windows Pagefile
uses: al-cheb/configure-pagefile-action@9b6da52fb72a3c6147c1aad2df22d8d905681adc # v1.5
if: contains(matrix.os, 'windows')
@@ -134,6 +148,15 @@ jobs:
maximum-size: 12GB
disk-root: "C:"
+ - name: Configure Linux Swap
+ if: runner.os == 'Linux'
+ run: |
+ sudo fallocate -l 8G /swapfile-ci
+ sudo chmod 600 /swapfile-ci
+ sudo mkswap /swapfile-ci
+ sudo swapon /swapfile-ci
+ swapon --show
+
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 50
@@ -179,6 +202,17 @@ jobs:
if: (github.event_name == 'schedule' || github.event_name == 'workflow_dispatch') && matrix.iteration == 1 && matrix.os == 'ubuntu-latest'
run: ./gradlew testFullDebugUnitTest -PemptyApplication --daemon
+ # Compile the extra release variant once per scheduled run, keeping PR checks unchanged.
+ - name: Test Parallel Content Provider (scheduled run only)
+ if: github.event_name == 'schedule' && matrix.iteration == 1 && matrix.os == 'ubuntu-latest'
+ env:
+ TEST_RELEASE_BUILD: "true"
+ run: >
+ ./gradlew :AnkiDroid:testFullReleaseUnitTest
+ -PcustomSuffix=a
+ --tests 'com.ichi2.anki.provider.CardContentProviderTest'
+ --daemon
+
- name: Report Unit Test Results
if: always() && hashFiles('**/build/test-results/**/TEST-*.xml') != ''
run: python3 .github/report_test_failures.py . --pattern "build/test-results/**/TEST-*.xml" --title "Unit test failures"
diff --git a/AI_POLICY.md b/AI_POLICY.md
index f1d1b31598d8..f27967614c5e 100644
--- a/AI_POLICY.md
+++ b/AI_POLICY.md
@@ -26,10 +26,16 @@ You may be banned from the repository if this occurs a second time.
### Contributors
* You **must** be able to explain all your contributions.
-* AI tools **should not** be used to produce GitHub comments, including filling in the pull request
- template.
+* When acting as a reviewer, you **may** use AI tools to produce code review comments. Prefer a
+ summary, with the exact AI-generated wording in a `` block.
+* AI tools **should not** be used to produce other GitHub comments, including filling in the pull
+ request template.
* Contributions that make nontrivial use of AI tools **must** be labelled, with the tool version.
+#### Exceptions
+
+AI-generated issues may be submitted at a maintainer's discretion.
+
### Handling Questions about AI Use
If you are not sure whether a tool or workflow counts as AI usage under this policy, please ask
@@ -42,29 +48,53 @@ or trying to bypass this policy may lead to moderator action.
### Disclosure
-**Commit messages** **must** use the `Assisted-by:`
- [git trailer](https://git-scm.com/docs/git-interpret-trailers#_description) with an explanation
- of the contributions in the description.
+Disclose which AI tools were used.
+**Commit messages** **must** use the `Assisted-by:`
+ [git trailer](https://git-scm.com/docs/git-interpret-trailers#_description).
```
docs: example title
[Optional commit description]
-GPT-5.2 implemented `complexMethod`
+Assisted-by: GPT-6-Astra xhigh
+Assisted-by: Claude Opus 5.5
+```
+
+**GitHub comments** **must** disclose AI tool use, typically using one of the following formats:
+
+```md
+Assisted-by: Claude Opus 5.5
+```
+
+```md
+[Summary of change]
+
+
+
+Assisted-by: Claude Opus 5.5
+
+Details of change
-Assisted-by: GPT-5.2
-Assisted-by: Claude Opus 4.5 [Proofreading/grammar]
+
```
+produces:
+
+[Summary of change]
-**GitHub comments** **must** use the `> [!NOTE]`
- [alert](https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts).
+
+
+Assisted-by: Claude Opus 5.5
+
+Details of change
+
+
```md
> [!NOTE]
-> The following table was generated using Claude Opus 4.5
+> The following table was generated using Claude Opus 5.5
```
produces:
-> [!NOTE]
-> The following table was generated using Claude Opus 4.5
\ No newline at end of file
+> [!NOTE]
+> The following table was generated using Claude Opus 5.5
\ No newline at end of file
diff --git a/AnkiDroid/build.gradle.kts b/AnkiDroid/build.gradle.kts
index ebcf51b3d8e0..2fe03b88a849 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 = 22600109
// 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.0alpha9"
testApplicationId = "com.ichi2.anki.tests"
vectorDrawables.useSupportLibrary = true
testInstrumentationRunner = "com.ichi2.testutils.NewCollectionPathTestRunner"
@@ -331,11 +330,11 @@ android {
animationsDisabled = true
unitTests {
all { test ->
+ // Profile coverage checks must rerun when the generated profiles change.
+ test.inputs.dir("src/main/generated/baselineProfiles")
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 +357,7 @@ android {
test.systemProperty("screenshot.theme", theme)
test.systemProperty("screenshot.device", device)
- } else {
- excludeTags("com.ichi2.anki.ScreenshotTestCategory")
+ test.systemProperty("screenshot.accessibilityScan", project.hasProperty("accessibilityScan"))
}
// Ensures that no EmptyApplication test relies on AnkiDroidApp
// by only running EmptyApplication tests
@@ -728,9 +726,10 @@ android {
}
}
-configurations.configureEach {
+configurations.matching { it.name.endsWith("CompileClasspath") || it.name.endsWith("RuntimeClasspath") }.configureEach {
resolutionStrategy {
- // Timber has this as a dependency but they are not up to date. We want to force our version.
+ // Keep app dependencies such as Timber on our annotations version. Changing Kotlin's
+ // compiler classpath creates an extra compiler daemon and increases peak memory use.
force(libs.jetbrains.annotations)
}
}
@@ -751,6 +750,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 +758,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 +847,6 @@ dependencies {
// ankidroid.plugins.robolectric-downloader may need a new SDK jar entry
// when Robolectric or targetSdk changes. See the instructions in that plugin.
testImplementation(libs.robolectric)
- testImplementation(libs.androidx.compose.ui.test.junit4)
- // Needed for createComposeRule(), but not for createAndroidComposeRule()
- debugImplementation(libs.androidx.compose.ui.test.manifest)
testImplementation(libs.androidx.test.core)
testImplementation(libs.androidx.test.junit)
testImplementation(libs.kotlin.reflect)
@@ -877,6 +866,7 @@ dependencies {
}
testImplementation(libs.androidx.work.testing)
testImplementation(libs.roborazzi)
+ testImplementation(libs.roborazzi.accessibility.check)
testImplementation(libs.roborazzi.rule)
// for testing flows
testImplementation(libs.cashapp.turbine)
@@ -908,10 +898,4 @@ dependencies {
// Required so the ExperimentalCoroutinesApi opt-in (applied globally) doesn't cause
// an "unresolved" warning, which is treated as an error due to allWarningsAsErrors
testFixturesImplementation(libs.kotlinx.coroutines.core)
- // The Kotlin Compose Compiler plugin attaches to every Kotlin compilation in the
- // module including testFixtures, which has no @Composable code and refuses to
- // run unless the Compose Runtime is on the classpath. compileOnly satisfies the
- // plugin's version check without shipping the runtime in the testFixtures output.
- testFixturesCompileOnly(platform(libs.androidx.compose.bom))
- testFixturesCompileOnly(libs.androidx.compose.runtime)
}
diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/DeckPickerImportTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/DeckPickerImportTest.kt
new file mode 100644
index 000000000000..bfba6bbbd07a
--- /dev/null
+++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/DeckPickerImportTest.kt
@@ -0,0 +1,140 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki
+
+import androidx.core.content.edit
+import androidx.test.core.app.ActivityScenario
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.assertion.ViewAssertions.matches
+import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
+import androidx.test.espresso.matcher.ViewMatchers.withText
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.ichi2.anki.CollectionManager.withCol
+import com.ichi2.anki.common.preferences.sharedPrefs
+import com.ichi2.anki.dialogs.DialogHandler
+import com.ichi2.anki.tests.InstrumentedTest
+import com.ichi2.anki.testutil.GrantStoragePermission.storagePermission
+import com.ichi2.anki.testutil.disableIntroductionSlide
+import com.ichi2.anki.testutil.grantPermissions
+import com.ichi2.anki.testutil.notificationPermission
+import com.ichi2.anki.testutil.waitUntil
+import com.ichi2.utils.ImportUtils
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.NonCancellable
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.withContext
+import org.junit.After
+import org.junit.Assume.assumeTrue
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.TimeUnit
+import kotlin.test.assertNull
+import kotlin.test.assertTrue
+
+@RunWith(AndroidJUnit4::class)
+class DeckPickerImportTest : InstrumentedTest() {
+ @get:Rule
+ val runtimePermissionRule = grantPermissions(storagePermission, notificationPermission)
+
+ private val preferences get() = testContext.sharedPrefs()
+ private val hadBackupPromptPreference = preferences.contains("backupPromptDisabled")
+ private val backupPromptDisabled = preferences.getBoolean("backupPromptDisabled", false)
+
+ @Test
+ fun importConfirmationSurvivesDelayedTabletStartup() {
+ DialogHandler.storeMessage(ImportUtils.CollectionImportAdd("deck.apkg").toMessage())
+
+ withDelayedStartup { scenario, resumeStartup ->
+ assertImportConfirmationVisible()
+ scenario.assertStartupWaiting()
+
+ resumeStartup()
+ scenario.awaitStartup()
+
+ assertImportConfirmationVisible()
+ }
+ }
+
+ @Before
+ fun prepareTablet() {
+ // The study options side panel is selected by layout-sw600dp.
+ assumeTrue("Requires the tablet layout", testContext.resources.configuration.smallestScreenWidthDp >= 600)
+ disableIntroductionSlide()
+ InitialActivity.setUpgradedToLatestVersion(preferences)
+ preferences.edit { putBoolean("backupPromptDisabled", true) }
+ addNoteUsingBasicNoteType()
+ }
+
+ @After
+ fun restorePreferencesAndPendingDialogs() {
+ DialogHandler.storeMessage(null)
+ preferences.edit {
+ if (hadBackupPromptPreference) {
+ putBoolean("backupPromptDisabled", backupPromptDisabled)
+ } else {
+ remove("backupPromptDisabled")
+ }
+ }
+ }
+
+ private fun withDelayedStartup(test: suspend (scenario: ActivityScenario, resumeStartup: suspend () -> Unit) -> Unit) =
+ runBlocking {
+ val collectionQueueHeld = CountDownLatch(1)
+ val releaseStartup = CountDownLatch(1)
+ val pendingCollectionOperation =
+ launch(Dispatchers.IO) {
+ withCol {
+ collectionQueueHeld.countDown()
+ check(releaseStartup.await(30, TimeUnit.SECONDS)) { "Startup was not released" }
+ }
+ }
+ var scenario: ActivityScenario? = null
+ try {
+ assertTrue(collectionQueueHeld.await(10, TimeUnit.SECONDS), "The collection queue must be held before launching")
+ val activityScenario = ActivityScenario.launch(DeckPicker::class.java)
+ scenario = activityScenario
+ test(activityScenario) {
+ releaseStartup.countDown()
+ pendingCollectionOperation.join()
+ }
+ } finally {
+ releaseStartup.countDown()
+ withContext(NonCancellable) {
+ pendingCollectionOperation.join()
+ scenario?.close()
+ }
+ }
+ }
+
+ private fun ActivityScenario.assertStartupWaiting() {
+ onActivity { activity ->
+ assertTrue(activity.fragmented, "The tablet side panel must be present")
+ assertNull(activity.fragment, "Study options must not have been initialized yet")
+ assertTrue(activity.viewModel.startupJob?.isActive == true, "Startup must still be waiting")
+ }
+ }
+
+ private fun ActivityScenario.awaitStartup() {
+ waitUntil(message = { "Startup did not finish initializing study options" }) {
+ var finished = false
+ onActivity { activity ->
+ finished =
+ activity.viewModel.startupJob?.isCompleted == true &&
+ activity.viewModel.loadDeckCounts?.isCompleted == true &&
+ activity.fragment != null
+ }
+ finished
+ }
+ }
+
+ private fun assertImportConfirmationVisible() {
+ onView(withText(testContext.getString(CommonString.import_dialog_message_add, "deck.apkg")))
+ .check(matches(isDisplayed()))
+ onView(withText(CommonString.import_message_add))
+ .check(matches(isDisplayed()))
+ }
+}
diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/cardviewer/HtmlTypeAnswerReviewerTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/cardviewer/HtmlTypeAnswerReviewerTest.kt
index 8dd588fb39a0..23f26fae8e1f 100644
--- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/cardviewer/HtmlTypeAnswerReviewerTest.kt
+++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/cardviewer/HtmlTypeAnswerReviewerTest.kt
@@ -2,6 +2,7 @@
package com.ichi2.anki.cardviewer
+import android.app.Activity
import android.text.InputType
import android.view.inputmethod.EditorInfo
import android.webkit.WebView
@@ -17,6 +18,7 @@ import com.ichi2.anki.Reviewer
import com.ichi2.anki.common.preferences.sharedPrefs
import com.ichi2.anki.libanki.DeckId
import com.ichi2.anki.libanki.NoteTypeId
+import com.ichi2.anki.previewer.CardViewerActivity
import com.ichi2.anki.tests.InstrumentedTest
import com.ichi2.anki.tests.checkWithTimeout
import com.ichi2.anki.testutil.AvoidDayRolloverRule
@@ -27,6 +29,7 @@ import com.ichi2.anki.testutil.ensureWebViewIsSupported
import com.ichi2.anki.testutil.focusInput
import com.ichi2.anki.testutil.grantPermissions
import com.ichi2.anki.testutil.notificationPermission
+import com.ichi2.anki.ui.windows.reviewer.ReviewerFragment
import org.junit.After
import org.junit.Before
import org.junit.Rule
@@ -34,13 +37,18 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
import java.util.UUID
+import kotlin.test.assertIs
import kotlin.test.assertNotNull
-/** Checks the legacy reviewer's HTML, script loading, and WebView keyboard support together. */
+/** Checks the real reviewers' HTML, script loading, and WebView keyboard support together. */
@RunWith(Parameterized::class)
class HtmlTypeAnswerReviewerTest : InstrumentedTest() {
@JvmField // required for Parameter
@Parameterized.Parameter
+ var newReviewer = false
+
+ @JvmField // required for Parameter
+ @Parameterized.Parameter(1)
var autoFocus = false
@get:Rule
@@ -72,7 +80,7 @@ class HtmlTypeAnswerReviewerTest : InstrumentedTest() {
originalPreferences =
listOf(newReviewerKey, "useInputTag", "autoFocusTypeInAnswer", doubleTapKey).associateWith { preferences.all[it] }
preferences.edit {
- putBoolean(newReviewerKey, false)
+ putBoolean(newReviewerKey, newReviewer)
putBoolean("useInputTag", true)
putBoolean("autoFocusTypeInAnswer", autoFocus)
// These tests intentionally grade immediately after Done.
@@ -134,6 +142,8 @@ class HtmlTypeAnswerReviewerTest : InstrumentedTest() {
@Test
fun onlyTheMarkedAnswerDisablesSuggestions() =
withReviewer { webView ->
+ // Let reviewer autofocus finish before moving to another field.
+ focusAnswer(webView, noSuggest = true)
webView.focusInput("ordinary")
val baseline = keyboard.awaitEditor().inputType
webView.focusInput("typeans")
@@ -165,7 +175,7 @@ class HtmlTypeAnswerReviewerTest : InstrumentedTest() {
keyboard.perform("done")
webView.awaitJavascript("document.querySelector('#typeans .typeGood')?.textContent === 'été'")
if (question != "Third") {
- onView(withId(R.id.flashcard_layout_ease3))
+ onView(withId(if (newReviewer) R.id.good_button else R.id.flashcard_layout_ease3))
.also { it.checkWithTimeout(matches(isDisplayed())) }
.perform(click())
}
@@ -190,10 +200,16 @@ class HtmlTypeAnswerReviewerTest : InstrumentedTest() {
}
private fun withReviewer(block: (WebView) -> Unit) {
- ActivityScenario.launch(Reviewer.getIntent(testContext)).use { scenario ->
+ ActivityScenario.launch(Reviewer.getIntent(testContext)).use { scenario ->
lateinit var webView: WebView
scenario.onActivity { activity ->
- webView = assertNotNull(activity.webView)
+ webView =
+ if (newReviewer) {
+ val fragment = assertIs(assertIs(activity).fragment)
+ assertIs(fragment.binding.webViewLayout.getChildAt(0))
+ } else {
+ assertNotNull(assertIs(activity).webView)
+ }
}
webView.awaitJavascript("document.getElementById('typeans') !== null")
block(webView)
@@ -202,7 +218,7 @@ class HtmlTypeAnswerReviewerTest : InstrumentedTest() {
companion object {
@JvmStatic // required for Parameters
- @Parameterized.Parameters(name = "autoFocus={0}")
- fun focusModes() = listOf(false, true)
+ @Parameterized.Parameters(name = "newReviewer={0}, autoFocus={1}")
+ fun reviewers() = listOf(arrayOf(false, false), arrayOf(false, true), arrayOf(true, false), arrayOf(true, true))
}
}
diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/dialogs/NoteTypeFieldEditorContextMenuTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/dialogs/NoteTypeFieldEditorContextMenuTest.kt
index 2c2cb19c783b..f22833bb5aca 100644
--- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/dialogs/NoteTypeFieldEditorContextMenuTest.kt
+++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/dialogs/NoteTypeFieldEditorContextMenuTest.kt
@@ -10,12 +10,12 @@ import androidx.test.espresso.matcher.RootMatchers.isDialog
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.R
import com.ichi2.anki.dialogs.NoteTypeFieldEditorContextMenu.NoteTypeFieldEditorContextMenuAction
import com.ichi2.anki.tests.InstrumentedTest
import org.junit.Ignore
import org.junit.Test
import org.junit.runner.RunWith
+import com.ichi2.anki.common.android.R as CommonR
/** Tests [NoteTypeFieldEditorContextMenu] */
@RunWith(AndroidJUnit4::class)
@@ -26,8 +26,8 @@ class NoteTypeFieldEditorContextMenuTest : InstrumentedTest() {
@Ignore("flaky")
fun showsAllOptions() {
launchFragment(
- fragmentArgs = Bundle().apply { putString(NoteTypeFieldEditorContextMenu.KEY_LABEL, testDialogTitle) },
- themeResId = R.style.Theme_Light,
+ fragmentArgs = Bundle().apply { putString(NoteTypeFieldEditorContextMenu.ARG_LABEL, testDialogTitle) },
+ themeResId = CommonR.style.Theme_Light,
) { NoteTypeFieldEditorContextMenu() }
onView(withText(testDialogTitle))
.inRoot(isDialog())
diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/exception/SanitizedReportCollectorIntegrationTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/exception/SanitizedReportCollectorIntegrationTest.kt
new file mode 100644
index 000000000000..3da3adad95e8
--- /dev/null
+++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/exception/SanitizedReportCollectorIntegrationTest.kt
@@ -0,0 +1,156 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.exception
+
+import android.content.Context
+import androidx.test.core.app.ApplicationProvider.getApplicationContext
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.ichi2.anki.AnkiDroidApp
+import com.ichi2.anki.logging.ProductionCrashReportingTree
+import com.ichi2.anki.servicelayer.ThrowableFilterService
+import org.acra.ReportField
+import org.acra.builder.ReportBuilder
+import org.acra.config.CoreConfigurationBuilder
+import org.acra.data.CrashReportData
+import org.acra.data.CrashReportDataFactory
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import timber.log.Timber
+import java.util.UUID
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertNotNull
+import kotlin.test.assertSame
+import kotlin.test.assertTrue
+
+/** Exercises the packaged ACRA collectors against Android's logcat and the uncaught-error filter. */
+@RunWith(AndroidJUnit4::class)
+class SanitizedReportCollectorIntegrationTest {
+ private val tree = ProductionCrashReportingTree()
+
+ @Before
+ fun setUp() {
+ Timber.plant(tree)
+ }
+
+ @After
+ fun tearDown() {
+ Timber.uproot(tree)
+ }
+
+ @Test
+ fun sanitizedReportsExcludePrivateMessagesAlreadyWrittenToLogcat() {
+ val privateMessage = "private-${UUID.randomUUID()}@example.com"
+ val exception = StorageAccessException(privateMessage)
+ Timber.w(exception)
+
+ // Prove that the real LogCatCollector can read the private message before filtering.
+ val unfiltered = collectReport(exception, parallel = false)
+ assertTrue(assertNotNull(unfiltered.getString(ReportField.LOGCAT)).contains(privateMessage))
+
+ for (parallel in listOf(false, true)) {
+ val report = collectUnhandledReport(exception, parallel)
+
+ assertEquals(
+ "Logcat filtered because it may contain personally identifiable information (PII).",
+ report.getString(ReportField.LOGCAT),
+ )
+ assertFalse(report.toJSON().contains(privateMessage))
+ assertTrue(assertNotNull(report.getString(ReportField.STACK_TRACE)).contains(exception.javaClass.name))
+ assertFalse(report.getString(ReportField.STACK_TRACE_HASH).isNullOrEmpty())
+ }
+ }
+
+ @Test
+ fun ordinaryReportsRetainTheirLogcatDiagnostics() {
+ val message = "ordinary diagnostics ${UUID.randomUUID()}"
+ val exception = IllegalStateException(message)
+ Timber.w(exception)
+
+ for (parallel in listOf(false, true)) {
+ val report = collectUnhandledReport(exception, parallel)
+
+ assertTrue(assertNotNull(report.getString(ReportField.LOGCAT)).contains(message))
+ assertTrue(assertNotNull(report.getString(ReportField.STACK_TRACE)).contains(message))
+ }
+ }
+
+ @Test
+ fun unrelatedThreadFailuresReachOriginalHandler() {
+ val backgroundFailure = IllegalStateException("unrelated background failure")
+ val worker =
+ Thread { throw backgroundFailure }.apply {
+ uncaughtExceptionHandler = ThrowableFilterService.uncaughtExceptionHandler
+ }
+ val originalHandler = ThrowableFilterService.originalUncaughtExceptionHandler
+ var delegatedThread: Thread? = null
+ var delegatedFailure: Throwable? = null
+ val delegate =
+ Thread.UncaughtExceptionHandler { thread, throwable ->
+ if (thread === worker) {
+ delegatedThread = thread
+ delegatedFailure = throwable
+ } else {
+ originalHandler?.uncaughtException(thread, throwable)
+ }
+ }
+ try {
+ ThrowableFilterService.originalUncaughtExceptionHandler = delegate
+ val expectedFailure = IllegalStateException("expected report failure")
+ val report =
+ collectUnhandledReport(expectedFailure, parallel = false) {
+ worker.start()
+ worker.join(5000)
+ assertFalse(worker.isAlive, "The unrelated exception handler must finish")
+ assertSame(worker, delegatedThread)
+ assertSame(backgroundFailure, delegatedFailure)
+ }
+
+ assertTrue(assertNotNull(report.getString(ReportField.STACK_TRACE)).contains("expected report failure"))
+ assertSame(delegate, ThrowableFilterService.originalUncaughtExceptionHandler)
+ } finally {
+ ThrowableFilterService.originalUncaughtExceptionHandler = originalHandler
+ }
+ }
+
+ private fun collectUnhandledReport(
+ exception: Throwable,
+ parallel: Boolean,
+ beforeReport: () -> Unit = {},
+ ): CrashReportData {
+ val reportingThread = Thread.currentThread()
+ val originalHandler = ThrowableFilterService.originalUncaughtExceptionHandler
+ var report: CrashReportData? = null
+ try {
+ // Capture this thread's report before ACRA's sending and process-termination stages.
+ ThrowableFilterService.originalUncaughtExceptionHandler =
+ Thread.UncaughtExceptionHandler { thread, reportable ->
+ if (thread === reportingThread) {
+ report = collectReport(reportable, parallel)
+ } else {
+ originalHandler?.uncaughtException(thread, reportable)
+ }
+ }
+ beforeReport()
+ ThrowableFilterService.uncaughtExceptionHandler.uncaughtException(reportingThread, exception)
+ } finally {
+ ThrowableFilterService.originalUncaughtExceptionHandler = originalHandler
+ }
+ return assertNotNull(report, "The uncaught exception must reach report collection")
+ }
+
+ private fun collectReport(
+ exception: Throwable,
+ parallel: Boolean,
+ ): CrashReportData {
+ val config =
+ CoreConfigurationBuilder()
+ .withReportContent(ReportField.STACK_TRACE, ReportField.STACK_TRACE_HASH, ReportField.LOGCAT)
+ .withLogcatArguments("-t", "500", "-v", "time", "${AnkiDroidApp.TAG}:D", "*:S")
+ .withParallel(parallel)
+ .build()
+ return CrashReportDataFactory(getApplicationContext(), config).createCrashData(ReportBuilder().exception(exception))
+ }
+}
diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/ContentProviderTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/ContentProviderTest.kt
index c3672a7eb83b..3dc642ec971e 100644
--- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/ContentProviderTest.kt
+++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/ContentProviderTest.kt
@@ -80,7 +80,7 @@ import kotlin.test.junit.JUnitAsserter.assertNotNull
*/
class ContentProviderTest : InstrumentedTest() {
@get:Rule
- var runtimePermissionRule = grantPermissions(storagePermission, FlashCardsContract.READ_WRITE_PERMISSION)
+ var runtimePermissionRule = grantPermissions(storagePermission, FlashCardsContract.getPermission())
// Whether tear down should be executed. I.e. if set up was not cancelled.
private var tearDown = false
@@ -1062,7 +1062,7 @@ class ContentProviderTest : InstrumentedTest() {
val filteredDeckId = col.decks.newFiltered("Raw due filtered deck")
testDeckIds.add(filteredDeckId)
val filteredDeck = checkNotNull(col.decks.getLegacy(filteredDeckId))
- filteredDeck.getJSONArray("terms").getJSONArray(0).put(0, "cid:${card.id}")
+ filteredDeck.firstFilter.search = "cid:${card.id}"
col.decks.save(filteredDeck)
col.sched.rebuildFilteredDeck(filteredDeckId)
card.load(col)
@@ -1860,7 +1860,7 @@ class ContentProviderTest : InstrumentedTest() {
assertNotNull("Check that the deck we received actually exists", deck)
assertEquals(
"Check that the received deck has the correct name",
- deck.getString("name"),
+ deck.name,
deckName,
)
}
@@ -1894,7 +1894,7 @@ class ContentProviderTest : InstrumentedTest() {
)
assertEquals(
"Check that received deck name equals real deck name",
- realDeck.getString("name"),
+ realDeck.name,
returnedDeckName,
)
}
diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/DeckPicker.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/DeckPicker.kt
index 5349b53595f0..c0571ba4fb2a 100644
--- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/DeckPicker.kt
+++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/DeckPicker.kt
@@ -18,6 +18,7 @@ import com.ichi2.anki.R
import com.ichi2.anki.TestUtils.clickChildViewWithId
import com.ichi2.anki.tests.checkWithTimeout
import com.ichi2.anki.testutil.ThreadUtils.sleep
+import com.ichi2.anki.common.android.R as CommonR
/**
* This file contains utility methods to interact with the DeckPicker.
@@ -51,7 +52,7 @@ fun createDeckWithUniqueName(): String {
val deckName = "TestDeck$testString"
onView(withId(R.id.fab_main)).perform(click())
onView(withId(R.id.add_deck_button)).perform(click())
- onView(withId(R.id.dialog_text_input)).perform(typeText(deckName))
+ onView(withId(CommonR.id.dialog_text_input)).perform(typeText(deckName))
onView(withText(CommonString.dialog_ok)).perform(click())
return deckName
}
diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/IntroductionSlide.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/IntroductionSlide.kt
index be89daa4df15..9529ef95aca6 100644
--- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/IntroductionSlide.kt
+++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/testutil/IntroductionSlide.kt
@@ -9,6 +9,6 @@ import com.ichi2.anki.IntroductionActivity
fun disableIntroductionSlide() {
AnkiDroidApp.sharedPrefs().edit {
- putBoolean(IntroductionActivity.INTRODUCTION_SLIDES_SHOWN, true)
+ putBoolean(IntroductionActivity.PREF_INTRODUCTION_SLIDES_SHOWN, true)
}
}
diff --git a/AnkiDroid/src/main/AndroidManifest.xml b/AnkiDroid/src/main/AndroidManifest.xml
index f5c3dc5cc208..805c40bd7732 100644
--- a/AnkiDroid/src/main/AndroidManifest.xml
+++ b/AnkiDroid/src/main/AndroidManifest.xml
@@ -81,6 +81,10 @@
+
+
+
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
AnkiDroid Changelog
+
+
Version 2.26.0alpha9 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)