Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
d3a4c67
fix(server): read complete protobuf request bodies
david-allison Sep 22, 2026
3a8f7d3
fix(card-browser): add fast path to reduce chance of ANRs
david-allison Aug 12, 2026
b5d4b4f
feat(card-browser): implement 'Default search text'
david-allison May 5, 2026
673b01b
fix(accessibility): android:textColorHint
david-allison May 5, 2026
bb105d4
fix(tests): tap was considered a long press
david-allison Sep 24, 2026
d5c6370
fix(ci): exempt AndroidX from Dependabot cooldown
david-allison Sep 23, 2026
c097988
fix(day-rollover): fix concurrency
david-allison Sep 24, 2026
4907a59
test: open JDK modules for Robolectric
david-allison Sep 24, 2026
a6bd3fd
refactor(ci): extract ADB shell retry helper
david-allison Sep 24, 2026
256a61e
fix(ci): retry ADB if it's offline
david-allison Sep 24, 2026
cac0573
fix(tests): await study options destruction
david-allison Sep 24, 2026
51f55b0
test(reviewer): await zoom status
david-allison Sep 24, 2026
b578fd8
fix(ci): avoid duplicate Dependabot CI runs
david-allison Sep 24, 2026
c8e4773
feat(deck-options): keep options open after optimizing presets
david-allison Sep 22, 2026
2973612
refactor(test): extract :anki-common helpers to fixtures
david-allison Sep 24, 2026
4d19dbf
fix: replace deprecated bundleOf calls
david-allison Sep 24, 2026
7c78fb0
refactor(browser): remove duplicate icon load
david-allison Sep 24, 2026
88e75e2
fix(browser): fix search icon tinting
david-allison Sep 24, 2026
e875fea
feat(ci): report emulator test failures
david-allison Sep 24, 2026
55b9b6c
fix(card-viewer): fix unnecessary onCleared call
david-allison Sep 24, 2026
59c5994
fix(tests): download Robolectric for Android 7.1
david-allison Sep 24, 2026
fc207d2
refactor(navigation): move deck options methods to :anki-common
david-allison Aug 16, 2026
ed39db1
refactor(navigation): inline extension methods
david-allison Aug 16, 2026
1cfc7e6
build(anki-common): add unit test & testFixtures support
david-allison Aug 16, 2026
b2e93fc
test: move ChangeManager tests to :anki-common
david-allison Aug 16, 2026
5c06630
test: use the TestChangeSubscriber fixture in ContentProviderTest
david-allison Aug 16, 2026
cd35f8f
refactor: move launchCatching to :anki-common
david-allison Aug 16, 2026
4858cb6
ported nextId from Python to Kotlin and added tests
AdityaDarke97 Sep 23, 2026
6c5a361
Set scroll lift color to colorSurface for Statistics toolbar
lukstbit Sep 25, 2026
e677ab2
fix(note-editor): hide the camera when no app can take pictures
criticalAY Sep 25, 2026
e5d8869
fix(deck-options): wait for save response before reloading
david-allison Sep 25, 2026
b524d93
fix(browser): edge to edge for 'Manage columns'
david-allison Sep 25, 2026
0458528
docs: convert licenses to SPDX format
david-allison Sep 26, 2026
75ee53a
fix(card-browser): keep the card count after rotating
criticalAY Sep 26, 2026
ceed49d
docs(libanki): defaultsForAdding
david-allison Sep 26, 2026
afb4b2d
docs(libanki): defaultDeckForNoteType
david-allison Sep 26, 2026
b813bdf
refactor: move ActivityTransitionAnimation to :anki-common
david-allison Sep 26, 2026
78891a1
fix(reminders): remove eta from notif description
ericli3690 Sep 20, 2026
d5db700
fix(reminders): add quotation marks to notif title
ericli3690 Sep 20, 2026
5f98988
fix(reminders): pluralize due card count
ericli3690 Sep 20, 2026
879a000
refactor(reminders): move reminder strings out of 12-dont-translate
ericli3690 Sep 20, 2026
a486c01
refactor(reminders): extract settings entry point strings
ericli3690 Sep 20, 2026
3d3630a
refactor(reminders): extract schedule screen strings
ericli3690 Sep 20, 2026
1cce864
refactor(reminders): extract add/edit reminder dialog strings
ericli3690 Sep 20, 2026
916e583
refactor(reminders): extract troubleshooting screen strings
ericli3690 Sep 20, 2026
a375e62
refactor(reminders): resolve troubleshooting check text via resources
ericli3690 Sep 20, 2026
b64c465
refactor(reminders): resolve troubleshooting action labels via resources
ericli3690 Sep 20, 2026
a812421
refactor(reminders): extract review reminder notification strings
ericli3690 Sep 20, 2026
226411e
refactor(reminders): extract reminder load error message
ericli3690 Sep 20, 2026
52b61f8
fix(reminders): handle RTL in notification title
ericli3690 Sep 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/adb_retry.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
#!/bin/bash
# SPDX-License-Identifier: GPL-3.0-or-later

