Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
a645d5f
refactor: extract IdsFile
david-allison Sep 29, 2026
60a89ac
fix(tests): use CommonString
david-allison Sep 29, 2026
3e82d05
build(lint): enable `checkDependencies`
david-allison Aug 30, 2026
11ae9b5
fix(ci): retry emulator installation
david-allison Sep 17, 2026
19d5287
fix(manage-tags): make search input independent of loading
david-allison Sep 27, 2026
a7ea895
refactor(manage-tags): combine tag operation handling
david-allison Sep 27, 2026
de648db
fix(manage-tags): prevent overlapping tag operations
david-allison Sep 27, 2026
0cbd254
fix(manage-tags): keep content visible while operations run
david-allison Sep 27, 2026
f080a7b
fix(manage-tags): keep content visible after operation failures
david-allison Sep 27, 2026
eed0d71
feat(manage-tags): retain messages until acknowledged
david-allison Sep 27, 2026
8c9be9b
fix(build): narrow JaCoCo execution data file trees
mikehardy Sep 29, 2026
ebeb8b1
refactor(tests): use [positive/negative]Button extensions
david-allison Sep 29, 2026
e4b5622
Bumped version to 2.26.0alpha1
mikehardy Sep 30, 2026
c9e2189
test: fail on Robolectric leftover activities after each test
mikehardy Sep 30, 2026
b4a63d6
build(lint): enable WrongThread in JVM libraries
mikehardy Sep 30, 2026
3845a47
fix(tts): prefer an installed voice for locale-only tags
criticalAY Jul 17, 2026
91d120f
fix(reviewer): stop render recovery when the activity is closing
david-allison Sep 26, 2026
e73de7c
test: screens with keyboard open
david-allison Sep 26, 2026
4767ddd
fix(fields): keep content above keyboard
david-allison Sep 26, 2026
32c59b4
fix(study-options): keep content above keyboard
david-allison Sep 26, 2026
a462515
fix(reviewer): fix MathJax text scaling
david-allison Sep 27, 2026
cde9d69
test(reviewer): text scaling
david-allison Sep 27, 2026
9651dd3
fix(snackbar): retain pending snackbars until shown
david-allison Sep 27, 2026
feef8fb
fix(sync): fix crash - foreground service timeout
david-allison Sep 29, 2026
21d0643
fix(tags): stop the tag filter freezing in landscape
criticalAY Sep 28, 2026
bb8dd61
fix(tags): keep tags visible above the keyboard
criticalAY Sep 28, 2026
7773e08
refactor: separate ViewModel state from arguments
david-allison Sep 27, 2026
63ed2f9
test: preserve ViewModel state across recreation
david-allison Sep 27, 2026
a534253
fix(export): stop duplicating intent extras in state
david-allison Sep 27, 2026
f965d02
fix(sync): fix race condition on sync completion
david-allison Sep 29, 2026
fd7d4c3
Updated strings from Crowdin
Sep 30, 2026
79471b2
docs(tests): explain separate Robolectric SDK configurations
david-allison Sep 25, 2026
9802781
fix(tests): avoid Robolectric download lock contention
david-allison Sep 25, 2026
2024629
fix: Note Editor create/edit toolbar item dialogs
Koc13 Aug 30, 2026
f958119
test: clarify deck options save and reload assertions
david-allison Sep 28, 2026
05f3726
refactor: extract SaveAndOptimizeReload
david-allison Sep 28, 2026
b13d044
refactor: notify Android after response body is read
david-allison Sep 28, 2026
5e4bb45
refactor: natively handle deck options saves failures
david-allison Sep 28, 2026
0ee995f
fix: defer deck options reloads until saving finishes
david-allison Sep 28, 2026
7a27751
fix(tests): await reviewer answer result
david-allison Sep 29, 2026
1e674e4
test: detect leaked OnlyOnce operations
david-allison Sep 29, 2026
37228c1
test: make collection lease tests deterministic
david-allison Sep 29, 2026
7c96b26
Merge remote-tracking branch 'origin/main' into merge/upstream-sep30
float3 Sep 30, 2026
bbf9392
Run the pre-channel notifier tests on API 25
float3 Sep 30, 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
14 changes: 14 additions & 0 deletions .github/workflows/tests_emulator.yml
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,20 @@ jobs:
max_attempts: 3
command: ./gradlew packagePlayRelease packagePlayReleaseAndroidTest --daemon