set -euo pipefail

# ADB can report the device offline just after boot (#22033/#22042).
# Retry only connection failures reported before the command is dispatched.
# Only use for short-lived commands: stderr is captured until they exit.
stderr_file=$(mktemp)
trap 'rm -f "$stderr_file"' EXIT

for ((attempt = 1; attempt <= 5; attempt++)); do
# Keep retry messages out of stdout so getprop results remain usable.
if adb "$@" 2>"$stderr_file"; then
cat "$stderr_file" >&2
exit 0
else
adb_status=$?
fi
cat "$stderr_file" >&2

if ((attempt == 5)); then
exit "$adb_status"
fi
case "$(<"$stderr_file")" in
"adb: device offline"|"error: device offline") ;;
*) exit "$adb_status" ;;
esac
printf 'ADB device offline; retrying in 1 second (attempt %s/5).\n' "$((attempt + 1))" >&2
sleep 1
done
4 changes: 4 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ updates:
- "dependencies"
cooldown:
default-days: 3
# Missing Google Maven release dates cause Dependabot to close older AndroidX updates.
# https://github.com/dependabot/dependabot-core/issues/16055
exclude:
- "androidx.*"
ignore:
# Ignore all Rust backend updates, these are always done as manual pulls
- dependency-name: ankiBackend
Expand Down
16 changes: 2 additions & 14 deletions .github/disable_art_metrics.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,21 +3,9 @@

set -euo pipefail

# Issue 22033: ADB reported "device offline" after sys.boot_completed=1.
# The underlying cause is unknown; fixing it would likely remove the need for retries.
# Retries may help if the disconnect is transient; this is unverified in CI.
# Only use for commands safe to repeat: a failed ADB call may already have
# executed the command on the device.
# Only use for shell commands safe to repeat, such as reading or setting a value.
retry_repeatable_adb_shell_cmd() {
local attempt output
for ((attempt = 1; attempt <= 5; attempt++)); do
if output=$(adb shell "$@"); then
printf '%s\n' "$output"
return 0
fi
sleep 1
done
return 1
bash "$(dirname "${BASH_SOURCE[0]}")/adb_retry.sh" shell "$@"
}

# Both CI images use BE2A.250530.026.F3. Revisit this workaround when that changes.
Expand Down
97 changes: 97 additions & 0 deletions .github/report_emulator_test_failures.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: GPL-3.0-or-later

"""Report failures and skipped tests from Android's JUnit XML using the standard library."""

import html
import os
from pathlib import Path
import re
import sys
import xml.etree.ElementTree as ET


def read_ignored_tests(log_file):
ignored = set()
failed = set()
if log_file is None or not log_file.is_file():
return ignored
with log_file.open(encoding="utf-8", errors="replace") as log:
for line in log:
match = re.search(r"\bTestRunner: (ignored|failed): (.+)\(([^()]+)\)\s*$", line)
if match:
status, method, classname = match.groups()
(ignored if status == "ignored" else failed).add(f"{classname}.{method}")
return ignored - failed


def main(results_dir, log_file=None):
failures = []
skipped = []
ignored = read_ignored_tests(log_file)
for report in sorted(results_dir.rglob("TEST-*.xml")):
try:
root = ET.parse(report).getroot()
except (ET.ParseError, OSError) as error:
failures.append(f"Could not read {report}: {error}")
continue

for test in root.iter("testcase"):
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.
# Only match the top-level exception, not mentions in a real failure.
exception_type = result.get("type") or (details or message).partition("\n")[0].partition(":")[0]
# The Android test engine also writes @Ignore as an empty failure.
# Require the runner's ignore event before treating an empty failure as skipped.
is_ignored = name in ignored and not (details or message or result.get("type"))
if message and message not in details:
details = f"{message}\n{details}".strip()
if result.tag == "skipped" or is_ignored or exception_type in (
"org.junit.AssumptionViolatedException",
"org.junit.internal.AssumptionViolatedException",
):
skipped.append(f"{name}\n{details or result.get('type', 'Test skipped')}")
else:
failures.append(f"{name}\n{details or result.get('type', 'Test failed')}")

for failure in failures:
if os.environ.get("GITHUB_ACTIONS") == "true":
# GitHub renders the escaped message as a multiline error in the log.
message = failure.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A")
print(f"::error::{message}")
else:
print(failure, end="\n\n")