- name: Install Android Emulator
# Retry to work around https://github.com/ReactiveCircus/android-emulator-runner/issues/496
uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0
with:
timeout_minutes: 5
retry_wait_seconds: 60
max_attempts: 3
command: '"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --verbose --install emulator --channel=0'
# sdkmanager preserves failed downloads and installation state for resuming.
on_retry_command: >-
rm -rf "${ANDROID_HOME:?}/.temp"
"${ANDROID_HOME:?}/.downloadIntermediates"
"${ANDROID_HOME:?}/emulator/.installer"

- name: AVD Boot and Snapshot Creation
# Only generate a snapshot for saving if we are on main branch with a cache miss
# Comment the if out to generate snapshots on branch for performance testing
Expand Down
30 changes: 20 additions & 10 deletions AnkiDroid/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,17 @@ android {
//
// This ensures the correct ordering between the various types of releases (dev < alpha < beta < release) which is
// needed for upgrades to be offered correctly.
versionCode=22600100
versionCode=22600101
// If you change this to a new version, you probably also want to update .gradle/workflows/milestone.yml for the new version...
versionName="2.26.0alpha1"
minSdk = libs.versions.minSdk.get().toInteger()

// After #13695: change .tests_emulator.yml
targetSdk = libs.versions.targetSdk.get().toInteger()
testApplicationId = "com.ichi2.anki.tests"
vectorDrawables.useSupportLibrary = true
testInstrumentationRunner = 'com.ichi2.testutils.NewCollectionPathTestRunner'

// Quest tags have their own monotonic upgrade sequence, independent of
// the upstream beta version. Keep the eight-digit ABI-split budget.
// quest-<n> is 5nnnn000; nightly builds after it add their commit count,
Expand All @@ -137,15 +147,6 @@ android {
setVersionCode(50000000 + questRelease.toInteger() * 1000 + questNightly.toInteger())
}
}
// If you change this to a new version, you probably also want to update .gradle/workflows/milestone.yml for the new version...
versionName="2.26.0alpha0"
minSdk = libs.versions.minSdk.get().toInteger()

// After #13695: change .tests_emulator.yml
targetSdk = libs.versions.targetSdk.get().toInteger()
testApplicationId = "com.ichi2.anki.tests"
vectorDrawables.useSupportLibrary = true
testInstrumentationRunner = 'com.ichi2.testutils.NewCollectionPathTestRunner'

// GA4 Measurement Protocol api_secret. Sourced from `local.properties`
// (ANALYTICS_API_KEY) or the ANALYTICS_API_KEY env var; falls back to
Expand Down Expand Up @@ -687,6 +688,15 @@ apply from: "./robolectricDownloader.gradle"
apply from: "./jacoco.gradle.kts"
apply from: "../lint.gradle"

android {
lint {
// Analyze project-local dependencies (:common:android etc.) as part of this
// module's lint run.
// Required for whole-app UnusedResources and StringFormatMatches coverage
checkDependencies = true
}
}