if skipped:
title = f"Skipped tests ({len(skipped)})"
if os.environ.get("GITHUB_ACTIONS") == "true":
print(f"::group::{title}")
else:
print(title, end="\n\n")
for skip in skipped:
print(skip, end="\n\n")
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 failures:
summary.write("### Emulator test failures\n\n")
for failure in failures:
summary.write(f"<pre>{html.escape(failure)}</pre>\n\n")
if skipped:
summary.write(f"<details><summary>{title}</summary>\n\n")
for skip in skipped:
summary.write(f"<pre>{html.escape(skip)}</pre>\n\n")
summary.write("</details>\n\n")

return 1 if failures else 0


if __name__ == "__main__":
sys.exit(main(Path(sys.argv[1]), Path(sys.argv[2]) if len(sys.argv) > 2 else None))
2 changes: 2 additions & 0 deletions .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ on:
# Ignore merge queue branches on push; avoids merge_group+push concurrency race since ref is same
branches-ignore:
- 'gh-readonly-queue/**'
# Dependabot branches are already checked by the pull_request trigger.
- 'dependabot/**'
merge_group:

permissions:
Expand Down
18 changes: 12 additions & 6 deletions .github/workflows/tests_emulator.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ on:
# Ignore merge queue branches on push; avoids merge_group+push concurrency race since ref is same
branches-ignore:
- 'gh-readonly-queue/**'
# Dependabot branches are already checked by the pull_request trigger.
- 'dependabot/**'
merge_group:

permissions:
Expand Down Expand Up @@ -176,7 +178,7 @@ jobs:
script: |
touch adb-log.txt
$ANDROID_HOME/platform-tools/adb logcat '*:D' >> adb-log.txt &
adb logcat --clear
bash .github/adb_retry.sh logcat --clear
echo "Generated AVD snapshot for caching."

# This step is separate so pure install time may be calculated as a step
Expand All @@ -200,10 +202,10 @@ jobs:
script: |
touch adb-log.txt
$ANDROID_HOME/platform-tools/adb logcat '*:D' >> adb-log.txt &
$ANDROID_HOME/platform-tools/adb shell su root "setprop ctl.restart zygote"
bash .github/adb_retry.sh shell su root "setprop ctl.restart zygote"
sleep 10
sleep $FIRST_BOOT_DELAY
adb logcat --clear
bash .github/adb_retry.sh logcat --clear
echo "First boot warmup completed."

- name: Run Emulator Tests
Expand All @@ -223,11 +225,15 @@ jobs:
$ANDROID_HOME/platform-tools/adb logcat '*:D' >> adb-log.txt &
bash .github/disable_art_metrics.sh
# Fix "Speech Recognition and Synthesis from Google keeps stopping" causing RootViewWithoutFocusException
adb shell settings put global hide_error_dialogs 1
adb emu screenrecord start --time-limit 1800 video.webm
bash .github/adb_retry.sh shell settings put global hide_error_dialogs 1
bash .github/adb_retry.sh emu screenrecord start --time-limit 1800 video.webm
sleep 5
./gradlew uninstallAll jacocoAndroidTestReport --daemon

- name: Report Emulator Test Results
if: always() && hashFiles('AnkiDroid/build/outputs/androidTest-results/connected/**/TEST-*.xml') != ''
run: python3 .github/report_emulator_test_failures.py AnkiDroid/build/outputs/androidTest-results/connected adb-log.txt

- name: Configure NDK tools PATH for alignment checking
run: |
echo "Using pre-installed NDK tools for alignment checking"
Expand Down Expand Up @@ -300,4 +306,4 @@ jobs:
- uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
with:
verbose: true
fail_ci_if_error: ${{ github.repository == 'ankidroid/Anki-Android' }} # optional (default = false)
fail_ci_if_error: ${{ github.repository == 'ankidroid/Anki-Android' }} # optional (default = false)
2 changes: 2 additions & 0 deletions .github/workflows/tests_unit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ on:
# Ignore merge queue branches on push; avoids merge_group+push concurrency race since ref is same
branches-ignore:
- 'gh-readonly-queue/**'
# Dependabot branches are already checked by the pull_request trigger.
- 'dependabot/**'
merge_group:

permissions:
Expand Down
3 changes: 3 additions & 0 deletions AnkiDroid/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -767,6 +767,7 @@ dependencies {
testImplementation testFixtures(project(":common:android"))
testImplementation testFixtures(project(":compat"))
testImplementation testFixtures(project(":anki-common"))
androidTestImplementation testFixtures(project(":anki-common"))

// A path for a testing library which provide Parameterized Test
testImplementation libs.junit.jupiter
Expand Down Expand Up @@ -804,6 +805,8 @@ dependencies {
// for testing flows
testImplementation libs.cashapp.turbine

androidTestImplementation testFixtures(project(":anki-common"))

// May need a resolution strategy for support libs to our versions
androidTestImplementation libs.androidx.espresso.core
androidTestImplementation(libs.androidx.espresso.contrib) {
Expand Down
2 changes: 1 addition & 1 deletion AnkiDroid/robolectricDownloader.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
def robolectricAndroidSdkVersions = [
// [androidVersion: "6.0.1_r3", frameworkSdkBuildVersion: "r1"],
// [androidVersion: "7.0.0_r1", frameworkSdkBuildVersion: "r1"],
// [androidVersion: "7.1.0_r7", frameworkSdkBuildVersion: "r1"],
[androidVersion: "7.1.0_r7", frameworkSdkBuildVersion: "r1"],
// [androidVersion: "8.0.0_r4", frameworkSdkBuildVersion: "r1"],
[androidVersion: "8.1.0", frameworkSdkBuildVersion: "4611349"],
[androidVersion: "9", frameworkSdkBuildVersion: "4913185-2"],
Expand Down
16 changes: 1 addition & 15 deletions AnkiDroid/src/androidTest/java/com/ichi2/anki/DeckOptionsTest.kt
Original file line number Diff line number Diff line change
@@ -1,18 +1,4 @@
/*
* Copyright (c) 2026 David Allison <davidallisongithub@gmail.com>
*
* This program is free software; you can redistribute it and/or modify it under
* the terms of the GNU General Public License as published by the Free Software
* Foundation; either version 3 of the License, or (at your option) any later
* version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT ANY
* WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A
* PARTICULAR PURPOSE. See the GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License along with
* this program. If not, see <http://www.gnu.org/licenses/>.
*/
// SPDX-License-Identifier: GPL-3.0-or-later

package com.ichi2.anki

Expand Down
13 changes: 5 additions & 8 deletions AnkiDroid/src/androidTest/java/com/ichi2/anki/DeckPickerTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,9 @@ import android.annotation.SuppressLint
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.assertion.ViewAssertions
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.matcher.ViewMatchers.assertThat
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
import androidx.test.ext.junit.rules.ActivityScenarioRule
import com.ichi2.anki.TestUtils.activityInstance
import com.ichi2.anki.TestUtils.isTablet
import com.ichi2.anki.tests.InstrumentedTest
import com.ichi2.anki.testutil.GrantStoragePermission.storagePermission
Expand All @@ -33,7 +31,7 @@ import com.ichi2.anki.testutil.discardPreliminaryViews
import com.ichi2.anki.testutil.grantPermissions
import com.ichi2.anki.testutil.notificationPermission
import com.ichi2.anki.testutil.tapOnCountLayouts
import org.hamcrest.Matchers.instanceOf
import com.ichi2.anki.testutil.useResumedActivity
import org.junit.Assume.assumeTrue
import org.junit.Before
import org.junit.Rule
Expand Down Expand Up @@ -65,11 +63,10 @@ class DeckPickerTest : InstrumentedTest() {
// Go to RecyclerView item having "Test Deck" and click on the counts layout
tapOnCountLayouts("Default")

// Check if currently open Activity is StudyOptionsActivity
assertThat(
activityInstance,
instanceOf(StudyOptionsActivity::class.java),
)
// ActivityScenarioRule only owns DeckPicker. Close Study Options before collection cleanup.
useResumedActivity<StudyOptionsActivity> {
onView(withId(R.id.studyoptions_frame)).check(matches(isDisplayed()))
}
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

package com.ichi2.anki

import android.webkit.WebView
import androidx.core.content.edit
import androidx.test.core.app.ActivityScenario
import androidx.test.espresso.Espresso.onView
Expand Down Expand Up @@ -290,7 +291,18 @@ class ReviewerFragmentScrollTest : InstrumentedTest() {
private val SafeWebViewLayout.pageScale: Double
get() = evaluateScript("window.visualViewport.scale").toDouble()

@Suppress("DEPRECATION") // Native zoom limits indicate whether WebView has applied the viewport.
private val SafeWebViewLayout.canZoomIn: Boolean
get() {
var canZoom = false
InstrumentationRegistry.getInstrumentation().runOnMainSync {
canZoom = (getChildAt(0) as WebView).canZoomIn()
}
return canZoom
}

private fun SafeWebViewLayout.zoomIn() {
waitUntil(message = { "WebView did not become ready to zoom" }) { canZoomIn }
val originalScale = pageScale
InstrumentationRegistry.getInstrumentation().runOnMainSync {
zoomBy(2f)
Expand Down
Loading
Loading