dependencies {
configurations.configureEach {
resolutionStrategy {
Expand Down
47 changes: 23 additions & 24 deletions AnkiDroid/jacoco.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,25 @@ fun builtInKotlincClassDir(variant: String): String {
val flavorClassDir = builtInKotlincClassDir("play${rootProject.androidTestVariantName}")
val moduleClassDir = builtInKotlincClassDir(rootProject.androidTestVariantName.lowercase())

// Narrow coverage roots: a fileTree over the whole build/ dir makes Gradle treat every
// sibling task output under build/ as an undeclared input (e.g. lintFullDebug + this report).
val unitTestCoverageDirs = listOf("outputs/unit_test_code_coverage", "jacoco")
val androidTestCoverageDirs = listOf("outputs/code_coverage")

fun Project.coverageFileTree(
relativeDirs: List<String>,
includes: List<String>,
) = files(
relativeDirs.map { relativeDir ->
fileTree(
mapOf(
"dir" to layout.buildDirectory.dir(relativeDir),
"includes" to includes,
),
)
},
)

// Our merge report task
tasks.register<JacocoReport>("jacocoTestReport") {
val htmlOutDir =
Expand Down Expand Up @@ -120,12 +139,8 @@ tasks.register<JacocoReport>("jacocoTestReport") {
sourceDirectories.setFrom(project.files(mainSrc))
classDirectories.setFrom(project.files(kotlinClasses))
executionData.setFrom(
project.fileTree(
mapOf(
"dir" to project.layout.buildDirectory,
"includes" to listOf("**/*.exec", "**/*.ec"),
),
),
project.coverageFileTree(unitTestCoverageDirs, listOf("**/*.exec")),
project.coverageFileTree(androidTestCoverageDirs, listOf("**/*.ec")),
)
dependsOn("testPlayDebugUnitTest")
dependsOn("connectedPlay${rootProject.androidTestVariantName}AndroidTest")
Expand Down Expand Up @@ -208,16 +223,7 @@ fun includeUnitTestCoverage(
),
),
)
report.executionData.from(
project.files(
project.fileTree(
mapOf(
"dir" to project.layout.buildDirectory,
"includes" to listOf("**/*.exec"),
),
),
),
)
report.executionData.from(project.coverageFileTree(unitTestCoverageDirs, listOf("**/*.exec")))
}

// A connected android tests only report task
Expand Down Expand Up @@ -250,14 +256,7 @@ tasks.register<JacocoReport>("jacocoAndroidTestReport") {

sourceDirectories.setFrom(project.files(mainSrc))
classDirectories.setFrom(project.files(kotlinClasses))
executionData.setFrom(
project.fileTree(
mapOf(
"dir" to project.layout.buildDirectory,
"includes" to listOf("**/*.ec"),
),
),
)
executionData.setFrom(project.coverageFileTree(androidTestCoverageDirs, listOf("**/*.ec")))
dependsOn("connectedPlay${rootProject.androidTestVariantName}AndroidTest")
}

Expand Down
40 changes: 12 additions & 28 deletions AnkiDroid/robolectricDownloader.gradle
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/**
* Downloads all android-all-instrumented dependencies and copies them to the mavenLocal() repository
* Downloads android-all-instrumented dependencies into Gradle's cache for offline Robolectric.
* Writes a properties file pointing to the cached jars, so worktrees do not need their own copies.
*
* Once applied to your gradle project, can be executed with ./gradlew robolectricSdkDownload
*/
Expand All @@ -13,11 +14,8 @@
// Note: the PREINSTRUMENTED_VERSION constant is what you put on the end of the artifact

// Only the versions currently used in AnkiDroid Robolectric tests are active, the rest are commented out
// To update these versions, open a terminal in the Anki-Android directory and perform the following steps:
// 1. Run `rm -r ~/.m2` to delete the .m2 directory in your home directory.
// 2. Run `./gradlew jacocoUnitTestReport` to run all unit tests.
// 3. Run `find ~/.m2 -type d -name '*-robolectric-*'`.
// 4. Update the lines below to match the output from `find`.
// When updating Robolectric, match these versions to DefaultSdkProvider and PREINSTRUMENTED_VERSION.
// Run `./gradlew jacocoUnitTestReport` to check that all SDKs used by the tests are included.
def robolectricAndroidSdkVersions = [
// [androidVersion: "6.0.1_r3", frameworkSdkBuildVersion: "r1"],
// [androidVersion: "7.0.0_r1", frameworkSdkBuildVersion: "r1"],
Expand All @@ -36,13 +34,13 @@ def robolectricAndroidSdkVersions = [
[androidVersion: "16", frameworkSdkBuildVersion: "13921718"], // current targetSdk
]

// Base, public task - will be displayed in ./gradlew robolectricDownloader:tasks
tasks.register('robolectricSdkDownload') {
def robolectricSdkDownload = tasks.register('robolectricSdkDownload', WriteProperties) {
group = "Dependencies"
description = "Downloads all robolectric SDK dependencies into mavenLocal, for use with offline robolectric"
description = "Downloads Robolectric SDKs and writes their cached paths for offline tests"
destinationFile = layout.buildDirectory.file("robolectric-deps.properties")
}

// Generate the configuration and actual copy tasks.
// Use separate configurations so Gradle retains every SDK version instead of selecting the newest.
robolectricAndroidSdkVersions.forEach { robolectricSdkVersion ->
// the final part of this `-i<number>` comes from PREINSTRUMENTED_VERSION in upstream DefaultSdkProvider
def version = "${robolectricSdkVersion['androidVersion']}-robolectric-${robolectricSdkVersion['frameworkSdkBuildVersion']}-i7"
Expand All @@ -52,24 +50,10 @@ robolectricAndroidSdkVersions.forEach { robolectricSdkVersion ->
def sdkConfig = configurations.create(version)
dependencies.add(version, "org.robolectric:android-all-instrumented:${version}")

// An ArtifactView on the existing configuration gives us the POM alongside the JAR so
// maven local has both.
def sdkPomFiles = sdkConfig.incoming.artifactView {
attributes {
attribute(ArtifactTypeDefinition.ARTIFACT_TYPE_ATTRIBUTE, "pom")
}
// if the POM can't be resolved, return an empty collection rather than failing the build
lenient = true
}.files

def mavenLocalFile = new File(this.repositories.mavenLocal().url)
def mavenRobolectric = new File(mavenLocalFile, "org/robolectric/android-all-instrumented/${version}")
// Copying all files downloaded for the created configuration into maven local.
tasks.register("robolectricSdkDownload-${version}", Copy) {
from sdkConfig
from sdkPomFiles
into mavenRobolectric
robolectricSdkDownload.configure {
inputs.files(sdkConfig).withPropertyName(version)
property("org.robolectric:android-all-instrumented:${version}",
sdkConfig.elements.map { it.iterator().next().asFile.absolutePath })
}
robolectricSdkDownload.configure { dependsOn "robolectricSdkDownload-${version}" }
}

125 changes: 103 additions & 22 deletions AnkiDroid/src/androidTest/java/com/ichi2/anki/pages/DeckOptionsTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package com.ichi2.anki.pages

import androidx.core.content.edit
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import androidx.test.core.app.ActivityScenario
import androidx.test.platform.app.InstrumentationRegistry
import com.ichi2.anki.CollectionManager.TR
Expand All @@ -15,6 +16,8 @@ import com.ichi2.anki.tests.InstrumentedTest
import com.ichi2.anki.testutil.waitUntil
import com.ichi2.testutils.ext.defaultDeckNewCardsPerDay
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.async
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import org.json.JSONObject
Expand Down Expand Up @@ -71,38 +74,116 @@ class DeckOptionsTest : InstrumentedTest() {
fun optimizingAllPresetsSavesAndReloadsOptions() {
withDeckOptions {
saveAndOptimize(newPerDay = 43)
assertSavedAndReloaded(newPerDay = 43)
}
}

@Test
fun optimizingAllPresetsWaitsForSlowSave() {
withDelayedSave {
saveAndOptimize(newPerDay = 43)
assertSavedAndReloaded(newPerDay = 43)
}
}

waitUntil(timeout = 30.seconds, message = { "optimization did not save the changed limit" }) {
col.defaultDeckNewCardsPerDay == 43
@Test
fun failedOptimizationKeepsEditsAndAllowsRetry() {
val originalLimit = col.defaultDeckNewCardsPerDay
withDelayedSave(failFirstAttempt = true) {
// Capture the error without leaving a dialog blocking the retry.
evaluateJavascript("window.alert = message => { globalThis.saveError = String(message); }")
saveAndOptimize(newPerDay = 43)

waitForPageCondition(
condition = "globalThis.saveError?.includes('Optimization failed for test') === true",
message = "save error was not displayed",
)
assertEquals("true", evaluateJavascript("globalThis.beforeOptimization === true"), "failed save reloaded the page")
assertEquals(originalLimit, col.defaultDeckNewCardsPerDay)

saveAndOptimize(newPerDay = 43)
assertSavedAndReloaded(newPerDay = 43)
}
}

/** Exercise a pending response, optionally rejecting the first save so the test can retry. */
private fun withDelayedSave(
failFirstAttempt: Boolean = false,
block: DeckOptions.() -> Unit,
) {
val originalHandler = uiMethods.getValue("updateDeckConfigs")
var failNextSave = failFirstAttempt
uiMethods["updateDeckConfigs"] = { input ->
val activity = this
lifecycleScope.async {
// explicitly reproduce the reload race
delay(1.seconds)
if (failNextSave) {
failNextSave = false
error("Optimization failed for test")
}
originalHandler.invoke(activity, input).await()
}
val optionsReloaded = AtomicBoolean(false)
waitUntil(timeout = 30.seconds, message = { "options did not reload after optimization" }) {
// Reloading can discard an evaluation callback. Retry without waiting for each one.
InstrumentationRegistry.getInstrumentation().runOnMainSync {
webViewLayout.evaluateJavascript(
"globalThis.beforeOptimization === undefined && Array.from(document.querySelectorAll('input[type=number]')).find(input => input.offsetParent !== null)?.value === '43'",
) {
if (it == "true") optionsReloaded.set(true)
}
}
try {
withDeckOptions(block)
} finally {
uiMethods["updateDeckConfigs"] = originalHandler
}
}

private fun DeckOptions.assertSavedAndReloaded(newPerDay: Int) {
waitUntil(timeout = 30.seconds, message = { "optimization did not save the changed limit" }) {
col.defaultDeckNewCardsPerDay == newPerDay
}

// The edited field already has the expected value. The marker disappearing proves
// that a new document was loaded and the value came back from the saved settings.
waitForPageCondition(
condition =
"""
(() => {
const newCardsPerDay = Array.from(document.querySelectorAll('input[type="number"]'))
.find(input => input.offsetParent !== null);
return globalThis.beforeOptimization === undefined && newCardsPerDay?.value === '$newPerDay';
})();
""".trimIndent(),
message = "options did not reload after optimization",
)
assertFalse(requireActivity().isFinishing)
}

/** Poll across page reloads, which can discard an individual JavaScript evaluation callback. */
private fun DeckOptions.waitForPageCondition(
condition: String,
message: String,
) {
val satisfied = AtomicBoolean(false)
waitUntil(timeout = 30.seconds, message = { message }) {
// Reloading can discard an evaluation callback. Retry without waiting for each one.
InstrumentationRegistry.getInstrumentation().runOnMainSync {
webViewLayout.evaluateJavascript(condition) {
if (it == "true") satisfied.set(true)
}
optionsReloaded.get()
}
assertFalse(requireActivity().isFinishing)
satisfied.get()
}
}

private fun DeckOptions.saveAndOptimize(newPerDay: Int) {
val script =
"""
globalThis.beforeOptimization = true;
window.confirm = () => true;
const input = Array.from(document.querySelectorAll('input[type="number"]')).find(input => input.offsetParent !== null);
input.focus();
input.value = '$newPerDay';
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
Array.from(document.querySelectorAll('button'))
.find(button => button.textContent.trim() === ${JSONObject.quote(TR.deckConfigSaveAndOptimize())}).click();
(() => {
globalThis.beforeOptimization = true;
window.confirm = () => true;
const input = Array.from(document.querySelectorAll('input[type="number"]')).find(input => input.offsetParent !== null);
input.focus();
input.value = '$newPerDay';
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
Array.from(document.querySelectorAll('button'))
.find(button => button.textContent.trim() === ${JSONObject.quote(TR.deckConfigSaveAndOptimize())}).click();
})();
""".trimIndent()
// The action navigates away from its JavaScript context, which can discard the
// evaluation callback. Observe the persisted settings and the new page instead.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1061,6 +1061,8 @@ abstract class AbstractFlashcardViewer :
MyWebView(this).apply {
scrollBarStyle = View.SCROLLBARS_OUTSIDE_OVERLAY
with(settings) {
// Scale the whole card with CSS instead; text-only zoom breaks MathJax layout.
textZoom = 100
displayZoomControls = false
builtInZoomControls = true
setSupportZoom(true)
Expand Down
4 changes: 3 additions & 1 deletion AnkiDroid/src/main/java/com/ichi2/anki/AndroidTtsPlayer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,9 @@ class AndroidTtsPlayer(
TtsVoices.ttsEngine?.let { defaultEngine -> getOrCreateTts(defaultEngine) }
}

override fun getAvailableVoices(): List<TtsVoice> = this.voices
// Prefer installed voices for language fallback without changing the shared matcher.
// Keep uninstalled voices selectable: an explicit request can trigger their download.
override fun getAvailableVoices(): List<TtsVoice> = voices.sortedBy { it.unavailable() }

/**
* Returns a ready [TextToSpeech] bound to [engine], creating and caching one if necessary.
Expand Down
Loading
Loading