From 93d74b4a904125a084d1480dabb348448da4937c Mon Sep 17 00:00:00 2001
From: David Allison <62114487+david-allison@users.noreply.github.com>
Date: Tue, 29 Sep 2026 22:04:06 +0100
Subject: [PATCH 001/150] fix(tests): make caller stack traces deterministic
Ensure `requestCollection` suspends before the opening error is raised.
Part of 22197
Assisted-by: GPT-6
---
.../ichi2/anki/CollectionManagerStackTraceTest.kt | 12 ++++++------
1 file changed, 6 insertions(+), 6 deletions(-)
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerStackTraceTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerStackTraceTest.kt
index 8c38dca3ffc9..1cf6d04952cd 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerStackTraceTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerStackTraceTest.kt
@@ -2,12 +2,11 @@
package com.ichi2.anki
-import android.os.Build
import androidx.test.ext.junit.runners.AndroidJUnit4
+import kotlinx.coroutines.test.StandardTestDispatcher
import net.ankiweb.rsdroid.BackendException.BackendDbException.BackendDbLockedException
import org.junit.Test
import org.junit.runner.RunWith
-import org.robolectric.shadows.ShadowBuild
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
@@ -17,9 +16,9 @@ class CollectionManagerStackTraceTest : RobolectricTest() {
@Test
fun `opening failure records the withCol caller`() =
runTest {
- val originalFingerprint = Build.FINGERPRINT
- // Exercise the real dispatcher switch: Robolectric's synchronous queue retains the caller.
- ShadowBuild.setFingerprint("collection-stack-trace-test")
+ // Queue collection access so the failure occurs after the caller suspends.
+ val previousQueue =
+ CollectionManager.setTestDispatcher(StandardTestDispatcher(testScheduler), useReentrantLock = false)
try {
CollectionManager.emulatedOpenFailure = CollectionManager.CollectionOpenFailure.LOCKED
@@ -29,7 +28,8 @@ class CollectionManagerStackTraceTest : RobolectricTest() {
val caller = failure.suppressed.single()
assertTrue(caller.stackTrace.any { it.methodName == "requestCollection" })
} finally {
- ShadowBuild.setFingerprint(originalFingerprint)
+ CollectionManager.emulatedOpenFailure = null
+ CollectionManager.setTestDispatcher(previousQueue)
}
}
From 2ea8504d89903581dbf26fae1676a5457b8d3716 Mon Sep 17 00:00:00 2001
From: David Allison <62114487+david-allison@users.noreply.github.com>
Date: Tue, 29 Sep 2026 22:05:29 +0100
Subject: [PATCH 002/150] fix(tests): restore the lease test dispatcher
This ensures the collection queue dispatcher is unchanged.
I will unit test this later.
Part of 22197
Assisted-by: GPT-6
---
.../src/test/java/com/ichi2/anki/CollectionLeaseTest.kt | 5 ++---
1 file changed, 2 insertions(+), 3 deletions(-)
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionLeaseTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionLeaseTest.kt
index 8e1cd8d316db..8282cf475a3c 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionLeaseTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionLeaseTest.kt
@@ -18,7 +18,6 @@ import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
-import kotlinx.coroutines.test.UnconfinedTestDispatcher
import net.ankiweb.rsdroid.Backend
import net.ankiweb.rsdroid.BackendException
import org.hamcrest.MatcherAssert.assertThat
@@ -413,11 +412,11 @@ class CollectionLeaseTest : RobolectricTest() {
dispatcher: CoroutineDispatcher,
block: suspend CoroutineScope.() -> Unit,
) {
- CollectionManager.setTestDispatcher(dispatcher, useReentrantLock = false)
+ val previousDispatcher = CollectionManager.setTestDispatcher(dispatcher, useReentrantLock = false)
try {
coroutineScope(block)
} finally {
- CollectionManager.setTestDispatcher(UnconfinedTestDispatcher(testScheduler))
+ CollectionManager.setTestDispatcher(previousDispatcher)
}
}
}
From d74d931b8246b54057a20d47c63fc3214c246f4b Mon Sep 17 00:00:00 2001
From: David Allison <62114487+david-allison@users.noreply.github.com>
Date: Tue, 29 Sep 2026 22:18:35 +0100
Subject: [PATCH 003/150] fix(tests): restore test dispatchers
Part of 22197
Assisted-by: GPT-6
---
.../java/com/ichi2/anki/RobolectricTest.kt | 16 ++++++++-----
.../testutils/ProductionCollectionManager.kt | 6 -----
.../ichi2/anki/libanki/testutils/AnkiTest.kt | 23 +++++++++++++------
3 files changed, 26 insertions(+), 19 deletions(-)
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt
index 3db9dcb2b740..e3089deb3e22 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt
@@ -53,8 +53,6 @@ import com.ichi2.testutils.grantPermissions
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.TestDispatcher
-import kotlinx.coroutines.test.TestScope
-import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.resetMain
import net.ankiweb.rsdroid.BackendException
import net.ankiweb.rsdroid.testing.RustBackendLoader
@@ -497,10 +495,16 @@ open class RobolectricTest :
ioDispatcher = dispatcher
}
- override suspend fun TestScope.runTestInner(testBody: suspend TestScope.() -> Unit) {
- (collectionManager as? ProductionCollectionManager)
- ?.setTestDispatcher(UnconfinedTestDispatcher(testScheduler))
- testBody()
+ override fun withTestDispatcher(
+ dispatcher: TestDispatcher,
+ block: () -> Unit,
+ ) {
+ val previousDispatcher = CollectionManager.setTestDispatcher(dispatcher)
+ try {
+ block()
+ } finally {
+ CollectionManager.setTestDispatcher(previousDispatcher)
+ }
}
}
diff --git a/AnkiDroid/src/test/java/com/ichi2/testutils/ProductionCollectionManager.kt b/AnkiDroid/src/test/java/com/ichi2/testutils/ProductionCollectionManager.kt
index 3353307cc8f1..1979ce1462ea 100644
--- a/AnkiDroid/src/test/java/com/ichi2/testutils/ProductionCollectionManager.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/testutils/ProductionCollectionManager.kt
@@ -4,7 +4,6 @@ package com.ichi2.testutils
import com.ichi2.anki.CollectionManager
import com.ichi2.anki.libanki.testutils.TestCollectionManager
-import kotlinx.coroutines.CoroutineDispatcher
/**
* Adapts [CollectionManager] to [TestCollectionManager]
@@ -15,9 +14,4 @@ object ProductionCollectionManager : TestCollectionManager {
override suspend fun discardBackend() {
CollectionManager.discardBackend()
}
-
- /** @see CollectionManager.setTestDispatcher */
- fun setTestDispatcher(dispatcher: CoroutineDispatcher) {
- CollectionManager.setTestDispatcher(dispatcher)
- }
}
diff --git a/libanki/src/testFixtures/java/com/ichi2/anki/libanki/testutils/AnkiTest.kt b/libanki/src/testFixtures/java/com/ichi2/anki/libanki/testutils/AnkiTest.kt
index 1e7cfd2d0b2e..36e651234f88 100644
--- a/libanki/src/testFixtures/java/com/ichi2/anki/libanki/testutils/AnkiTest.kt
+++ b/libanki/src/testFixtures/java/com/ichi2/anki/libanki/testutils/AnkiTest.kt
@@ -406,17 +406,26 @@ interface AnkiTest {
Dispatchers.setMain(dispatcher)
setupTestDispatcher(dispatcher)
- repeat(times) {
- if (times != 1) Timber.d("------ Executing test $it/$times ------")
- kotlinx.coroutines.test.runTest(context + scheduler, dispatchTimeout) {
- runTestInner(testBody)
+ withTestDispatcher(dispatcher) {
+ repeat(times) {
+ if (times != 1) Timber.d("------ Executing test $it/$times ------")
+ kotlinx.coroutines.test.runTest(context + scheduler, dispatchTimeout, testBody)
}
}
}
- /** Runs [testBody], supporting [TestScope]-specific setup & teardown */
- suspend fun TestScope.runTestInner(testBody: suspend TestScope.() -> Unit) {
- testBody()
+ /**
+ * Override hook for dispatcher setup and cleanup around the complete coroutine test.
+ *
+ * By default, just runs [block]. Android tests override this to install [dispatcher] in
+ * CollectionManager and restore it afterward. [block] includes completion of child coroutines.
+ */
+ @Suppress("UNUSED_PARAMETER") // The dispatcher is used by overrides.
+ fun withTestDispatcher(
+ dispatcher: TestDispatcher,
+ block: () -> Unit,
+ ) {
+ block()
}
val Notetypes.basic
From e5b600768f4be59692316d4e3845bf17eb9a21c9 Mon Sep 17 00:00:00 2001
From: David Allison <62114487+david-allison@users.noreply.github.com>
Date: Tue, 29 Sep 2026 22:20:28 +0100
Subject: [PATCH 004/150] test: fail on leaked collection dispatchers
Regression test for 22197
Assisted-by: GPT-6
---
.../src/test/java/com/ichi2/anki/RobolectricTest.kt | 10 +++++++++-
1 file changed, 9 insertions(+), 1 deletion(-)
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt
index e3089deb3e22..b5e1a2e0eca7 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt
@@ -50,6 +50,7 @@ import com.ichi2.testutils.common.FailOnUnhandledExceptionRule
import com.ichi2.testutils.common.IgnoreFlakyTestsInCIRule
import com.ichi2.testutils.filter
import com.ichi2.testutils.grantPermissions
+import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.test.TestDispatcher
@@ -75,6 +76,7 @@ import org.robolectric.shadows.ShadowLooper
import org.robolectric.shadows.ShadowMediaPlayer
import timber.log.Timber
import kotlin.test.assertNotNull
+import kotlin.test.assertSame
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
import kotlin.time.TimeSource
@@ -500,11 +502,17 @@ open class RobolectricTest :
block: () -> Unit,
) {
val previousDispatcher = CollectionManager.setTestDispatcher(dispatcher)
+ val dispatcherAfterTest: CoroutineDispatcher
try {
block()
} finally {
- CollectionManager.setTestDispatcher(previousDispatcher)
+ dispatcherAfterTest = CollectionManager.setTestDispatcher(previousDispatcher)
}
+ assertSame(
+ dispatcher,
+ dispatcherAfterTest,
+ "CollectionManager dispatcher was not restored. Save and restore it in a finally block.",
+ )
}
}
From 261482acc7042855c6fab36c6ca6060fed41a486 Mon Sep 17 00:00:00 2001
From: David Allison <62114487+david-allison@users.noreply.github.com>
Date: Fri, 25 Sep 2026 13:03:43 +0100
Subject: [PATCH 005/150] docs: SortOrderBottomSheetFragment
---
.../browser/search/SortOrderBottomSheetFragment.kt | 10 ++++++++++
1 file changed, 10 insertions(+)
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/SortOrderBottomSheetFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/SortOrderBottomSheetFragment.kt
index 25865d0406e8..a68b6dbfefbb 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/SortOrderBottomSheetFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/SortOrderBottomSheetFragment.kt
@@ -45,6 +45,16 @@ private typealias Reverse = Boolean?
/**
* A [BottomSheetDialogFragment] allowing selection of the sort order of the Card Browser
*
+ * Entitled "Choose display order"
+ *
+ * The fragment displays a list of columns which can be sorted by:
+ * Active, then Available, and allows a user to sort on each column in either an ascending or
+ * descending order.
+ *
+ * Columns have [sort types][ColumnType] which are displayed (text, numerical, dates etc...)
+ *
+ * 'No sorting' is also offered as an option.
+ *
* @param viewModelProviderFactory A factory producing a [CardBrowserViewModel]
*/
class SortOrderBottomSheetFragment(
From 9fbe3ffdc1144782a242cd5ed554e8e0bc04f2d7 Mon Sep 17 00:00:00 2001
From: David Allison <62114487+david-allison@users.noreply.github.com>
Date: Fri, 25 Sep 2026 13:55:57 +0100
Subject: [PATCH 006/150] test: support tablet screenshots in CI
Assisted-by: GPT-6
---
.../java/com/ichi2/anki/ScreenshotTest.kt | 24 ++++++++++++++++++-
1 file changed, 23 insertions(+), 1 deletion(-)
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt
index c4fdde87afc4..6a2d4b7c279f 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt
@@ -52,6 +52,22 @@ abstract class ScreenshotTest : RobolectricTest() {
enum class DeviceConfig { PHONE, TABLET, FOLDABLE, DESKTOP }
+ /**
+ * Devices always covered by the annotated class, in addition to the `-Pdevice` selection.
+ *
+ * Usage:
+ *
+ * ```kt
+ * @IncludeDevices(TABLET)
+ * class TestClass : ScreenshotTest() {
+ * ```
+ */
+ @Target(AnnotationTarget.CLASS)
+ @Retention(AnnotationRetention.RUNTIME)
+ annotation class IncludeDevices(
+ vararg val value: DeviceConfig,
+ )
+
@TestParameter(valuesProvider = ThemeProvider::class)
lateinit var theme: ThemeConfig
@@ -180,7 +196,13 @@ abstract class ScreenshotTest : RobolectricTest() {
if ("all" in requestedDevices) {
return DeviceConfig.entries
}
- return DeviceConfig.entries.filter { requestedDevices.contains(it.name.lowercase()) }
+ val includedDevices =
+ context
+ ?.testClass()
+ ?.getAnnotation(IncludeDevices::class.java)
+ ?.value
+ .orEmpty()
+ return DeviceConfig.entries.filter { it in includedDevices || requestedDevices.contains(it.name.lowercase()) }
}
}
}
From 7f401185b8f97ae528796e71029fb57c6b2a02f7 Mon Sep 17 00:00:00 2001
From: David Allison <62114487+david-allison@users.noreply.github.com>
Date: Fri, 25 Sep 2026 13:15:06 +0100
Subject: [PATCH 007/150] test: screenshots tests for "Choose display order"
portrait & landscape:
* normal
* scrolled to bottom
* scrollshot
* tablet
Part of 22080
---
.idea/dictionaries/davidallison.xml | 1 +
.../java/com/ichi2/anki/ScreenshotTest.kt | 27 ++++++++--
...tOrderBottomSheetFragmentScreenshotTest.kt | 53 +++++++++++++++++++
.../java/com/ichi2/testutils/ScreenSize.kt | 32 +++++++++--
4 files changed, 106 insertions(+), 7 deletions(-)
create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/browser/search/SortOrderBottomSheetFragmentScreenshotTest.kt
diff --git a/.idea/dictionaries/davidallison.xml b/.idea/dictionaries/davidallison.xml
index 833a85d06aa6..fce38a732117 100644
--- a/.idea/dictionaries/davidallison.xml
+++ b/.idea/dictionaries/davidallison.xml
@@ -95,6 +95,7 @@
rerender
resync
retryable
+ scrollshot
searchpreference
servicelayer
sinh
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt
index 6a2d4b7c279f..4bf33d50b7db 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt
@@ -7,9 +7,11 @@ import android.view.WindowManager
import android.widget.EditText
import androidx.core.content.getSystemService
import androidx.core.view.allViews
+import androidx.core.view.drawToBitmap
import com.github.takahirom.roborazzi.ExperimentalRoborazziApi
import com.github.takahirom.roborazzi.RobolectricDeviceQualifiers
import com.github.takahirom.roborazzi.RoborazziOptions
+import com.github.takahirom.roborazzi.captureRoboImage
import com.github.takahirom.roborazzi.captureScreenRoboImage
import com.github.takahirom.roborazzi.provideRoborazziContext
import com.google.testing.junit.testparameterinjector.TestParameter
@@ -141,7 +143,24 @@ abstract class ScreenshotTest : RobolectricTest() {
* Writes to /diffs/ if there is an issue.
*/
@OptIn(ExperimentalRoborazziApi::class)
- protected fun captureScreen(name: String) {
+ protected fun captureScreen(name: String) =
+ capture(name) { filePath, options ->
+ captureScreenRoboImage(filePath = filePath, roborazziOptions = options)
+ }
+
+ /** Captures a laid-out view, including content extending beyond the screen. */
+ protected fun captureView(
+ name: String,
+ view: View,
+ ) = capture(name) { filePath, options ->
+ view.drawToBitmap().captureRoboImage(filePath = filePath, roborazziOptions = options)
+ }
+
+ @OptIn(ExperimentalRoborazziApi::class)
+ private fun capture(
+ name: String,
+ captureImage: (String, RoborazziOptions) -> Unit,
+ ) {
// Note: this.javaClass should not be used inside a lambda, as 'this' will be unnamed
val classDir = "build/outputs/roborazzi/${this.javaClass.simpleName}"
val diffDir = File("$classDir/diffs")
@@ -150,9 +169,9 @@ abstract class ScreenshotTest : RobolectricTest() {
val baseline = File(classDir, fileName)
disableScrollbarFading()
restartTextCursorBlink()
- captureScreenRoboImage(
- filePath = baseline.path,
- roborazziOptions = provideRoborazziContext().options.withCompareOutputDir(diffDir.path),
+ captureImage(
+ baseline.path,
+ provideRoborazziContext().options.withCompareOutputDir(diffDir.path),
)
// copy the baseline into /diffs (if it exists)
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/browser/search/SortOrderBottomSheetFragmentScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/browser/search/SortOrderBottomSheetFragmentScreenshotTest.kt
new file mode 100644
index 000000000000..16e0726932ce
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/browser/search/SortOrderBottomSheetFragmentScreenshotTest.kt
@@ -0,0 +1,53 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.browser.search
+
+import android.os.Bundle
+import com.google.testing.junit.testparameterinjector.TestParameter
+import com.ichi2.anki.ScreenshotTest
+import com.ichi2.anki.ScreenshotTest.DeviceConfig.TABLET
+import com.ichi2.anki.ScreenshotTest.IncludeDevices
+import com.ichi2.anki.browser.BrowserColumnKey
+import com.ichi2.anki.browser.createCardBrowserViewModel
+import com.ichi2.anki.browser.search.SortOrderBottomSheetFragment.Companion.ARG_CURRENT_SORT_TYPE
+import com.ichi2.anki.model.SortType
+import com.ichi2.anki.testutils.SingleViewModelFactory
+import com.ichi2.testutils.launchFragment
+import com.ichi2.testutils.layoutForFullHeightScreenshot
+import com.ichi2.testutils.scrollToEnd
+import org.junit.Test
+import org.robolectric.RuntimeEnvironment
+
+/**
+ * Screenshots of the [display order dialog][SortOrderBottomSheetFragment]
+ */
+@IncludeDevices(TABLET)
+class SortOrderBottomSheetFragmentScreenshotTest : ScreenshotTest() {
+ @Test
+ fun `display order`(
+ @TestParameter("port", "land") orientation: String,
+ ) {
+ RuntimeEnvironment.setQualifiers("+$orientation")
+ val viewModelFactory = SingleViewModelFactory.create(createCardBrowserViewModel())
+ val args =
+ Bundle().apply {
+ putParcelable(ARG_CURRENT_SORT_TYPE, SortType.CollectionOrdering(BrowserColumnKey("noteFld"), reverse = false))
+ }
+ launchFragment(args) { SortOrderBottomSheetFragment(viewModelFactory) }.use { scenario ->
+ scenario.onFragment { fragment ->
+ advanceRobolectricLooper()
+ val name = if (orientation == "land") "landscape" else "portrait"
+ captureScreen(name)
+
+ if (device == DeviceConfig.PHONE) {
+ fragment.binding.list.scrollToEnd()
+ captureScreen("${name}_scrolled_to_bottom")
+
+ fragment.binding.list.scrollToPosition(0)
+ advanceRobolectricLooper()
+ captureView("${name}_scrollshot", fragment.layoutForFullHeightScreenshot(fragment.binding.list))
+ }
+ }
+ }
+ }
+}
diff --git a/AnkiDroid/src/test/java/com/ichi2/testutils/ScreenSize.kt b/AnkiDroid/src/test/java/com/ichi2/testutils/ScreenSize.kt
index 801377117500..bbea036ee146 100644
--- a/AnkiDroid/src/test/java/com/ichi2/testutils/ScreenSize.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/testutils/ScreenSize.kt
@@ -5,15 +5,19 @@ package com.ichi2.testutils
import android.app.Activity
import android.content.Context
import android.content.Intent
+import android.view.View
+import android.view.View.MeasureSpec
import androidx.recyclerview.widget.RecyclerView
import androidx.test.core.app.ActivityScenario
import androidx.test.core.app.ApplicationProvider
+import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.ichi2.anki.RobolectricTest.Companion.advanceRobolectricLooper
import com.ichi2.utils.Dp
import com.ichi2.utils.dp
import org.robolectric.RuntimeEnvironment
import timber.log.Timber
import kotlin.math.ceil
+import com.google.android.material.R as MaterialR
/** [block] runs with a runtime qualifier emulating a split-pane display */
fun withSplitPaneUi(block: () -> Unit) = withQualifier("sw700dp", block)
@@ -85,6 +89,25 @@ fun launchForFullHeightScreenshot(
}
}
+/**
+ * Lays out the whole bottom sheet at its current width, preserving orientation resources.
+ *
+ * Use after any device-sized screenshots. Capture the returned view directly, as it may extend
+ * beyond the screen. Activity screenshots can use [launchForFullHeightScreenshot] instead.
+ */
+fun BottomSheetDialogFragment.layoutForFullHeightScreenshot(list: RecyclerView): View {
+ val sheet = requireDialog().findViewById(MaterialR.id.design_bottom_sheet)
+ sheet.measure(
+ MeasureSpec.makeMeasureSpec(sheet.width, MeasureSpec.EXACTLY),
+ MeasureSpec.makeMeasureSpec(0, MeasureSpec.UNSPECIFIED),
+ )
+ sheet.layout(0, 0, sheet.measuredWidth, sheet.measuredHeight)
+ // Newly created MaterialButtons finish updating their labels and icons before drawing.
+ sheet.viewTreeObserver.dispatchOnPreDraw()
+ list.checkFitsFullHeightScreenshot()
+ return sheet
+}
+
private fun launchAndCheckListFits(
intent: Intent,
listView: (A) -> RecyclerView,
@@ -96,14 +119,17 @@ private fun launchAndCheckListFits(
setup(activity)
advanceRobolectricLooper() // apply layout changes from setup
val list = listView(activity)
- check(!list.canScrollVertically(1)) {
- "${activity::class.simpleName}: list does not fit a ${activity.resources.configuration.screenHeightDp}dp screen"
- }
+ list.checkFitsFullHeightScreenshot()
block(activity, list)
}
}
}
+private fun RecyclerView.checkFitsFullHeightScreenshot() {
+ check(childCount == requireNotNull(adapter).itemCount) { "Full-height screenshot must lay out every list item" }
+ check(!canScrollVertically(-1) && !canScrollVertically(1)) { "Full-height screenshot must show the entire list" }
+}
+
private fun setScreenHeight(height: Dp) {
val qualifiers =
RuntimeEnvironment.getQualifiers().split("-").mapNotNull { qualifier ->
From fee69a123099e39bb740cbd156723be52bf92574 Mon Sep 17 00:00:00 2001
From: David Allison <62114487+david-allison@users.noreply.github.com>
Date: Fri, 25 Sep 2026 17:14:15 +0100
Subject: [PATCH 008/150] fix(settings): edge to edge toolbar color
Keep the top inset inside the toolbar so the collapsing scrim covers the
status bar. Increase both toolbar heights to preserve the scroll range
and touch targets.
Adjust the scrim visibility threshold by the same inset. Otherwise, a
48dp cutout prevents the scrim from appearing even when fully collapsed.
Reproduced this in settingsPanelSystemBarsChange's fullscreen cutout
screenshot.
Validated eight screenshot cases on phones and tablets in light and dark
themes, plus Kotlin formatting and Android lint.
GPT-6 assisted with implementation, screenshot tests, and self-review.
Fixes 22087
Assisted-by: GPT-6
---
.../anki/preferences/SettingsFragment.kt | 13 ++++-
.../preferences/PreferencesScreenshotTest.kt | 53 +++++++++++++++++++
2 files changed, 65 insertions(+), 1 deletion(-)
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/SettingsFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/SettingsFragment.kt
index 7b74eabf2757..e9da4fd0f242 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/SettingsFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/SettingsFragment.kt
@@ -14,6 +14,7 @@ import androidx.core.view.ViewGroupCompat
import androidx.core.view.WindowInsetsCompat.Type.displayCutout
import androidx.core.view.WindowInsetsCompat.Type.ime
import androidx.core.view.WindowInsetsCompat.Type.systemBars
+import androidx.core.view.updateLayoutParams
import androidx.core.view.updatePadding
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
@@ -82,13 +83,23 @@ abstract class SettingsFragment :
}
ViewGroupCompat.installCompatInsetsDispatch(binding.root)
+ val initialScrimVisibleHeightTrigger = binding.collapsingToolbarLayout.scrimVisibleHeightTrigger
+ var previousTopInset = 0
ViewCompat.setOnApplyWindowInsetsListener(view) { _, insets ->
val bars = insets.getInsets(systemBars() or displayCutout() or ime())
view.updatePadding(
left = bars.left,
right = bars.right,
)
- binding.appbar.updatePadding(top = bars.top)
+ val topInsetChange = bars.top - previousTopInset
+ if (topInsetChange != 0) {
+ binding.toolbar.updatePadding(top = bars.top)
+ binding.toolbar.updateLayoutParams { height += topInsetChange }
+ binding.collapsingToolbarLayout.updateLayoutParams { height += topInsetChange }
+ // The scrim must still appear at the same scroll offset, even with a tall cutout.
+ binding.collapsingToolbarLayout.scrimVisibleHeightTrigger = initialScrimVisibleHeightTrigger + bars.top
+ previousTopInset = bars.top
+ }
listView.updatePadding(bottom = bars.bottom)
insets
}
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/preferences/PreferencesScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/preferences/PreferencesScreenshotTest.kt
index c44f93fc5a0c..16965374c0e9 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/preferences/PreferencesScreenshotTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/preferences/PreferencesScreenshotTest.kt
@@ -1,22 +1,26 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package com.ichi2.anki.preferences
+import android.os.Build
import androidx.fragment.app.Fragment
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import androidx.recyclerview.widget.RecyclerView
import androidx.test.core.app.ActivityScenario
import com.google.android.material.appbar.AppBarLayout
+import com.google.android.material.appbar.CollapsingToolbarLayout
import com.ichi2.anki.R
import com.ichi2.anki.ScreenshotTest
import com.ichi2.anki.common.storage.CollectionHelper
import com.ichi2.anki.preferences.reviewer.ReviewerMenuSettingsFragment
import com.ichi2.anki.settings.Prefs
import com.ichi2.testutils.HIDDEN_GESTURE_BAR
+import com.ichi2.testutils.dispatchInsets
import com.ichi2.testutils.ext.clear
import com.ichi2.testutils.launchForFullHeightScreenshot
import com.ichi2.testutils.scrollToEnd
import com.ichi2.testutils.simulateSystemBars
+import com.ichi2.testutils.withWritePermissions
import com.ichi2.utils.dp
import org.junit.After
import org.junit.Test
@@ -46,6 +50,55 @@ class PreferencesScreenshotTest : ScreenshotTest() {
}
}
+ @Test
+ fun headerFragmentToolbarCollapseAndExpand() =
+ withPreferencesActivity(HeaderFragment::class) { activity ->
+ activity.simulateSystemBars()
+ val appbar = activity.findViewById(R.id.appbar)
+ appbar.setExpanded(false, false)
+ advanceRobolectricLooper()
+ captureScreen("HeaderFragment_toolbar_collapsed")
+
+ appbar.setExpanded(true, false)
+ advanceRobolectricLooper()
+ captureScreen("HeaderFragment_toolbar_expanded")
+ }
+
+ @Test
+ fun settingsPanelSystemBarsChange() =
+ withWritePermissions {
+ withPreferencesActivity(GeneralSettingsFragment::class) { activity ->
+ val name = "GeneralSettingsFragment_sdk${Build.VERSION.SDK_INT}"
+ val appbar = activity.settingsFragment!!.requireView().findViewById(R.id.appbar)
+ appbar.findViewById(R.id.collapsingToolbarLayout).scrimAnimationDuration = 0
+ activity.dispatchInsets(navBarBottom = 24.dp)
+ appbar.setExpanded(false, false)
+ advanceRobolectricLooper()
+ captureScreen("${name}_collapsed")
+
+ activity.dispatchInsets(barsVisible = false, navBarStableBottom = 24.dp)
+ advanceRobolectricLooper()
+ captureScreen("${name}_fullscreen_collapsed")
+
+ appbar.setExpanded(true, false)
+ advanceRobolectricLooper()
+ captureScreen("${name}_fullscreen_expanded")
+
+ activity.dispatchInsets(barsVisible = false, cutoutTop = 48.dp, navBarStableBottom = 24.dp)
+ advanceRobolectricLooper()
+ captureScreen("${name}_fullscreen_cutout")
+
+ appbar.setExpanded(false, false)
+ advanceRobolectricLooper()
+ captureScreen("${name}_fullscreen_cutout_collapsed")
+
+ appbar.setExpanded(true, false)
+ activity.dispatchInsets(navBarBottom = 24.dp)
+ advanceRobolectricLooper()
+ captureScreen("${name}_system_bars_restored")
+ }
+ }
+
@Test
fun headerFragmentGestureBarHiddenScrolledToBottom() =
withPreferencesActivity(HeaderFragment::class) { activity ->
From b9d791683ddf2c1dbcdb091cbdb7028805ecd172 Mon Sep 17 00:00:00 2001
From: David Allison <62114487+david-allison@users.noreply.github.com>
Date: Sat, 26 Sep 2026 16:51:26 +0100
Subject: [PATCH 009/150] test(deck-picker): wait for deck rows before long
press
Fixes 22103
Assisted-by: GPT-6
---
.../src/test/java/com/ichi2/anki/DeckPickerTest.kt | 13 ++++++-------
1 file changed, 6 insertions(+), 7 deletions(-)
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt
index dae51842a2e3..1014f2a4a288 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt
@@ -22,7 +22,6 @@ import androidx.core.net.toUri
import androidx.core.view.ContentInfoCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
-import androidx.core.view.children
import androidx.test.core.app.ActivityScenario
import androidx.test.filters.SdkSuppress
import anki.backend.backendError
@@ -514,6 +513,11 @@ class DeckPickerTest : RobolectricTest() {
private fun DeckPicker.longPressDeck(name: String): View {
val decks = deckPickerBinding.decks
val adapter = decks.adapter as DeckAdapter
+ // Background list diffs can finish after the initial main looper drain.
+ advanceRobolectricLooperUntil(lazyMessage = { "Deck '$name' was not laid out" }) {
+ val position = adapter.currentList.indexOfFirst { it.lastDeckNameComponent == name }
+ position >= 0 && decks.findViewHolderForAdapterPosition(position) != null
+ }
val deck = adapter.currentList.single { it.lastDeckNameComponent == name }
val position = adapter.currentList.indexOf(deck)
decks.findViewHolderForAdapterPosition(position)!!.itemView.performLongClick()
@@ -861,14 +865,9 @@ class DeckPickerTest : RobolectricTest() {
assertThat("deck focus is set", viewModel.focusedDeck, equalTo(emptyDeck))
// ACT: open up the Deck Context Menu
- val deckToClick =
- deckPickerBinding.decks.children.single {
- it.findViewById(R.id.deck_name).text == "With Cards"
- }
- deckToClick.performLongClick()
+ longPressDeck("With Cards")
// ASSERT
- advanceRobolectricLooper() // ensure that 'focusedDeck' is current
assertThat("unbury is visible: one card is buried", col.sched.haveBuried())
assertThat("deck focus has changed", viewModel.focusedDeck, equalTo(deckWithCards))
}
From f4fb14278b93d45ec56ba1de3737583d79931fab Mon Sep 17 00:00:00 2001
From: David Allison <62114487+david-allison@users.noreply.github.com>
Date: Sat, 26 Sep 2026 18:38:09 +0100
Subject: [PATCH 010/150] test(set-due-date): scheduler behavior
Part of 21915
Assisted-by: GPT-6
---
.../anki/scheduling/SetDueDateDialogTest.kt | 29 +++++++++++++++++--
1 file changed, 27 insertions(+), 2 deletions(-)
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/scheduling/SetDueDateDialogTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/scheduling/SetDueDateDialogTest.kt
index d0f982d761a1..eea014e35602 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/scheduling/SetDueDateDialogTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/scheduling/SetDueDateDialogTest.kt
@@ -7,6 +7,7 @@ import android.widget.CheckBox
import android.widget.EditText
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
+import androidx.core.view.isVisible
import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.testing.launchFragment
import androidx.lifecycle.Lifecycle
@@ -18,7 +19,6 @@ import com.ichi2.anki.R
import com.ichi2.anki.RobolectricTest
import com.ichi2.anki.RobolectricTest.Companion.advanceRobolectricLooper
import com.ichi2.anki.browser.IdsFile
-import com.ichi2.anki.common.annotations.NeedsTest
import com.ichi2.anki.libanki.CardId
import com.ichi2.anki.libanki.sched.SetDueDateDays
import com.ichi2.anki.scheduling.SetDueDateViewModel.Tab
@@ -52,7 +52,6 @@ import kotlin.test.assertNotSame
import kotlin.test.assertNull
import kotlin.test.assertTrue
-@NeedsTest("set interval to same value visibility with FSRS")
@RunWith(AndroidJUnit4::class)
class SetDueDateDialogTest : RobolectricTest() {
@Test
@@ -207,6 +206,15 @@ class SetDueDateDialogTest : RobolectricTest() {
}
}
+ @Test
+ fun `loading FSRS forces the interval to match the due date`() = assertSchedulerLoading(fsrsEnabled = true)
+
+ @Test
+ fun `loading SM-2 allows changing the interval`() = assertSchedulerLoading(fsrsEnabled = false)
+
+ @Test
+ fun `unavailable scheduler setting falls back to SM-2`() = assertSchedulerLoading(fsrsEnabled = null)
+
@Test
fun `cancelled caller leaves no ids file behind`() =
runTest {
@@ -286,6 +294,23 @@ class SetDueDateDialogTest : RobolectricTest() {
}
}
+ private fun assertSchedulerLoading(fsrsEnabled: Boolean?) =
+ withActivity { activity ->
+ val cardIds = listOf(addBasicNote().firstCard().id)
+ withPausedLoading(fsrsEnabled) { finishLoading ->
+ finishLoading.complete(Unit)
+ SetDueDateDialog.show(activity, cardIds)
+ advanceRobolectricLooper()
+ val dialog = assertNotNull(activity.currentDialog)
+ dialog.singleDayText.setText("3")
+
+ assertTrue(dialog.positiveButtonIsEnabled)
+ assertEquals(fsrsEnabled != true, dialog.changeInterval.isVisible)
+ assertEquals(fsrsEnabled == true, dialog.changeInterval.isChecked)
+ assertEquals(SetDueDateDays(if (fsrsEnabled == true) "3!" else "3"), dialog.viewModel.calculateDaysParameter())
+ }
+ }
+
private val FragmentActivity.currentDialog: SetDueDateDialog?
get() = supportFragmentManager.findFragmentByTag(DIALOG_FRAGMENT_TAG) as SetDueDateDialog?
From a581bb6c924c3660f6ac2f98b95ad5eea2e893eb Mon Sep 17 00:00:00 2001
From: David Allison <62114487+david-allison@users.noreply.github.com>
Date: Sat, 26 Sep 2026 18:30:49 +0100
Subject: [PATCH 011/150] refactor(set-due-date): move loading into ViewModel
Load FSRS in `viewModelScope` so the dialog can load asynchronously
Part of 21915
Assisted-by: GPT-6
---
.../ichi2/anki/scheduling/SetDueDateDialog.kt | 22 ++----
.../anki/scheduling/SetDueDateViewModel.kt | 32 ++++++---
.../anki/scheduling/SetDueDateDialogTest.kt | 71 ++++++++-----------
.../scheduling/SetDueDateViewModelTest.kt | 9 ++-
4 files changed, 61 insertions(+), 73 deletions(-)
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/SetDueDateDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/SetDueDateDialog.kt
index 7176fc7708d0..c5c2f7e60f20 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/SetDueDateDialog.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/SetDueDateDialog.kt
@@ -47,12 +47,10 @@ import com.ichi2.anki.libanki.CardId
import com.ichi2.anki.libanki.sched.Scheduler
import com.ichi2.anki.requireAnkiActivity
import com.ichi2.anki.scheduling.SetDueDateViewModel.Tab
-import com.ichi2.anki.servicelayer.getFSRSStatus
import com.ichi2.anki.snackbar.showSnackbar
import com.ichi2.anki.ui.internationalization.sentenceCase
import com.ichi2.anki.utils.doOnImeHidden
import com.ichi2.anki.utils.ext.launchCollectionInLifecycleScope
-import com.ichi2.anki.utils.ext.requireBoolean
import com.ichi2.anki.utils.ext.requireParcelable
import com.ichi2.anki.utils.ext.showDialogFragment
import com.ichi2.anki.utils.openUrl
@@ -67,8 +65,6 @@ import com.ichi2.utils.titleWithHelpIcon
import dev.androidbroadcast.vbpd.viewBinding
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.currentCoroutineContext
-import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
@@ -101,9 +97,6 @@ class SetDueDateDialog : AnalyticsDialogFragment() {
val cardIds: List
get() = requireArguments().requireParcelable(ARG_IDS_FILE).getIds()
- val fsrsEnabled: Boolean
- get() = requireArguments().requireBoolean(ARG_FSRS)
-
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val cardIds =
@@ -117,7 +110,7 @@ class SetDueDateDialog : AnalyticsDialogFragment() {
return
}
- viewModel.init(cardIds, fsrsEnabled)
+ viewModel.init(cardIds)
Timber.d("Set due date dialog: %d card(s)", cardIds.size)
this.initialRotation = getScreenRotation()
@@ -272,7 +265,6 @@ class SetDueDateDialog : AnalyticsDialogFragment() {
companion object {
const val ARG_IDS_FILE = "ARGS_IDS_FILE"
- const val ARG_FSRS = "ARGS_FSRS"
const val MAX_WIDTH_DP = 450f
private const val RESULT_SUBMIT_DUE_DATE = "SubmitDueDate"
@@ -299,23 +291,17 @@ class SetDueDateDialog : AnalyticsDialogFragment() {
@VisibleForTesting
@CheckResult
- suspend fun newInstance(
+ fun newInstance(
cacheDir: File,
cardIds: List,
- ): SetDueDateDialog {
- val fsrsEnabled = getFSRSStatus() ?: false.also { Timber.w("FSRS Status error") }
- // getFSRSStatus swallows cancellation, so check for it explicitly: the dialog would
- // fail to show and leave behind a file which only onDismiss removes
- currentCoroutineContext().ensureActive()
- return SetDueDateDialog().apply {
+ ): SetDueDateDialog =
+ SetDueDateDialog().apply {
arguments =
Bundle().apply {
putParcelable(ARG_IDS_FILE, IdsFile(cacheDir, cardIds, "set-due-date"))
- putBoolean(ARG_FSRS, fsrsEnabled)
}
Timber.i("Showing 'set due date' dialog for %d cards", cardIds.size)
}
- }
}
class DueDateStateAdapter(
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/SetDueDateViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/SetDueDateViewModel.kt
index 1ddee60333b1..eea40cefc9fe 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/SetDueDateViewModel.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/SetDueDateViewModel.kt
@@ -12,7 +12,10 @@ import com.ichi2.anki.libanki.CardId
import com.ichi2.anki.libanki.CardType
import com.ichi2.anki.libanki.sched.SetDueDateDays
import com.ichi2.anki.observability.undoableOp
+import com.ichi2.anki.servicelayer.getFSRSStatus
+import kotlinx.coroutines.Job
import kotlinx.coroutines.async
+import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.update
@@ -47,6 +50,8 @@ class SetDueDateViewModel : ViewModel() {
val fsrsEnabled: StateFlow
field = MutableStateFlow(null)
+ private var loadJob: Job? = null
+
/** Whether the user can set [updateIntervalToMatchDueDate] */
val canSetUpdateIntervalToMatchDueDate
// this only makes sense in SM-2, where the due date does not directly impact the next
@@ -112,19 +117,26 @@ class SetDueDateViewModel : ViewModel() {
*/
val currentInterval = MutableStateFlow(null)
- fun init(
- cardIds: List,
- fsrsEnabled: Boolean,
- ) {
+ fun init(cardIds: List) {
+ loadJob?.cancel()
this.cardIds = cardIds
- Timber.d("fsrsEnabled : %b", fsrsEnabled)
- if (fsrsEnabled) {
- Timber.d("updateIntervalToMatchDueDate forced to true: FSRS is enabled")
- updateIntervalToMatchDueDate = true
- }
- this.fsrsEnabled.value = fsrsEnabled
+ fsrsEnabled.value = null
refreshIsValid()
+ loadJob =
+ viewModelScope.launch {
+ val enabled = getFSRSStatus() ?: false.also { Timber.w("FSRS Status error") }
+ // getFSRSStatus swallows cancellation. An earlier load must not update a reopened dialog.
+ ensureActive()
+ Timber.d("fsrsEnabled : %b", enabled)
+ if (enabled) {
+ Timber.d("updateIntervalToMatchDueDate forced to true: FSRS is enabled")
+ updateIntervalToMatchDueDate = true
+ }
+ fsrsEnabled.value = enabled
+ refreshIsValid()
+ }
+
initCurrentInterval(cardIds)
}
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/scheduling/SetDueDateDialogTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/scheduling/SetDueDateDialogTest.kt
index eea014e35602..c3d216e321cc 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/scheduling/SetDueDateDialogTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/scheduling/SetDueDateDialogTest.kt
@@ -30,12 +30,6 @@ import io.mockk.coEvery
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.CompletableDeferred
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.CoroutineStart
-import kotlinx.coroutines.Job
-import kotlinx.coroutines.async
-import kotlinx.coroutines.cancel
-import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import org.hamcrest.MatcherAssert.assertThat
@@ -44,7 +38,6 @@ import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import java.io.File
-import kotlin.coroutines.coroutineContext
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotNull
@@ -216,41 +209,19 @@ class SetDueDateDialogTest : RobolectricTest() {
fun `unavailable scheduler setting falls back to SM-2`() = assertSchedulerLoading(fsrsEnabled = null)
@Test
- fun `cancelled caller leaves no ids file behind`() =
- runTest {
- val cardIds = List(2) { addBasicNote().firstCard().id }
- val cacheDir = File(targetContext.cacheDir, "set-due-date-cancelled").also { it.mkdirs() }
-
- CoroutineScope(coroutineContext + Job())
- .async {
- coroutineContext.cancel()
- SetDueDateDialog.newInstance(cacheDir, cardIds)
- }
- advanceUntilIdle()
-
- assertThat(cacheDir.listFiles()?.size, equalTo(0))
- }
-
- @Test
- fun `concurrent requests return without waiting for dialog preparation`() =
+ fun `show and duplicate requests return without waiting for loading`() =
withActivity { activity ->
val firstCardIds = listOf(addBasicNote().firstCard().id)
val secondCardIds = listOf(addBasicNote().firstCard().id)
withPausedLoading { finishLoading ->
- val first = async(start = CoroutineStart.UNDISPATCHED) { SetDueDateDialog.show(activity, firstCardIds) }
- val second = async(start = CoroutineStart.UNDISPATCHED) { SetDueDateDialog.show(activity, secondCardIds) }
-
- assertFalse(first.isCompleted)
- assertTrue(second.isCompleted, "duplicate requests return without waiting")
-
- finishLoading.complete(Unit)
- first.await()
- second.await()
+ SetDueDateDialog.show(activity, firstCardIds)
+ SetDueDateDialog.show(activity, secondCardIds)
advanceRobolectricLooper()
assertEquals(firstCardIds, assertNotNull(activity.currentDialog).cardIds)
assertEquals(1, activity.supportFragmentManager.backStackEntryCount)
assertEquals(1, activity.dueDateFiles.size)
+ finishLoading.complete(Unit)
}
}
@@ -276,21 +247,29 @@ class SetDueDateDialogTest : RobolectricTest() {
}
@Test
- fun `cancelled preparation allows another dialog request`() =
+ fun `dialog can be dismissed and reopened while loading`() =
withActivity { activity ->
- val cardIds = listOf(addBasicNote().firstCard().id)
- withPausedLoading { finishLoading ->
- val request = async(start = CoroutineStart.UNDISPATCHED) { SetDueDateDialog.show(activity, cardIds) }
- assertFalse(request.isCompleted)
- request.cancelAndJoin()
+ val firstCardIds = listOf(addBasicNote().firstCard().id)
+ val secondCardIds = listOf(addBasicNote().firstCard().id)
+ withPausedLoading(fsrsEnabled = true) { finishLoading ->
+ SetDueDateDialog.show(activity, firstCardIds)
+ advanceRobolectricLooper()
+ assertNotNull(activity.currentDialog).dismiss()
+ advanceRobolectricLooper()
+ assertNull(activity.currentDialog)
+ assertTrue(activity.dueDateFiles.isEmpty())
+ coEvery { getFSRSStatus() } returns false
+ SetDueDateDialog.show(activity, secondCardIds)
finishLoading.complete(Unit)
- SetDueDateDialog.show(activity, cardIds)
+ advanceUntilIdle()
advanceRobolectricLooper()
val dialog = assertNotNull(activity.currentDialog)
- assertEquals(cardIds, dialog.cardIds)
+ assertEquals(secondCardIds, dialog.cardIds)
assertTrue(dialog.requireDialog().isShowing)
+ assertTrue(dialog.changeInterval.isVisible)
+ assertFalse(dialog.viewModel.fsrsEnabled.value!!)
}
}
@@ -298,11 +277,17 @@ class SetDueDateDialogTest : RobolectricTest() {
withActivity { activity ->
val cardIds = listOf(addBasicNote().firstCard().id)
withPausedLoading(fsrsEnabled) { finishLoading ->
- finishLoading.complete(Unit)
SetDueDateDialog.show(activity, cardIds)
advanceRobolectricLooper()
val dialog = assertNotNull(activity.currentDialog)
dialog.singleDayText.setText("3")
+ assertFalse(dialog.positiveButtonIsEnabled, "cannot save before the scheduler setting is loaded")
+ assertFalse(dialog.changeInterval.isVisible)
+ assertNull(dialog.viewModel.updateDueDateAsync().await(), "keyboard submission cannot save while loading")
+
+ finishLoading.complete(Unit)
+ advanceUntilIdle()
+ advanceRobolectricLooper()
assertTrue(dialog.positiveButtonIsEnabled)
assertEquals(fsrsEnabled != true, dialog.changeInterval.isVisible)
@@ -342,7 +327,7 @@ class SetDueDateDialogTest : RobolectricTest() {
}
}
- private suspend fun setDueDateArgs(cardIds: List): Bundle =
+ private fun setDueDateArgs(cardIds: List): Bundle =
SetDueDateDialog
.newInstance(targetContext.externalCacheDir ?: targetContext.cacheDir, cardIds)
.requireArguments()
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/scheduling/SetDueDateViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/scheduling/SetDueDateViewModelTest.kt
index da6e8a5b3fc6..cbb3a33cb2d7 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/scheduling/SetDueDateViewModelTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/scheduling/SetDueDateViewModelTest.kt
@@ -9,6 +9,7 @@ import com.ichi2.anki.scheduling.SetDueDateViewModel.DateRange
import com.ichi2.anki.scheduling.SetDueDateViewModel.Tab
import com.ichi2.testutils.JvmTest
import com.ichi2.testutils.common.assertThrows
+import kotlinx.coroutines.test.advanceUntilIdle
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.equalTo
import org.junit.Test
@@ -36,7 +37,9 @@ class SetDueDateViewModelTest : JvmTest() {
assertThat(viewModel.calculateDaysParameter(), equalTo(null))
assertThat("keyboard submission is also guarded", viewModel.updateDueDateAsync().await(), equalTo(null))
- viewModel.init(cardIds = listOf(1, 2), fsrsEnabled = true)
+ col.config.set("fsrs", true)
+ viewModel.init(cardIds = listOf(1, 2))
+ advanceUntilIdle()
assertTrue(viewModel.isValid, "existing input becomes valid once the scheduler setting is known")
assertThat(viewModel.calculateDaysParameter(), equalTo(SetDueDateDays("3!")))
@@ -173,8 +176,10 @@ class SetDueDateViewModelTest : JvmTest() {
fsrsEnabled: Boolean = false,
testBody: suspend SetDueDateViewModel.() -> Unit,
) = runTest {
+ col.config.set("fsrs", fsrsEnabled)
val viewModel = SetDueDateViewModel()
- viewModel.init(cardIds, fsrsEnabled = fsrsEnabled)
+ viewModel.init(cardIds)
+ advanceUntilIdle()
testBody(viewModel)
}
}
From 4c4989e85df2a04299385763f1ba843efee48bcc Mon Sep 17 00:00:00 2001
From: David Allison <62114487+david-allison@users.noreply.github.com>
Date: Sat, 26 Sep 2026 18:31:46 +0100
Subject: [PATCH 012/150] refactor(set-due-date): simplify dialog opening
Part of 21915
Assisted-by: GPT-6
---
.../src/main/java/com/ichi2/anki/Reviewer.kt | 9 +++----
.../ichi2/anki/scheduling/SetDueDateDialog.kt | 25 +++++++------------
2 files changed, 13 insertions(+), 21 deletions(-)
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/Reviewer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/Reviewer.kt
index 886924b0fed4..9378968d6a93 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/Reviewer.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/Reviewer.kt
@@ -1127,11 +1127,10 @@ open class Reviewer :
}
}
- private fun showDueDateDialog() =
- launchCatchingTask {
- Timber.i("showing due date dialog")
- SetDueDateDialog.show(this@Reviewer, listOf(currentCardId!!))
- }
+ private fun showDueDateDialog() {
+ Timber.i("showing due date dialog")
+ SetDueDateDialog.show(this, listOf(currentCardId!!))
+ }
private fun showResetCardDialog() {
Timber.i("showResetCardDialog() Reset progress button pressed")
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/SetDueDateDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/SetDueDateDialog.kt
index c5c2f7e60f20..a91bed62c7af 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/SetDueDateDialog.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/SetDueDateDialog.kt
@@ -15,6 +15,7 @@ import android.view.WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE
import android.view.inputmethod.EditorInfo
import android.widget.EditText
import androidx.annotation.CheckResult
+import androidx.annotation.MainThread
import androidx.annotation.VisibleForTesting
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible
@@ -64,9 +65,7 @@ import com.ichi2.utils.title
import com.ichi2.utils.titleWithHelpIcon
import dev.androidbroadcast.vbpd.viewBinding
import kotlinx.coroutines.Deferred
-import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
-import kotlinx.coroutines.withContext
import timber.log.Timber
import java.io.File
import java.io.IOException
@@ -269,24 +268,18 @@ class SetDueDateDialog : AnalyticsDialogFragment() {
private const val RESULT_SUBMIT_DUE_DATE = "SubmitDueDate"
- // Only accessed on the main thread.
- private val pendingActivities = mutableSetOf()
-
/** Keeps the existing dialog and its input when another request arrives. */
- suspend fun show(
+ @MainThread
+ fun show(
activity: FragmentActivity,
cardIds: List,
- ) = withContext(Dispatchers.Main.immediate) {
- if (activity.supportFragmentManager.fragments.any { it is SetDueDateDialog } || !pendingActivities.add(activity)) {
- Timber.d("Ignoring 'set due date' request: dialog is already open or being prepared")
- return@withContext
- }
- try {
- val dialog = newInstance(activity.externalCacheDir ?: activity.cacheDir, cardIds)
- activity.showDialogFragment(dialog)
- } finally {
- pendingActivities.remove(activity)
+ ) {
+ if (activity.supportFragmentManager.fragments.any { it is SetDueDateDialog }) {
+ Timber.d("Ignoring 'set due date' request: dialog is already open")
+ return
}
+ val dialog = newInstance(activity.externalCacheDir ?: activity.cacheDir, cardIds)
+ activity.showDialogFragment(dialog)
}
@VisibleForTesting
From ad829a59ec0414041c7efe2315641897dcc9ebb9 Mon Sep 17 00:00:00 2001
From: David Allison <62114487+david-allison@users.noreply.github.com>
Date: Sun, 27 Sep 2026 14:59:07 +0100
Subject: [PATCH 013/150] refactor(tags): rename `sortOverride` to `sortWith`
Part of 18424
Assisted-by: GPT-6
---
.../java/com/ichi2/utils/UniqueArrayList.kt | 20 +++++++++----------
1 file changed, 10 insertions(+), 10 deletions(-)
diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/UniqueArrayList.kt b/AnkiDroid/src/main/java/com/ichi2/utils/UniqueArrayList.kt
index 7803de234486..80d940541704 100644
--- a/AnkiDroid/src/main/java/com/ichi2/utils/UniqueArrayList.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/utils/UniqueArrayList.kt
@@ -22,7 +22,6 @@
package com.ichi2.utils
-import com.ichi2.anki.common.utils.annotation.KotlinCleanup
import org.apache.commons.collections4.list.SetUniqueList
import java.util.Arrays
import java.util.Spliterator
@@ -68,7 +67,7 @@ class UniqueArrayList /**
* and [List] don't get out of sync, and [SetUniqueList] cannot be sorted via [java.util.Collections.sort]
* or [SetUniqueList.sort] both will throw an exception, due to a limitation on this class [java.util.ListIterator]
*
- * Sorting can be only done via [UniqueArrayList.sort] or [UniqueArrayList.sort].
+ * Sorting can be only done via [UniqueArrayList.sort] or [UniqueArrayList.sortWith].
*
* Modification to this list reference should be done with cautious to avoid having the internal [Set] out of sync
*/
@@ -91,11 +90,10 @@ class UniqueArrayList /**
* must not throw a `ClassCastException` for any elements
* `e1` and `e2` in the list).
*
- * @see .sort
+ * @see sortWith
*/
- @KotlinCleanup("sortWith")
fun sort() {
- sortOverride(null)
+ sortWith(null)
}
/**
@@ -121,15 +119,17 @@ class UniqueArrayList /**
*
* @see java.util.Collections.sort
*/
- @KotlinCleanup("sortWith")
override fun sort(c: Comparator?) {
- sortOverride(c)
+ sortWith(c)
}
- /** Exists temporarily: [sort] has been defined as invalid in kotlin so it can't be called internally */
+ /**
+ * Sorts the backing list while preserving the set used for uniqueness.
+ *
+ * @param c the comparator, or `null` to use the elements' natural ordering.
+ */
@Suppress("UNCHECKED_CAST")
- @KotlinCleanup("use sortWith")
- fun sortOverride(c: Comparator?) {
+ fun sortWith(c: Comparator?) {
val elements = ArrayList(list).toArray() as Array
Arrays.sort(elements, c)
val i = list.listIterator()
From bf97e5260f99de70188b26e789ef5a74433cdef6 Mon Sep 17 00:00:00 2001
From: David Allison <62114487+david-allison@users.noreply.github.com>
Date: Sun, 27 Sep 2026 13:49:16 +0100
Subject: [PATCH 014/150] perf(tags): preserve uniqueness set when sorting
Use `sortWith` so the set used for case-insensitive uniqueness
inside UniqueArrayList is retained.
Part of 18424
Assisted-by: GPT-6
---
.../com/ichi2/anki/dialogs/tags/TagsList.kt | 23 ++++++++-----------
.../ichi2/anki/dialogs/tags/TagsListTest.kt | 16 +++++++++++++
2 files changed, 26 insertions(+), 13 deletions(-)
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsList.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsList.kt
index b353a21f3f5a..31b6b114b3dd 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsList.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsList.kt
@@ -266,20 +266,17 @@ class TagsList(
* A tag priors to another one if its root tag is checked or indeterminate while the other one's is not
*/
fun sort() {
- val sortedList =
- allTags.toList().sortedWith { lhs: String?, rhs: String? ->
- val lhsRoot = getTagRoot(lhs!!)
- val rhsRoot = getTagRoot(rhs!!)
- val lhsChecked = isChecked(lhsRoot) || isIndeterminate(lhsRoot)
- val rhsChecked = isChecked(rhsRoot) || isIndeterminate(rhsRoot)
- if (lhsChecked != rhsChecked) {
- if (lhsChecked) -1 else 1
- } else {
- compareTag(lhs, rhs)
- }
+ allTags.sortWith { lhs, rhs ->
+ val lhsRoot = getTagRoot(lhs)
+ val rhsRoot = getTagRoot(rhs)
+ val lhsChecked = isChecked(lhsRoot) || isIndeterminate(lhsRoot)
+ val rhsChecked = isChecked(rhsRoot) || isIndeterminate(rhsRoot)
+ if (lhsChecked != rhsChecked) {
+ if (lhsChecked) -1 else 1
+ } else {
+ compareTag(lhs, rhs)
}
- allTags.clear()
- allTags.addAll(sortedList)
+ }
}
/**
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/tags/TagsListTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/tags/TagsListTest.kt
index 80d5d1ef598f..5f31a898f40b 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/tags/TagsListTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/tags/TagsListTest.kt
@@ -506,6 +506,22 @@ class TagsListTest {
)
}
+ @Test
+ fun `sort includes newly added ancestors and preserves case insensitive uniqueness`() {
+ val tags = TagsList(listOf("z", "a"), listOf("z"))
+ tags.sort()
+
+ assertTrue(tags.add("B::child"))
+ tags.sort()
+ assertEquals(listOf("z", "a", "B", "B::child"), tags.copyOfAllTagList())
+ assertFalse("Sorting must preserve case insensitive uniqueness", tags.add("b::CHILD"))
+
+ assertTrue(tags.check("b::CHILD"))
+ tags.sort()
+ assertEquals(listOf("B", "B::child", "z", "a"), tags.copyOfAllTagList())
+ assertFalse("Sorting must preserve case insensitive uniqueness", tags.add("Z"))
+ }
+
@Test // #8807
@Ignore(
"Collections.singletonList() triggers infinite recursion. " +
From 67859dbceb43801067d931402eed59b3e7edba6d Mon Sep 17 00:00:00 2001
From: David Allison <62114487+david-allison@users.noreply.github.com>
Date: Sun, 27 Sep 2026 18:12:57 +0100
Subject: [PATCH 015/150] refactor(tags): split out tag comparison by parts
Extract the tag hierarchy comparison into an overload for tag parts.
Allows future reuse of parsed tag parts.
Part of 18424
Assisted-by: GPT-6
---
.../src/main/java/com/ichi2/utils/TagsUtil.kt | 17 +++++++++--------
.../com/ichi2/anki/dialogs/tags/TagsListTest.kt | 16 ++++++++++++++++
2 files changed, 25 insertions(+), 8 deletions(-)
diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/TagsUtil.kt b/AnkiDroid/src/main/java/com/ichi2/utils/TagsUtil.kt
index 10e056526dab..b8c5edbda662 100644
--- a/AnkiDroid/src/main/java/com/ichi2/utils/TagsUtil.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/utils/TagsUtil.kt
@@ -95,18 +95,19 @@ object TagsUtil {
fun compareTag(
lhs: String,
rhs: String,
+ ): Int = compareTag(lhs.split("::"), rhs.split("::"))
+
+ /** Compare already split tags without allocating their parts for each comparison. */
+ internal fun compareTag(
+ lhs: List,
+ rhs: List,
): Int {
- val lhsIt = lhs.split("::").asSequence().iterator()
- val rhsIt = rhs.split("::").asSequence().iterator()
- while (lhsIt.hasNext() && rhsIt.hasNext()) {
- val cmp = lhsIt.next().compareTo(rhsIt.next(), true)
+ for (index in 0 until minOf(lhs.size, rhs.size)) {
+ val cmp = lhs[index].compareTo(rhs[index], true)
if (cmp != 0) {
return cmp
}
}
- if (!lhsIt.hasNext() && !rhsIt.hasNext()) {
- return 0
- }
- return if (lhsIt.hasNext()) 1 else -1
+ return lhs.size.compareTo(rhs.size)
}
}
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/tags/TagsListTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/tags/TagsListTest.kt
index 5f31a898f40b..a6adbfb198ed 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/tags/TagsListTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/tags/TagsListTest.kt
@@ -506,6 +506,22 @@ class TagsListTest {
)
}
+ @Test
+ fun `sort keeps selected branches first and compares hierarchy parts ignoring case`() {
+ val tags =
+ TagsList(
+ listOf("Zoo", "alpha!", "ALPHA::bbbz", "Alpha::bbb::leaf", "alpha::BBB", "Beta::z", "beta::A"),
+ listOf("BETA::a", "Zoo"),
+ )
+
+ tags.sort()
+
+ assertEquals(
+ listOf("Beta", "beta::A", "Beta::z", "Zoo", "ALPHA", "alpha::BBB", "Alpha::bbb::leaf", "ALPHA::bbbz", "alpha!"),
+ tags.copyOfAllTagList(),
+ )
+ }
+
@Test
fun `sort includes newly added ancestors and preserves case insensitive uniqueness`() {
val tags = TagsList(listOf("z", "a"), listOf("z"))
From d574e8cd6992fa6451640288a32fd9badb035224 Mon Sep 17 00:00:00 2001
From: David Allison <62114487+david-allison@users.noreply.github.com>
Date: Sun, 27 Sep 2026 18:13:21 +0100
Subject: [PATCH 016/150] perf(tags): precompute tag sort keys
Precompute splitting tags on '::' so this work is not performed
repeatedly in a sort operation.
Keys are recomputed on each sort to ensure selection changes update
the list.
Part of 18424
Assisted-by: GPT-6
---
.../com/ichi2/anki/dialogs/tags/TagsList.kt | 24 +++++++++++++------
1 file changed, 17 insertions(+), 7 deletions(-)
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsList.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsList.kt
index 31b6b114b3dd..dff513aafec5 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsList.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsList.kt
@@ -266,19 +266,29 @@ class TagsList(
* A tag priors to another one if its root tag is checked or indeterminate while the other one's is not
*/
fun sort() {
+ // Splitting tags and looking up root selection inside the comparator repeats this work
+ // for every comparison. Compute the keys once per tag instead.
+ val sortKeys =
+ allTags.associateWith { tag ->
+ val root = getTagRoot(tag)
+ SortKey(tag.split("::"), isChecked(root) || isIndeterminate(root))
+ }
allTags.sortWith { lhs, rhs ->
- val lhsRoot = getTagRoot(lhs)
- val rhsRoot = getTagRoot(rhs)
- val lhsChecked = isChecked(lhsRoot) || isIndeterminate(lhsRoot)
- val rhsChecked = isChecked(rhsRoot) || isIndeterminate(rhsRoot)
- if (lhsChecked != rhsChecked) {
- if (lhsChecked) -1 else 1
+ val lhsKey = sortKeys.getValue(lhs)
+ val rhsKey = sortKeys.getValue(rhs)
+ if (lhsKey.isSelected != rhsKey.isSelected) {
+ if (lhsKey.isSelected) -1 else 1
} else {
- compareTag(lhs, rhs)
+ compareTag(lhsKey.parts, rhsKey.parts)
}
}
}
+ private class SortKey(
+ val parts: List,
+ val isSelected: Boolean,
+ )
+
/**
* @return Iterator over all tags
*/
From 93308f1e6733eaa972103b7a6e7fb39ba058ee2e Mon Sep 17 00:00:00 2001
From: David Allison <62114487+david-allison@users.noreply.github.com>
Date: Sun, 27 Sep 2026 13:50:26 +0100
Subject: [PATCH 017/150] perf(tags): track changes when sorting
... and skip re-sorting if elements are unchanged
Invalidated when:
* adding tags
* changing any checked states
Part of 18424
Assisted-by: GPT-6
---
.../com/ichi2/anki/dialogs/tags/TagsList.kt | 24 +++++++++-
.../ichi2/anki/dialogs/tags/TagsListTest.kt | 44 +++++++++++++++++++
2 files changed, 66 insertions(+), 2 deletions(-)
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsList.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsList.kt
index dff513aafec5..7328e8bebefa 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsList.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsList.kt
@@ -55,6 +55,9 @@ class TagsList(
*/
private val allTags: UniqueArrayList
+ /** Tags or selection have changed since the last sort. */
+ private var needsSort = true
+
init {
this.checkedTags.addAll(checkedTags)
this.allTags = from(allTags, java.lang.String.CASE_INSENSITIVE_ORDER)
@@ -119,6 +122,7 @@ class TagsList(
if (!allTags.add(tag)) {
return false
}
+ needsSort = true
addAncestors(tag)
return true
}
@@ -143,6 +147,7 @@ class TagsList(
if (!checkedTags.add(tag)) {
return false
}
+ needsSort = true
if (processAncestors) {
markAncestorsIndeterminate(tag)
}
@@ -156,7 +161,13 @@ class TagsList(
* @return true if the tag changed its check status
* false if the tag was already unchecked or not in the list
*/
- fun uncheck(tag: String): Boolean = indeterminateTags.remove(tag) || checkedTags.remove(tag)
+ fun uncheck(tag: String): Boolean {
+ val changed = indeterminateTags.remove(tag) || checkedTags.remove(tag)
+ if (changed) {
+ needsSort = true
+ }
+ return changed
+ }
/**
* Mark a tag as indeterminate tag
@@ -170,7 +181,11 @@ class TagsList(
return false
}
checkedTags.remove(tag)
- return indeterminateTags.add(tag)
+ val changed = indeterminateTags.add(tag)
+ if (changed) {
+ needsSort = true
+ }
+ return changed
}
/**
@@ -181,6 +196,7 @@ class TagsList(
* @return true if this tag list changed as a result of the call
*/
fun toggleAllCheckedStatuses(): Boolean {
+ needsSort = true
indeterminateTags.clear()
if (allTags.size == checkedTags.size) {
checkedTags.clear()
@@ -266,6 +282,9 @@ class TagsList(
* A tag priors to another one if its root tag is checked or indeterminate while the other one's is not
*/
fun sort() {
+ if (!needsSort) {
+ return
+ }
// Splitting tags and looking up root selection inside the comparator repeats this work
// for every comparison. Compute the keys once per tag instead.
val sortKeys =
@@ -282,6 +301,7 @@ class TagsList(
compareTag(lhsKey.parts, rhsKey.parts)
}
}
+ needsSort = false
}
private class SortKey(
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/tags/TagsListTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/tags/TagsListTest.kt
index a6adbfb198ed..2ab3559f81fd 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/tags/TagsListTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/tags/TagsListTest.kt
@@ -522,6 +522,50 @@ class TagsListTest {
)
}
+ @Test
+ fun `sort updates ordering after checking and unchecking tags`() {
+ val tags = TagsList(listOf("c", "b", "a"), emptyList())
+ tags.sort()
+
+ tags.check("C")
+ tags.sort()
+ assertEquals(listOf("c", "a", "b"), tags.copyOfAllTagList())
+
+ tags.uncheck("c")
+ tags.sort()
+ assertEquals(listOf("a", "b", "c"), tags.copyOfAllTagList())
+ }
+
+ @Test
+ fun `sort updates ordering when a branch becomes indeterminate`() {
+ val tags = TagsList(listOf("a", "b::child"), emptyList())
+ tags.sort()
+
+ tags.check("B::child")
+ tags.sort()
+ assertEquals(listOf("b", "b::child", "a"), tags.copyOfAllTagList())
+
+ tags.uncheck("b::child")
+ tags.uncheck("b")
+ tags.sort()
+ assertEquals(listOf("a", "b", "b::child"), tags.copyOfAllTagList())
+
+ tags.setIndeterminate("B")
+ tags.sort()
+ assertEquals(listOf("b", "b::child", "a"), tags.copyOfAllTagList())
+ }
+
+ @Test
+ fun `sort updates ordering after selecting all tags`() {
+ val tags = TagsList(listOf("c", "b", "a"), listOf("c"))
+ tags.sort()
+ assertEquals(listOf("c", "a", "b"), tags.copyOfAllTagList())
+
+ tags.toggleAllCheckedStatuses()
+ tags.sort()
+ assertEquals(listOf("a", "b", "c"), tags.copyOfAllTagList())
+ }
+
@Test
fun `sort includes newly added ancestors and preserves case insensitive uniqueness`() {
val tags = TagsList(listOf("z", "a"), listOf("z"))
From 601741f824e11a47d89b6c104e6ce7adda59bf8b Mon Sep 17 00:00:00 2001
From: David Allison <62114487+david-allison@users.noreply.github.com>
Date: Mon, 28 Sep 2026 15:40:14 +0100
Subject: [PATCH 018/150] feat(build): expose release metadata as JSON
Add `:AnkiDroid:writeReleaseMetadata`:
A file is used as Gradle can also write to stdout/stderr:
`outputs/release-metadata.json`
```json
{
"versionName": "2.26.0alpha1",
"versionCode": 22600101,
"releaseType": "alpha"
}
```
Part of 20166
Assisted-by: GPT-6
---
AnkiDroid/build.gradle | 13 ++-
.../com/ichi2/anki/gradle/VersionCode.kt | 4 +-
.../ichi2/anki/gradle/WriteReleaseMetadata.kt | 63 ++++++++++++++
.../com/ichi2/anki/gradle/VersionCodeTest.kt | 17 ++++
.../anki/gradle/WriteReleaseMetadataTest.kt | 84 +++++++++++++++++++
5 files changed, 179 insertions(+), 2 deletions(-)
create mode 100644 buildSrc/src/main/kotlin/com/ichi2/anki/gradle/WriteReleaseMetadata.kt
create mode 100644 buildSrc/src/test/kotlin/com/ichi2/anki/gradle/WriteReleaseMetadataTest.kt
diff --git a/AnkiDroid/build.gradle b/AnkiDroid/build.gradle
index 5980a4ddecfc..d2cdcb372bc9 100644
--- a/AnkiDroid/build.gradle
+++ b/AnkiDroid/build.gradle
@@ -1,4 +1,5 @@
import com.ichi2.anki.gradle.VersionCodeTasks
+import com.ichi2.anki.gradle.WriteReleaseMetadata
plugins {
// Gradle plugin portal
@@ -377,8 +378,18 @@ android {
}
}
-// register `validateVersionCode` with our (potentially new versionCode).
+// register release tasks using the finalized version configuration.
androidComponents.finalizeDsl { androidDsl ->
+ // output versionName etc... to `build/outputs/release-metadata.json` for use in CI
+ tasks.register('writeReleaseMetadata', WriteReleaseMetadata) {
+ group = 'release'
+ description = 'Writes the configured release version and type as JSON.'
+ versionName.set(androidDsl.defaultConfig.versionName)
+ versionCode.set(androidDsl.defaultConfig.versionCode)
+ outputFile.set(layout.buildDirectory.file('outputs/release-metadata.json'))
+ }
+
+ // register `validateVersionCode` with the finalized version code.
def validation = VersionCodeTasks.registerValidationTask(project, androidDsl.defaultConfig.versionCode)
// handle all 'publish' calls, even if run directly
tasks.matching { it.name.startsWith("publish") }.configureEach {
diff --git a/buildSrc/src/main/kotlin/com/ichi2/anki/gradle/VersionCode.kt b/buildSrc/src/main/kotlin/com/ichi2/anki/gradle/VersionCode.kt
index a3e238bb1ff7..8bbc1bf3b4d1 100644
--- a/buildSrc/src/main/kotlin/com/ichi2/anki/gradle/VersionCode.kt
+++ b/buildSrc/src/main/kotlin/com/ichi2/anki/gradle/VersionCode.kt
@@ -8,8 +8,10 @@ object VersionCode {
// Consume whole comments atomically so dots inside them cannot look like chained calls.
private const val WHITESPACE_OR_COMMENTS = """(?>\s|//[^\r\n]*|/\*[\s\S]*?\*/)*"""
+ // Ignore task property access such as versionCode.set(...).
// Include chained calls across whitespace/comments so a numeric prefix cannot pass as a literal.
- private val assignment = Regex("""^\h*versionCode\b\h*=?\s*([^\r\n]+(?:\R$WHITESPACE_OR_COMMENTS\.[^\r\n]*)*)""", RegexOption.MULTILINE)
+ private val assignment =
+ Regex("""^\h*versionCode\b(?!\h*\.)\h*=?\s*([^\r\n]+(?:\R$WHITESPACE_OR_COMMENTS\.[^\r\n]*)*)""", RegexOption.MULTILINE)
private val literal = Regex("""([1-9][0-9]*)\h*;?\h*(?://.*)?""")
/**
diff --git a/buildSrc/src/main/kotlin/com/ichi2/anki/gradle/WriteReleaseMetadata.kt b/buildSrc/src/main/kotlin/com/ichi2/anki/gradle/WriteReleaseMetadata.kt
new file mode 100644
index 000000000000..8e2d95822dc8
--- /dev/null
+++ b/buildSrc/src/main/kotlin/com/ichi2/anki/gradle/WriteReleaseMetadata.kt
@@ -0,0 +1,63 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.gradle
+
+import groovy.json.JsonOutput
+import org.gradle.api.DefaultTask
+import org.gradle.api.file.RegularFileProperty
+import org.gradle.api.provider.Property
+import org.gradle.api.tasks.CacheableTask
+import org.gradle.api.tasks.Input
+import org.gradle.api.tasks.OutputFile
+import org.gradle.api.tasks.TaskAction
+
+/**
+ * Exports the configured release version, before any release-script version bump or variant suffix.
+ *
+ * Write JSON to a file because Gradle and plugins can also log to stdout. Consumers should read the
+ * file directly instead of redirecting Gradle output, which may mix those messages with the JSON.
+ *
+ * Example output:
+ * ```json
+ * {
+ * "versionName": "2.26.0alpha0",
+ * "versionCode": 22600100,
+ * "releaseType": "alpha"
+ * }
+ * ```
+ */
+@CacheableTask
+abstract class WriteReleaseMetadata : DefaultTask() {
+ @get:Input
+ abstract val versionName: Property
+
+ @get:Input
+ abstract val versionCode: Property
+
+ @get:OutputFile
+ abstract val outputFile: RegularFileProperty
+
+ @TaskAction
+ fun writeMetadata() {
+ val code = versionCode.get()
+ require(code > 0) { "versionCode must be positive: $code" }
+ // The hundreds digit encodes the release type in AnkiDroid's AbbCCtDD version code.
+ val releaseType =
+ when (code / 100 % 10) {
+ 0 -> "dev"
+ 1 -> "alpha"
+ 2 -> "beta"
+ 3 -> "public"
+ else -> error("Unknown release type in versionCode: $code")
+ }
+ val metadata =
+ mapOf(
+ "versionName" to versionName.get(),
+ "versionCode" to code,
+ "releaseType" to releaseType,
+ )
+ val destination = outputFile.get().asFile
+ destination.parentFile.mkdirs()
+ destination.writeText(JsonOutput.prettyPrint(JsonOutput.toJson(metadata)) + "\n")
+ }
+}
diff --git a/buildSrc/src/test/kotlin/com/ichi2/anki/gradle/VersionCodeTest.kt b/buildSrc/src/test/kotlin/com/ichi2/anki/gradle/VersionCodeTest.kt
index 920fe07c81d8..45149160e65b 100644
--- a/buildSrc/src/test/kotlin/com/ichi2/anki/gradle/VersionCodeTest.kt
+++ b/buildSrc/src/test/kotlin/com/ichi2/anki/gradle/VersionCodeTest.kt
@@ -20,6 +20,23 @@ class VersionCodeTest {
}
}
+ @Test
+ fun `ignores task property setters when reading the app version code`() {
+ val script =
+ """
+ android {
+ defaultConfig {
+ versionCode = 22600100
+ }
+ }
+ tasks.register('writeReleaseMetadata', WriteReleaseMetadata) {
+ versionCode.set(android.defaultConfig.versionCode)
+ }
+ """.trimIndent()
+
+ assertEquals(22600100, VersionCode.read(script))
+ }
+
@Test
fun `rejects missing ambiguous and unsupported versionCode declarations`() {
for (script in listOf(
diff --git a/buildSrc/src/test/kotlin/com/ichi2/anki/gradle/WriteReleaseMetadataTest.kt b/buildSrc/src/test/kotlin/com/ichi2/anki/gradle/WriteReleaseMetadataTest.kt
new file mode 100644
index 000000000000..796d10b294c1
--- /dev/null
+++ b/buildSrc/src/test/kotlin/com/ichi2/anki/gradle/WriteReleaseMetadataTest.kt
@@ -0,0 +1,84 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.gradle
+
+import groovy.json.JsonSlurper
+import org.gradle.testfixtures.ProjectBuilder
+import org.junit.jupiter.api.Assertions.assertEquals
+import org.junit.jupiter.api.Assertions.assertFalse
+import org.junit.jupiter.api.Assertions.assertThrows
+import org.junit.jupiter.api.Test
+import org.junit.jupiter.api.io.TempDir
+import org.junit.jupiter.params.ParameterizedTest
+import org.junit.jupiter.params.provider.CsvSource
+import org.junit.jupiter.params.provider.ValueSource
+import java.io.File
+
+/** Tests for [WriteReleaseMetadata] */
+class WriteReleaseMetadataTest {
+ @TempDir
+ lateinit var projectDir: File
+
+ @ParameterizedTest
+ @CsvSource(
+ "2.26.0dev, 22600000, dev",
+ "2.26.0alpha0, 22600100, alpha",
+ "2.26.0alpha99, 22600199, alpha",
+ "2.26.0beta2, 22600202, beta",
+ "2.26.0, 22600300, public",
+ )
+ fun `writes the configured version and release type as JSON`(
+ name: String,
+ code: Int,
+ releaseType: String,
+ ) {
+ assertEquals(
+ mapOf("versionName" to name, "versionCode" to code, "releaseType" to releaseType),
+ writeMetadata(name, code),
+ )
+ }
+
+ @Test
+ fun `rejects unknown release types before writing metadata`() {
+ assertRejectsVersionCode(22600400, IllegalStateException::class.java)
+ }
+
+ @ParameterizedTest
+ @ValueSource(ints = [0, -1])
+ fun `rejects nonpositive version codes`(code: Int) {
+ assertRejectsVersionCode(code, IllegalArgumentException::class.java)
+ }
+
+ private fun writeMetadata(
+ name: String,
+ code: Int,
+ ): Map<*, *> {
+ val task = metadataTask(name, code)
+ task.writeMetadata()
+ return JsonSlurper().parse(task.outputFile.get().asFile) as Map<*, *>
+ }
+
+ private fun assertRejectsVersionCode(
+ code: Int,
+ exceptionType: Class,
+ ) {
+ val task = metadataTask("2.26.0dev", code)
+ val destination = task.outputFile.get().asFile
+
+ assertThrows(exceptionType) { task.writeMetadata() }
+ assertFalse(destination.exists())
+ }
+
+ private fun metadataTask(
+ name: String,
+ code: Int,
+ ): WriteReleaseMetadata {
+ val project = ProjectBuilder.builder().withProjectDir(projectDir).build()
+ val task = project.tasks.register("writeReleaseMetadata", WriteReleaseMetadata::class.java).get()
+ return task.apply {
+ versionName.set(name)
+ versionCode.set(code)
+ outputFile.set(project.layout.buildDirectory.file("outputs/release-metadata.json"))
+ }
+ }
+}
From 02aed1d65dc57efebe4baa5bf7a34ff37cd9c446 Mon Sep 17 00:00:00 2001
From: David Allison <62114487+david-allison@users.noreply.github.com>
Date: Mon, 28 Sep 2026 15:40:46 +0100
Subject: [PATCH 019/150] feat(ci): weekly alpha releases
Scheduled for every Monday at 04:30 UTC
Skipped if:
* A publish occurred fewer than 7 UTC calendar days ago.
* The app is not in alpha.
* There were no commits since the last release.
This uses the release metadata published by gradle (`build/outputs/release-metadata.json`)
and `.github/check_alpha_release.js` to keep the yaml lean.
Fixes 20166
Assisted-by: GPT-6
---
.github/check_alpha_release.js | 54 +++++++++++++++++++
.github/workflows/README.md | 12 ++++-
.github/workflows/lint.yml | 2 +-
.github/workflows/publish.yml | 45 ++++++++++++++++
AnkiDroid/build.gradle | 1 -
buildSrc/build.gradle.kts | 2 +
.../ichi2/anki/gradle/WriteReleaseMetadata.kt | 4 ++
.../anki/gradle/ReleaseMetadataPathTest.kt | 17 ++++++
.../anki/gradle/WriteReleaseMetadataTest.kt | 1 -
9 files changed, 134 insertions(+), 4 deletions(-)
create mode 100644 .github/check_alpha_release.js
create mode 100644 buildSrc/src/test/kotlin/com/ichi2/anki/gradle/ReleaseMetadataPathTest.kt
diff --git a/.github/check_alpha_release.js b/.github/check_alpha_release.js
new file mode 100644
index 000000000000..332305a69137
--- /dev/null
+++ b/.github/check_alpha_release.js
@@ -0,0 +1,54 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+const fs = require("node:fs");
+
+module.exports = async function checkAlphaRelease({ github, context, core }) {
+ const metadata = JSON.parse(
+ fs.readFileSync("AnkiDroid/build/outputs/release-metadata.json", "utf8"),
+ );
+ if (metadata.releaseType !== "alpha") {
+ core.info("Skipping scheduled release: main is not on an alpha version.");
+ return false;
+ }
+
+ const releases = await github.paginate(github.rest.repos.listReleases, {
+ ...context.repo,
+ per_page: 100,
+ });
+ // Use calendar days to check, so last Monday's release doesn't block this release.
+ const day = 24 * 60 * 60 * 1000;
+ const today = Math.floor(Date.now() / day);
+ const recentRelease = releases.find(
+ release => !release.draft && today - Math.floor(Date.parse(release.published_at) / day) < 7,
+ );
+ if (recentRelease) {
+ core.info(
+ `Skipping scheduled release: ${recentRelease.tag_name} was published fewer than seven UTC calendar days ago.`,
+ );
+ return false;
+ }
+
+ const latestAlpha = releases
+ .filter(release => !release.draft && /^v\d+\.\d+\.\d+alpha\d+$/.test(release.tag_name))
+ .sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at))[0];
+ if (!latestAlpha) {
+ core.info("Skipping scheduled release: no published alpha to compare against.");
+ return false;
+ }
+
+ const { data: comparison } = await github.rest.repos.compareCommitsWithBasehead({
+ ...context.repo,
+ basehead: `${latestAlpha.tag_name}...${context.sha}`,
+ });
+ if (comparison.status !== "ahead" || comparison.ahead_by === 0) {
+ core.info(
+ `Skipping scheduled release: no new commits descending from ${latestAlpha.tag_name}.`,
+ );
+ return false;
+ }
+
+ core.info(
+ `Publishing an alpha: ${comparison.ahead_by} new commits since ${latestAlpha.tag_name}.`,
+ );
+ return true;
+};
diff --git a/.github/workflows/README.md b/.github/workflows/README.md
index a6051a7cbbdf..b8f7926444bf 100644
--- a/.github/workflows/README.md
+++ b/.github/workflows/README.md
@@ -22,4 +22,14 @@ Alternately, you may run the actions on your fork of `Anki-Android`.
## Other Workflows
-These are typically run by maintainers. See the [Maintenance guide](https://github.com/ankidroid/Anki-Android/wiki/Maintenance-guide)
\ No newline at end of file
+These are typically run by maintainers. See the [Maintenance guide](https://github.com/ankidroid/Anki-Android/wiki/Maintenance-guide)
+
+### Automatic publish
+
+An automatic alpha publish is performed every Monday at 04:30 UTC. This publish is skipped if:
+
+* A publish occurred fewer than 7 UTC calendar days ago.
+* The app is not in alpha.
+* There were no commits since the last release.
+
+An alpha published on Monday does not block the following Monday's release, regardless of the time it finished.
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index b95542719c11..2222728ea519 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -88,7 +88,7 @@ jobs:
- name: Prettify AnkiDroid javascript code
uses: creyD/prettier_action@8c18391fdc98ed0d884c6345f03975edac71b8f0 # v4.6
with:
- prettier_options: --check AnkiDroid/**/*.js
+ prettier_options: --check AnkiDroid/**/*.js .github/check_alpha_release.js
dry: True
- name: Install Localization
diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml
index 2adaf6bd1313..38465add834c 100644
--- a/.github/workflows/publish.yml
+++ b/.github/workflows/publish.yml
@@ -1,6 +1,9 @@
name: 🚀 Publish
on:
+ schedule:
+ # Check every Monday (04:30); skip if any release is fewer than seven UTC calendar days old.
+ - cron: "30 4 * * 1"
workflow_dispatch:
inputs:
public:
@@ -22,8 +25,50 @@ concurrency:
cancel-in-progress: false
jobs:
+ check_release:
+ name: 'Check whether a release is due'
+ if: github.event_name == 'schedule' && github.repository == 'ankidroid/Anki-Android' && github.ref == 'refs/heads/main'
+ timeout-minutes: 20
+ runs-on: ubuntu-latest
+ outputs:
+ publish: ${{ steps.check.outputs.result }}
+ steps:
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - name: Configure JDK
+ uses: actions/setup-java@dd06d9cba3e5552c54d9f8ea23572deb30010f7c # v6.0.0
+ with:
+ distribution: "jetbrains"
+ java-version: "21"
+
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0
+ with:
+ cache-provider: basic
+ cache-read-only: true
+
+ - name: Write release metadata
+ run: ./gradlew :AnkiDroid:writeReleaseMetadata --console=plain
+
+ - id: check
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ with:
+ script: |
+ const checkAlphaRelease = require('./.github/check_alpha_release.js');
+ return await checkAlphaRelease({ github, context, core });
+
release_app:
name: 'Release App'
+ # wait for 'check_release' (success/skipped)
+ needs: check_release
+ # Manual releases proceed when the scheduled check is skipped.
+ if: >
+ github.repository == 'ankidroid/Anki-Android' &&
+ !cancelled() &&
+ (
+ github.event_name == 'workflow_dispatch' ||
+ (success() && needs.check_release.outputs.publish == 'true')
+ )
timeout-minutes: 120
runs-on: ubuntu-latest
steps:
diff --git a/AnkiDroid/build.gradle b/AnkiDroid/build.gradle
index d2cdcb372bc9..557b0b47f58b 100644
--- a/AnkiDroid/build.gradle
+++ b/AnkiDroid/build.gradle
@@ -386,7 +386,6 @@ androidComponents.finalizeDsl { androidDsl ->
description = 'Writes the configured release version and type as JSON.'
versionName.set(androidDsl.defaultConfig.versionName)
versionCode.set(androidDsl.defaultConfig.versionCode)
- outputFile.set(layout.buildDirectory.file('outputs/release-metadata.json'))
}
// register `validateVersionCode` with the finalized version code.
diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts
index 6d1e48f82be4..6f76ceb01b9e 100644
--- a/buildSrc/build.gradle.kts
+++ b/buildSrc/build.gradle.kts
@@ -25,6 +25,8 @@ dependencies {
tasks.test {
useJUnitPlatform()
+ // Rerun the path guard if the app build file is moved.
+ inputs.files("../AnkiDroid/build.gradle")
}
configure {
diff --git a/buildSrc/src/main/kotlin/com/ichi2/anki/gradle/WriteReleaseMetadata.kt b/buildSrc/src/main/kotlin/com/ichi2/anki/gradle/WriteReleaseMetadata.kt
index 8e2d95822dc8..52b5d10250e1 100644
--- a/buildSrc/src/main/kotlin/com/ichi2/anki/gradle/WriteReleaseMetadata.kt
+++ b/buildSrc/src/main/kotlin/com/ichi2/anki/gradle/WriteReleaseMetadata.kt
@@ -37,6 +37,10 @@ abstract class WriteReleaseMetadata : DefaultTask() {
@get:OutputFile
abstract val outputFile: RegularFileProperty
+ init {
+ outputFile.convention(project.layout.buildDirectory.file("outputs/release-metadata.json"))
+ }
+
@TaskAction
fun writeMetadata() {
val code = versionCode.get()
diff --git a/buildSrc/src/test/kotlin/com/ichi2/anki/gradle/ReleaseMetadataPathTest.kt b/buildSrc/src/test/kotlin/com/ichi2/anki/gradle/ReleaseMetadataPathTest.kt
new file mode 100644
index 000000000000..474381f7af04
--- /dev/null
+++ b/buildSrc/src/test/kotlin/com/ichi2/anki/gradle/ReleaseMetadataPathTest.kt
@@ -0,0 +1,17 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.gradle
+
+import org.junit.jupiter.api.Assertions.assertTrue
+import org.junit.jupiter.api.Test
+import java.io.File
+
+class ReleaseMetadataPathTest {
+ @Test
+ fun `app build file stays at the path assumed by the publishing script`() {
+ assertTrue(
+ File("../AnkiDroid/build.gradle").isFile,
+ "Update .github/check_alpha_release.js if the AnkiDroid module is moved.",
+ )
+ }
+}
diff --git a/buildSrc/src/test/kotlin/com/ichi2/anki/gradle/WriteReleaseMetadataTest.kt b/buildSrc/src/test/kotlin/com/ichi2/anki/gradle/WriteReleaseMetadataTest.kt
index 796d10b294c1..cafbb5330c01 100644
--- a/buildSrc/src/test/kotlin/com/ichi2/anki/gradle/WriteReleaseMetadataTest.kt
+++ b/buildSrc/src/test/kotlin/com/ichi2/anki/gradle/WriteReleaseMetadataTest.kt
@@ -78,7 +78,6 @@ class WriteReleaseMetadataTest {
return task.apply {
versionName.set(name)
versionCode.set(code)
- outputFile.set(project.layout.buildDirectory.file("outputs/release-metadata.json"))
}
}
}
From 1a0085edaca8d7fb6ee55ee7d07be290bcf73aff Mon Sep 17 00:00:00 2001
From: David Allison <62114487+david-allison@users.noreply.github.com>
Date: Mon, 28 Sep 2026 22:23:31 +0100
Subject: [PATCH 020/150] refactor: remove Hungarian notation
Assisted-by: GPT-6
---
.../anki/provider/CardContentProvider.kt | 28 +++++++++----------
1 file changed, 14 insertions(+), 14 deletions(-)
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt b/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt
index 424ca9526ada..7069930a62f5 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt
@@ -108,7 +108,7 @@ class CardContentProvider : ContentProvider() {
private const val MEDIA = 5000
private const val CARDS = 6000
private const val CARD_ID = 6001
- private val sUriMatcher = UriMatcher(UriMatcher.NO_MATCH)
+ private val uriMatcher = UriMatcher(UriMatcher.NO_MATCH)
/**
* The names of the columns returned by this content provider differ slightly from the names
@@ -119,17 +119,17 @@ class CardContentProvider : ContentProvider() {
* This is currently only "_id" (projection) vs. "id" (Anki DB). But should probably be
* applied to more columns. "MID", "USN", "MOD" are not really user friendly.
*/
- private val sDefaultNoteProjectionDBAccess = FlashCardsContract.Note.DEFAULT_PROJECTION.clone()
+ private val defaultNoteProjectionDbAccess = FlashCardsContract.Note.DEFAULT_PROJECTION.clone()
private fun sanitizeNoteProjection(projection: Array?): Array {
if (projection.isNullOrEmpty()) {
- return sDefaultNoteProjectionDBAccess
+ return defaultNoteProjectionDbAccess
}
val sanitized = ArrayList(projection.size)
for (column in projection) {
val idx = FlashCardsContract.Note.DEFAULT_PROJECTION.indexOf(column)
if (idx >= 0) {
- sanitized.add(sDefaultNoteProjectionDBAccess[idx])
+ sanitized.add(defaultNoteProjectionDbAccess[idx])
} else {
throw IllegalArgumentException("Unknown column $column")
}
@@ -141,7 +141,7 @@ class CardContentProvider : ContentProvider() {
fun addUri(
path: String,
code: Int,
- ) = sUriMatcher.addURI(FlashCardsContract.AUTHORITY, path, code)
+ ) = uriMatcher.addURI(FlashCardsContract.AUTHORITY, path, code)
// Here you can see all the URIs at a glance
addUri("notes", NOTES)
addUri("notes_v2", NOTES_V2)
@@ -162,9 +162,9 @@ class CardContentProvider : ContentProvider() {
addUri("cards", CARDS)
addUri("cards/#", CARD_ID)
- for (idx in sDefaultNoteProjectionDBAccess.indices) {
- if (sDefaultNoteProjectionDBAccess[idx] == FlashCardsContract.Note._ID) {
- sDefaultNoteProjectionDBAccess[idx] = "id as _id"
+ for (idx in defaultNoteProjectionDbAccess.indices) {
+ if (defaultNoteProjectionDbAccess[idx] == FlashCardsContract.Note._ID) {
+ defaultNoteProjectionDbAccess[idx] = "id as _id"
}
}
}
@@ -181,7 +181,7 @@ class CardContentProvider : ContentProvider() {
@Suppress("RedundantNullableReturnType")
override fun getType(uri: Uri): String? {
// Find out what data the user is requesting
- return when (sUriMatcher.match(uri)) {
+ return when (uriMatcher.match(uri)) {
NOTES_V2, NOTES -> FlashCardsContract.Note.CONTENT_TYPE
NOTES_ID -> FlashCardsContract.Note.CONTENT_ITEM_TYPE
NOTES_ID_CARDS, NOTE_TYPES_ID_EMPTY_CARDS -> FlashCardsContract.Card.CONTENT_TYPE
@@ -238,7 +238,7 @@ class CardContentProvider : ContentProvider() {
Timber.d(getLogMessage("query", uri))
// Find out what data the user is requesting
- return when (sUriMatcher.match(uri)) {
+ return when (uriMatcher.match(uri)) {
NOTES_V2 -> {
// Search for notes using direct SQL query
val proj = sanitizeNoteProjection(projection)
@@ -492,7 +492,7 @@ class CardContentProvider : ContentProvider() {
Timber.d(getLogMessage("update", uri))
// Find out what data the user is requesting
- val match = sUriMatcher.match(uri)
+ val match = uriMatcher.match(uri)
var updated = 0 // Number of updated entries (return value)
when (match) {
NOTES_V2, NOTES -> throw IllegalArgumentException("Not possible to update notes directly (only through data URI)")
@@ -765,7 +765,7 @@ class CardContentProvider : ContentProvider() {
Timber.d(getLogMessage("delete", uri))
val deletedCount =
- when (sUriMatcher.match(uri)) {
+ when (uriMatcher.match(uri)) {
NOTES_ID -> {
col.removeNotes(noteIds = listOf(uri.pathSegments[1].toLong())).count
}
@@ -804,7 +804,7 @@ class CardContentProvider : ContentProvider() {
// by default, #bulkInsert simply calls insert for each item in #values
// but in some cases, we want to override this behavior
- val match = sUriMatcher.match(uri)
+ val match = uriMatcher.match(uri)
val deckIdStr = uri.getQueryParameter(FlashCardsContract.Note.DECK_ID_QUERY_PARAM)
val deckId =
@@ -896,7 +896,7 @@ class CardContentProvider : ContentProvider() {
// Find out what data the user is requesting
val insertedUri =
- when (sUriMatcher.match(uri)) {
+ when (uriMatcher.match(uri)) {
NOTES -> {
/* Insert new note with specified fields and tags
*/
From 6d92cab98112f2afa6ed561c8c57b77af84bd59b Mon Sep 17 00:00:00 2001
From: David Allison <62114487+david-allison@users.noreply.github.com>
Date: Mon, 28 Sep 2026 22:24:05 +0100
Subject: [PATCH 021/150] feat(api): expose independent default columns
Add:
* `DEFAULT_COLUMNS`
* `getDefaultColumns()` (Java)
Mutating these arrays no longer impacts the API
Part of 12346
Assisted-by: GPT-6
---
.../java/com/ichi2/anki/FlashCardsContract.kt | 175 +++++++++++-------
.../anki/FlashCardsContractJavaTest.java | 25 +++
.../com/ichi2/anki/FlashCardsContractTest.kt | 64 +++++++
3 files changed, 199 insertions(+), 65 deletions(-)
create mode 100644 api/src/test/java/com/ichi2/anki/FlashCardsContractJavaTest.java
create mode 100644 api/src/test/java/com/ichi2/anki/FlashCardsContractTest.kt
diff --git a/api/src/main/java/com/ichi2/anki/FlashCardsContract.kt b/api/src/main/java/com/ichi2/anki/FlashCardsContract.kt
index 9a1403dd219b..75282acc822f 100644
--- a/api/src/main/java/com/ichi2/anki/FlashCardsContract.kt
+++ b/api/src/main/java/com/ichi2/anki/FlashCardsContract.kt
@@ -262,21 +262,29 @@ public object FlashCardsContract {
public const val FLAGS: String = "flags"
public const val DATA: String = "data"
+ /**
+ * Default columns returned by queries.
+ */
+ @get:JvmName("getDefaultColumns")
+ @JvmStatic // required for Java API
+ public val DEFAULT_COLUMNS: Array
+ get() =
+ arrayOf(
+ _ID,
+ GUID,
+ MID,
+ MOD,
+ USN,
+ TAGS,
+ FLDS,
+ SFLD,
+ CSUM,
+ FLAGS,
+ DATA,
+ )
+
@JvmField // required for Java API
- public val DEFAULT_PROJECTION: Array =
- arrayOf(
- _ID,
- GUID,
- MID,
- MOD,
- USN,
- TAGS,
- FLDS,
- SFLD,
- CSUM,
- FLAGS,
- DATA,
- )
+ public val DEFAULT_PROJECTION: Array = DEFAULT_COLUMNS
/**
* MIME type used for a note.
@@ -393,20 +401,28 @@ public object FlashCardsContract {
*/
public const val DECK_ID: String = "deck_id"
+ /**
+ * Default columns returned by queries.
+ */
+ @get:JvmName("getDefaultColumns")
+ @JvmStatic // required for Java API
+ public val DEFAULT_COLUMNS: Array
+ get() =
+ arrayOf(
+ _ID,
+ NAME,
+ FIELD_NAMES,
+ NUM_CARDS,
+ CSS,
+ DECK_ID,
+ SORT_FIELD_INDEX,
+ TYPE,
+ LATEX_POST,
+ LATEX_PRE,
+ )
+
@JvmField // required for Java API
- public val DEFAULT_PROJECTION: Array =
- arrayOf(
- _ID,
- NAME,
- FIELD_NAMES,
- NUM_CARDS,
- CSS,
- DECK_ID,
- SORT_FIELD_INDEX,
- TYPE,
- LATEX_POST,
- LATEX_PRE,
- )
+ public val DEFAULT_PROJECTION: Array = DEFAULT_COLUMNS
/**
* MIME type used for a note type.
@@ -481,18 +497,23 @@ public object FlashCardsContract {
public const val CARD_COUNT: String = "card_count"
/**
- * Default columns that are returned when querying the ...models/#/templates URI.
+ * Default columns returned when querying the ...models/#/templates URI.
*/
+ @get:JvmName("getDefaultColumns")
+ @JvmStatic // required for Java API
+ public val DEFAULT_COLUMNS: Array
+ get() =
+ arrayOf(
+ _ID,
+ MODEL_ID,
+ ORD,
+ NAME,
+ QUESTION_FORMAT,
+ ANSWER_FORMAT,
+ )
+
@JvmField // required for Java API
- public val DEFAULT_PROJECTION: Array =
- arrayOf(
- _ID,
- MODEL_ID,
- ORD,
- NAME,
- QUESTION_FORMAT,
- ANSWER_FORMAT,
- )
+ public val DEFAULT_PROJECTION: Array = DEFAULT_COLUMNS
}
/**
@@ -916,18 +937,26 @@ public object FlashCardsContract {
@JvmField // required for Java API
public val CONTENT_URI: Uri = Uri.withAppendedPath(AUTHORITY_URI, "cards")
+ /**
+ * Default columns returned by queries.
+ */
+ @get:JvmName("getDefaultColumns")
+ @JvmStatic // required for Java API
+ public val DEFAULT_COLUMNS: Array
+ get() =
+ arrayOf(
+ _ID,
+ NOTE_ID,
+ CARD_ORD,
+ CARD_NAME,
+ DECK_ID,
+ QUESTION,
+ ANSWER,
+ FLAGS,
+ )
+
@JvmField // required for Java API
- public val DEFAULT_PROJECTION: Array =
- arrayOf(
- _ID,
- NOTE_ID,
- CARD_ORD,
- CARD_NAME,
- DECK_ID,
- QUESTION,
- ANSWER,
- FLAGS,
- )
+ public val DEFAULT_PROJECTION: Array = DEFAULT_COLUMNS
/**
* MIME type used for a card.
@@ -1098,15 +1127,23 @@ public object FlashCardsContract {
*/
public const val SUSPEND: String = "suspended"
+ /**
+ * Default columns returned by queries.
+ */
+ @get:JvmName("getDefaultColumns")
+ @JvmStatic // required for Java API
+ public val DEFAULT_COLUMNS: Array
+ get() =
+ arrayOf(
+ NOTE_ID,
+ CARD_ORD,
+ BUTTON_COUNT,
+ NEXT_REVIEW_TIMES,
+ MEDIA_FILES,
+ )
+
@JvmField // required for Java API
- public val DEFAULT_PROJECTION: Array =
- arrayOf(
- NOTE_ID,
- CARD_ORD,
- BUTTON_COUNT,
- NEXT_REVIEW_TIMES,
- MEDIA_FILES,
- )
+ public val DEFAULT_PROJECTION: Array = DEFAULT_COLUMNS
/**
* MIME type used for ReviewInfo.
@@ -1243,16 +1280,24 @@ public object FlashCardsContract {
*/
public const val DECK_DESC: String = "deck_desc"
+ /**
+ * Default columns returned by queries.
+ */
+ @get:JvmName("getDefaultColumns")
+ @JvmStatic // required for Java API
+ public val DEFAULT_COLUMNS: Array
+ get() =
+ arrayOf(
+ DECK_NAME,
+ DECK_ID,
+ DECK_COUNTS,
+ OPTIONS,
+ DECK_DYN,
+ DECK_DESC,
+ )
+
@JvmField // required for Java API
- public val DEFAULT_PROJECTION: Array =
- arrayOf(
- DECK_NAME,
- DECK_ID,
- DECK_COUNTS,
- OPTIONS,
- DECK_DYN,
- DECK_DESC,
- )
+ public val DEFAULT_PROJECTION: Array = DEFAULT_COLUMNS
/**
* MIME type used for Deck.
diff --git a/api/src/test/java/com/ichi2/anki/FlashCardsContractJavaTest.java b/api/src/test/java/com/ichi2/anki/FlashCardsContractJavaTest.java
new file mode 100644
index 000000000000..54c58cb4730c
--- /dev/null
+++ b/api/src/test/java/com/ichi2/anki/FlashCardsContractJavaTest.java
@@ -0,0 +1,25 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+
+package com.ichi2.anki;
+
+import static org.junit.Assert.assertArrayEquals;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.annotation.Config;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(sdk = {35})
+public class FlashCardsContractJavaTest {
+ @Test
+ @SuppressWarnings("deprecation") // Verify the original Java fields remain accessible.
+ public void defaultColumnsAreAccessibleFromJava() {
+ assertArrayEquals(FlashCardsContract.Note.DEFAULT_PROJECTION, FlashCardsContract.Note.getDefaultColumns());
+ assertArrayEquals(FlashCardsContract.Model.DEFAULT_PROJECTION, FlashCardsContract.Model.getDefaultColumns());
+ assertArrayEquals(FlashCardsContract.CardTemplate.DEFAULT_PROJECTION, FlashCardsContract.CardTemplate.getDefaultColumns());
+ assertArrayEquals(FlashCardsContract.Card.DEFAULT_PROJECTION, FlashCardsContract.Card.getDefaultColumns());
+ assertArrayEquals(FlashCardsContract.ReviewInfo.DEFAULT_PROJECTION, FlashCardsContract.ReviewInfo.getDefaultColumns());
+ assertArrayEquals(FlashCardsContract.Deck.DEFAULT_PROJECTION, FlashCardsContract.Deck.getDefaultColumns());
+ }
+}
diff --git a/api/src/test/java/com/ichi2/anki/FlashCardsContractTest.kt b/api/src/test/java/com/ichi2/anki/FlashCardsContractTest.kt
new file mode 100644
index 000000000000..a180558890c5
--- /dev/null
+++ b/api/src/test/java/com/ichi2/anki/FlashCardsContractTest.kt
@@ -0,0 +1,64 @@
+// SPDX-License-Identifier: LGPL-3.0-or-later
+
+package com.ichi2.anki
+
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import kotlin.test.assertContentEquals
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [35])
+@Suppress("DEPRECATION") // Verify compatibility with the original public fields.
+class FlashCardsContractTest {
+ @Test
+ fun `note default columns are independent`() {
+ assertIndependentColumns({ FlashCardsContract.Note.DEFAULT_COLUMNS }, FlashCardsContract.Note.DEFAULT_PROJECTION)
+ }
+
+ @Test
+ fun `model default columns are independent`() {
+ assertIndependentColumns({ FlashCardsContract.Model.DEFAULT_COLUMNS }, FlashCardsContract.Model.DEFAULT_PROJECTION)
+ }
+
+ @Test
+ fun `card template default columns are independent`() {
+ assertIndependentColumns({ FlashCardsContract.CardTemplate.DEFAULT_COLUMNS }, FlashCardsContract.CardTemplate.DEFAULT_PROJECTION)
+ }
+
+ @Test
+ fun `card default columns are independent`() {
+ assertIndependentColumns({ FlashCardsContract.Card.DEFAULT_COLUMNS }, FlashCardsContract.Card.DEFAULT_PROJECTION)
+ }
+
+ @Test
+ fun `review info default columns are independent`() {
+ assertIndependentColumns({ FlashCardsContract.ReviewInfo.DEFAULT_COLUMNS }, FlashCardsContract.ReviewInfo.DEFAULT_PROJECTION)
+ }
+
+ @Test
+ fun `deck default columns are independent`() {
+ assertIndependentColumns({ FlashCardsContract.Deck.DEFAULT_COLUMNS }, FlashCardsContract.Deck.DEFAULT_PROJECTION)
+ }
+
+ private fun assertIndependentColumns(
+ defaultColumns: () -> Array,
+ legacyProjection: Array,
+ ) {
+ val expected = legacyProjection.clone()
+ val columns = defaultColumns()
+ assertContentEquals(expected, columns)
+
+ columns[0] = "modified column"
+ assertContentEquals(expected, defaultColumns())
+ assertContentEquals(expected, legacyProjection)
+
+ try {
+ legacyProjection[0] = "modified legacy column"
+ assertContentEquals(expected, defaultColumns())
+ } finally {
+ expected.copyInto(legacyProjection)
+ }
+ }
+}
From 04e567b2440aa09387d54162c1f3debbe8c6f6d1 Mon Sep 17 00:00:00 2001
From: David Allison <62114487+david-allison@users.noreply.github.com>
Date: Mon, 28 Sep 2026 22:24:26 +0100
Subject: [PATCH 022/150] refactor(api): deprecate shared default projections
`DEFAULT_PROJECTION` mutability affected the API,
`DEFAULT_COLUMNS` fixes this
Part of 12346
Assisted-by: GPT-6
---
.../ichi2/anki/tests/ContentProviderTest.kt | 16 +++++-----
.../anki/provider/CardContentProvider.kt | 29 ++++++++++---------
.../java/com/ichi2/anki/FlashCardsContract.kt | 6 ++++
3 files changed, 29 insertions(+), 22 deletions(-)
diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/ContentProviderTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/ContentProviderTest.kt
index ec4627c4b966..9705445506b0 100644
--- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/ContentProviderTest.kt
+++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/ContentProviderTest.kt
@@ -853,7 +853,7 @@ class ContentProviderTest : InstrumentedTest() {
true,
)
- assertSingleCardProjection(card, null, FlashCardsContract.Card.DEFAULT_PROJECTION.toList()) {
+ assertSingleCardProjection(card, null, FlashCardsContract.Card.DEFAULT_COLUMNS.toList()) {
assertEquals(-1, it.getColumnIndex(FlashCardsContract.Card.ORIGINAL_POSITION))
assertEquals(-1, it.getColumnIndex(FlashCardsContract.Card.RAW_CUSTOM_DATA))
assertEquals(-1, it.getColumnIndex(FlashCardsContract.Card.FSRS_STABILITY))
@@ -1038,7 +1038,7 @@ class ContentProviderTest : InstrumentedTest() {
val cursor = contentResolver.cursorFor(cardUri)
cursor.use {
- assertEquals(FlashCardsContract.Card.DEFAULT_PROJECTION.toList(), it.columnNames.toList())
+ assertEquals(FlashCardsContract.Card.DEFAULT_COLUMNS.toList(), it.columnNames.toList())
assertTrue("default projection cursor should contain a row", it.moveToFirst())
assertEquals(-1, it.getColumnIndex(FlashCardsContract.Card.RAW_QUEUE))
@@ -1265,9 +1265,9 @@ class ContentProviderTest : InstrumentedTest() {
)
while (it.moveToNext()) {
// Check that it's possible to leave out columns from the projection
- for (i in FlashCardsContract.Note.DEFAULT_PROJECTION.indices) {
+ for (i in FlashCardsContract.Note.DEFAULT_COLUMNS.indices) {
val projection =
- removeFromProjection(FlashCardsContract.Note.DEFAULT_PROJECTION, i)
+ removeFromProjection(FlashCardsContract.Note.DEFAULT_COLUMNS, i)
val noteId =
it.getString(it.getColumnIndex(FlashCardsContract.Note._ID))
val noteUri = Uri.withAppendedPath(FlashCardsContract.Note.CONTENT_URI, noteId)
@@ -1311,8 +1311,8 @@ class ContentProviderTest : InstrumentedTest() {
fun testQueryNotesProjection() {
val cr = contentResolver
// Query all available notes
- for (i in FlashCardsContract.Note.DEFAULT_PROJECTION.indices) {
- val projection = removeFromProjection(FlashCardsContract.Note.DEFAULT_PROJECTION, i)
+ for (i in FlashCardsContract.Note.DEFAULT_COLUMNS.indices) {
+ val projection = removeFromProjection(FlashCardsContract.Note.DEFAULT_COLUMNS, i)
cr
.query(
FlashCardsContract.Note.CONTENT_URI,
@@ -1375,7 +1375,7 @@ class ContentProviderTest : InstrumentedTest() {
cv.put(FlashCardsContract.Note.FLDS, Utils.joinFields(dummyFields2))
cr.update(uri, cv, null, null)
cr
- .query(uri, FlashCardsContract.Note.DEFAULT_PROJECTION, null, null, null)
+ .query(uri, FlashCardsContract.Note.DEFAULT_COLUMNS, null, null, null)
.use { noteCursor ->
assertNotNull(
"Check that there is a valid cursor for detail data after update",
@@ -1755,7 +1755,7 @@ class ContentProviderTest : InstrumentedTest() {
contentResolver
.query(
FlashCardsContract.Deck.CONTENT_ALL_URI,
- FlashCardsContract.Deck.DEFAULT_PROJECTION,
+ FlashCardsContract.Deck.DEFAULT_COLUMNS,
null,
null,
null,
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt b/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt
index 7069930a62f5..160a266788a0 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt
@@ -119,15 +119,16 @@ class CardContentProvider : ContentProvider() {
* This is currently only "_id" (projection) vs. "id" (Anki DB). But should probably be
* applied to more columns. "MID", "USN", "MOD" are not really user friendly.
*/
- private val defaultNoteProjectionDbAccess = FlashCardsContract.Note.DEFAULT_PROJECTION.clone()
+ private val defaultNoteProjectionDbAccess = FlashCardsContract.Note.DEFAULT_COLUMNS
private fun sanitizeNoteProjection(projection: Array?): Array {
if (projection.isNullOrEmpty()) {
return defaultNoteProjectionDbAccess
}
+ val defaultColumns = FlashCardsContract.Note.DEFAULT_COLUMNS
val sanitized = ArrayList(projection.size)
for (column in projection) {
- val idx = FlashCardsContract.Note.DEFAULT_PROJECTION.indexOf(column)
+ val idx = defaultColumns.indexOf(column)
if (idx >= 0) {
sanitized.add(defaultNoteProjectionDbAccess[idx])
} else {
@@ -267,7 +268,7 @@ class CardContentProvider : ContentProvider() {
}
NOTES_ID_CARDS -> {
val currentNote = getNoteFromUri(uri, col)
- val columns = projection ?: FlashCardsContract.Card.DEFAULT_PROJECTION
+ val columns = projection ?: FlashCardsContract.Card.DEFAULT_COLUMNS
val rv = MatrixCursor(columns, 1)
for (currentCard: Card in currentNote.cards(col)) {
addCardToCursor(currentCard, rv, col, columns)
@@ -276,13 +277,13 @@ class CardContentProvider : ContentProvider() {
}
NOTES_ID_CARDS_ORD -> {
val currentCard = getCardFromUri(uri, col)
- val columns = projection ?: FlashCardsContract.Card.DEFAULT_PROJECTION
+ val columns = projection ?: FlashCardsContract.Card.DEFAULT_COLUMNS
val rv = MatrixCursor(columns, 1)
addCardToCursor(currentCard, rv, col, columns)
rv
}
NOTE_TYPES -> {
- val columns = projection ?: FlashCardsContract.Model.DEFAULT_PROJECTION
+ val columns = projection ?: FlashCardsContract.Model.DEFAULT_COLUMNS
val rv = MatrixCursor(columns, 1)
for (noteTypeId: NoteTypeId in col.notetypes.ids()) {
addNoteTypeToCursor(noteTypeId, col.notetypes, rv, columns)
@@ -291,7 +292,7 @@ class CardContentProvider : ContentProvider() {
}
NOTE_TYPES_ID -> {
val noteTypeId = getNoteTypeIdFromUri(uri, col)
- val columns = projection ?: FlashCardsContract.Model.DEFAULT_PROJECTION
+ val columns = projection ?: FlashCardsContract.Model.DEFAULT_COLUMNS
val rv = MatrixCursor(columns, 1)
addNoteTypeToCursor(noteTypeId, col.notetypes, rv, columns)
rv
@@ -299,7 +300,7 @@ class CardContentProvider : ContentProvider() {
NOTE_TYPES_ID_TEMPLATES -> {
// Direct access note type templates
val currentNoteType = col.notetypes.get(getNoteTypeIdFromUri(uri, col))
- val columns = projection ?: FlashCardsContract.CardTemplate.DEFAULT_PROJECTION
+ val columns = projection ?: FlashCardsContract.CardTemplate.DEFAULT_COLUMNS
val rv = MatrixCursor(columns, 1)
try {
for ((ord, template) in currentNoteType!!.templates.withIndex()) {
@@ -314,7 +315,7 @@ class CardContentProvider : ContentProvider() {
// Direct access note type template with specific ID
val ord = uri.lastPathSegment!!.toInt()
val currentNoteType = col.notetypes.get(getNoteTypeIdFromUri(uri, col))
- val columns = projection ?: FlashCardsContract.CardTemplate.DEFAULT_PROJECTION
+ val columns = projection ?: FlashCardsContract.CardTemplate.DEFAULT_COLUMNS
val rv = MatrixCursor(columns, 1)
try {
val template = getTemplateFromUri(uri, col)
@@ -325,7 +326,7 @@ class CardContentProvider : ContentProvider() {
rv
}
SCHEDULE -> {
- val columns = projection ?: FlashCardsContract.ReviewInfo.DEFAULT_PROJECTION
+ val columns = projection ?: FlashCardsContract.ReviewInfo.DEFAULT_COLUMNS
val rv = MatrixCursor(columns, 1)
val selectedDeckBeforeQuery = col.decks.selected()
var deckIdOfTemporarilySelectedDeck: Long = -1
@@ -388,7 +389,7 @@ class CardContentProvider : ContentProvider() {
rv
}
DECKS -> {
- val columns = projection ?: FlashCardsContract.Deck.DEFAULT_PROJECTION
+ val columns = projection ?: FlashCardsContract.Deck.DEFAULT_COLUMNS
val allDecks = col.sched.deckDueTree()
val rv = MatrixCursor(columns, 1)
allDecks.forEach {
@@ -405,7 +406,7 @@ class CardContentProvider : ContentProvider() {
}
DECKS_ID -> {
// Direct access deck
- val columns = projection ?: FlashCardsContract.Deck.DEFAULT_PROJECTION
+ val columns = projection ?: FlashCardsContract.Deck.DEFAULT_COLUMNS
val rv = MatrixCursor(columns, 1)
val allDecks = col.sched.deckDueTree()
val desiredDeckId = uri.pathSegments[1].toLong()
@@ -417,7 +418,7 @@ class CardContentProvider : ContentProvider() {
DECK_SELECTED -> {
val id = col.decks.selected()
val name = col.decks.name(id)
- val columns = projection ?: FlashCardsContract.Deck.DEFAULT_PROJECTION
+ val columns = projection ?: FlashCardsContract.Deck.DEFAULT_COLUMNS
val rv = MatrixCursor(columns, 1)
val counts = JSONArray(listOf(col.sched.counts()))
addDeckToCursor(id, name, counts, rv, col, columns)
@@ -425,7 +426,7 @@ class CardContentProvider : ContentProvider() {
}
CARDS -> {
// Search for cards using Anki browser syntax
- val columns = projection ?: FlashCardsContract.Card.DEFAULT_PROJECTION
+ val columns = projection ?: FlashCardsContract.Card.DEFAULT_COLUMNS
val query = selection ?: ""
val cardIds =
@@ -461,7 +462,7 @@ class CardContentProvider : ContentProvider() {
CARD_ID -> {
// Direct access to specific card by ID
val cardId = uri.pathSegments[1].toLong()
- val columns = projection ?: FlashCardsContract.Card.DEFAULT_PROJECTION
+ val columns = projection ?: FlashCardsContract.Card.DEFAULT_COLUMNS
val rv = MatrixCursor(columns, 1)
val card = col.getCard(cardId)
addCardToCursor(card, rv, col, columns)
diff --git a/api/src/main/java/com/ichi2/anki/FlashCardsContract.kt b/api/src/main/java/com/ichi2/anki/FlashCardsContract.kt
index 75282acc822f..cc4faaf3c662 100644
--- a/api/src/main/java/com/ichi2/anki/FlashCardsContract.kt
+++ b/api/src/main/java/com/ichi2/anki/FlashCardsContract.kt
@@ -284,6 +284,7 @@ public object FlashCardsContract {
)
@JvmField // required for Java API
+ @Deprecated("Use DEFAULT_COLUMNS for an independent array", replaceWith = ReplaceWith("DEFAULT_COLUMNS"))
public val DEFAULT_PROJECTION: Array = DEFAULT_COLUMNS
/**
@@ -422,6 +423,7 @@ public object FlashCardsContract {
)
@JvmField // required for Java API
+ @Deprecated("Use DEFAULT_COLUMNS for an independent array", replaceWith = ReplaceWith("DEFAULT_COLUMNS"))
public val DEFAULT_PROJECTION: Array = DEFAULT_COLUMNS
/**
@@ -513,6 +515,7 @@ public object FlashCardsContract {
)
@JvmField // required for Java API
+ @Deprecated("Use DEFAULT_COLUMNS for an independent array", replaceWith = ReplaceWith("DEFAULT_COLUMNS"))
public val DEFAULT_PROJECTION: Array = DEFAULT_COLUMNS
}
@@ -956,6 +959,7 @@ public object FlashCardsContract {
)
@JvmField // required for Java API
+ @Deprecated("Use DEFAULT_COLUMNS for an independent array", replaceWith = ReplaceWith("DEFAULT_COLUMNS"))
public val DEFAULT_PROJECTION: Array = DEFAULT_COLUMNS
/**
@@ -1143,6 +1147,7 @@ public object FlashCardsContract {
)
@JvmField // required for Java API
+ @Deprecated("Use DEFAULT_COLUMNS for an independent array", replaceWith = ReplaceWith("DEFAULT_COLUMNS"))
public val DEFAULT_PROJECTION: Array = DEFAULT_COLUMNS
/**
@@ -1297,6 +1302,7 @@ public object FlashCardsContract {
)
@JvmField // required for Java API
+ @Deprecated("Use DEFAULT_COLUMNS for an independent array", replaceWith = ReplaceWith("DEFAULT_COLUMNS"))
public val DEFAULT_PROJECTION: Array = DEFAULT_COLUMNS
/**
From bbec29cd1e3976a5a3f9de3d94ff8156ebecfbac Mon Sep 17 00:00:00 2001
From: David Allison <62114487+david-allison@users.noreply.github.com>
Date: Mon, 28 Sep 2026 22:12:34 +0100
Subject: [PATCH 023/150] refactor: extract emulator test command into Python
We want to add functionality improving test coverage extraction
Part of 22147
Assisted-by: GPT-6
---
.github/run_emulator_tests.py | 23 +++++++++++++++++++++++
.github/workflows/tests_emulator.yml | 2 +-
2 files changed, 24 insertions(+), 1 deletion(-)
create mode 100644 .github/run_emulator_tests.py
diff --git a/.github/run_emulator_tests.py b/.github/run_emulator_tests.py
new file mode 100644
index 000000000000..9a8961836951
--- /dev/null
+++ b/.github/run_emulator_tests.py
@@ -0,0 +1,23 @@
+#!/usr/bin/env python3
+# SPDX-License-Identifier: GPL-3.0-or-later
+
+"""Run emulator tests and generate the coverage report."""
+
+import subprocess
+import sys
+
+
+def main():
+ subprocess.run(
+ ["./gradlew", "uninstallAll", "jacocoAndroidTestReport", "--daemon"],
+ check=True,
+ )
+
+
+if __name__ == "__main__":
+ try:
+ main()
+ except subprocess.CalledProcessError as error:
+ sys.exit(error.returncode)
+ except OSError as error:
+ sys.exit(str(error))
diff --git a/.github/workflows/tests_emulator.yml b/.github/workflows/tests_emulator.yml
index 714274db142c..2cb3e22e4e98 100644
--- a/.github/workflows/tests_emulator.yml
+++ b/.github/workflows/tests_emulator.yml
@@ -241,7 +241,7 @@ jobs:
bash .github/adb_retry.sh shell settings put global hide_error_dialogs 1
bash .github/adb_retry.sh emu screenrecord start --time-limit 1800 video.webm
sleep 5
- bash .github/with_emulator_log.sh adb-log.txt ./gradlew uninstallAll jacocoAndroidTestReport --daemon
+ bash .github/with_emulator_log.sh adb-log.txt python3 .github/run_emulator_tests.py
- name: Report Emulator Test Results
if: always() && hashFiles('AnkiDroid/build/outputs/androidTest-results/connected/**/TEST-*.xml') != ''
From 7917cda197deebae80e099b01670553dbc08363b Mon Sep 17 00:00:00 2001
From: David Allison <62114487+david-allison@users.noreply.github.com>
Date: Mon, 28 Sep 2026 23:08:06 +0100
Subject: [PATCH 024/150] build: separate emulator tests from coverage
reporting
This allows coverage to be recovered without rerunning the tests.
Part of 22147
Assisted-by: GPT-6
---
.github/run_emulator_tests.py | 8 +++++++-
1 file changed, 7 insertions(+), 1 deletion(-)
diff --git a/.github/run_emulator_tests.py b/.github/run_emulator_tests.py
index 9a8961836951..bd444a7e8c27 100644
--- a/.github/run_emulator_tests.py
+++ b/.github/run_emulator_tests.py
@@ -8,8 +8,14 @@
def main():
+ connected_test = ":AnkiDroid:connectedPlayReleaseAndroidTest"
subprocess.run(
- ["./gradlew", "uninstallAll", "jacocoAndroidTestReport", "--daemon"],
+ ["./gradlew", "uninstallAll", connected_test,
+ "-Pandroid.injected.androidTest.leaveApksInstalledAfterRun=true", "--daemon"],
+ check=True,
+ )
+ subprocess.run(
+ ["./gradlew", ":AnkiDroid:jacocoAndroidTestReport", "-x", connected_test, "--daemon"],
check=True,
)
From f7eb6df311a4b194baf10bc6594ca5e1b6d1a854 Mon Sep 17 00:00:00 2001
From: David Allison <62114487+david-allison@users.noreply.github.com>
Date: Mon, 28 Sep 2026 23:08:20 +0100
Subject: [PATCH 025/150] fix: verify emulator coverage transfers
Coverage could be truncated.
Copy coverage and verify it via SHA-256, retrying up to 5 times.
Fixes Issue 22147
Assisted-by: GPT-6
---
.github/run_emulator_tests.py | 30 +++++++++++++++++++++++++++++-
1 file changed, 29 insertions(+), 1 deletion(-)
diff --git a/.github/run_emulator_tests.py b/.github/run_emulator_tests.py
index bd444a7e8c27..f384f060acc5 100644
--- a/.github/run_emulator_tests.py
+++ b/.github/run_emulator_tests.py
@@ -3,8 +3,29 @@
"""Run emulator tests and generate the coverage report."""
+import hashlib
+from pathlib import Path
import subprocess
import sys
+import time
+
+
+def read_coverage():
+ shell = ["adb", "shell", "-T", "run-as", "com.ichi2.anki"]
+ for attempt in range(1, 6):
+ try:
+ checksum = subprocess.check_output([*shell, "sha256sum", "coverage.ec"], timeout=30).split()
+ data = subprocess.check_output([*shell, "cat", "coverage.ec"], timeout=30)
+ # ADB can exit successfully after a truncated transfer.
+ if not data or not checksum or hashlib.sha256(data).hexdigest().encode() != checksum[0]:
+ raise ValueError("Coverage copy is empty or incomplete")
+ return data
+ except (subprocess.SubprocessError, ValueError) as error:
+ # TODO (Issue 22147 - Dec 2026): check CI logs for "Coverage copy attempt" failures.
+ print(f"Coverage copy attempt {attempt}/5 failed: {error}", file=sys.stderr)
+ if attempt == 5:
+ raise
+ time.sleep(1)
def main():
@@ -14,6 +35,13 @@ def main():
"-Pandroid.injected.androidTest.leaveApksInstalledAfterRun=true", "--daemon"],
check=True,
)
+ data = read_coverage()
+ output_dir = Path("AnkiDroid/build/outputs/code_coverage/playReleaseAndroidTest/connected")
+ output_dir.mkdir(parents=True, exist_ok=True)
+ # JaCoCo reads every .ec file, including AGP's potentially truncated copy.
+ for previous in output_dir.rglob("*.ec"):
+ previous.unlink()
+ (output_dir / "coverage.ec").write_bytes(data)
subprocess.run(
["./gradlew", ":AnkiDroid:jacocoAndroidTestReport", "-x", connected_test, "--daemon"],
check=True,
@@ -25,5 +53,5 @@ def main():
main()
except subprocess.CalledProcessError as error:
sys.exit(error.returncode)
- except OSError as error:
+ except (OSError, ValueError, subprocess.TimeoutExpired) as error:
sys.exit(str(error))
From 2debf8945ebffb04682adfff5e7ace326e6b6925 Mon Sep 17 00:00:00 2001
From: David Allison <62114487+david-allison@users.noreply.github.com>
Date: Tue, 29 Sep 2026 18:53:49 +0100
Subject: [PATCH 026/150] docs(backups): clarify params, return values and
exceptions
Assisted-by: GPT-6
---
.../java/com/ichi2/anki/BackendBackups.kt | 9 +++++
.../java/com/ichi2/anki/libanki/Collection.kt | 35 ++++++++++++++-----
2 files changed, 35 insertions(+), 9 deletions(-)
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/BackendBackups.kt b/AnkiDroid/src/main/java/com/ichi2/anki/BackendBackups.kt
index 21f50993cfd0..39778269e3f3 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/BackendBackups.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/BackendBackups.kt
@@ -17,10 +17,19 @@
package com.ichi2.anki
import com.ichi2.anki.CollectionManager.withCol
+import com.ichi2.anki.libanki.Collection
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
+import net.ankiweb.rsdroid.BackendException
+/**
+ * Attempts a backup and awaits its completion.
+ *
+ * @param force Bypass the minimum backup interval. Unchanged collections are still skipped.
+ * @throws BackendException if backup creation or completion fails. See [Collection.createBackup]
+ * for full exception details.
+ */
suspend fun performBackupInBackground(force: Boolean = false) {
// Wait a second to allow the deck list to finish loading first, or it
// will hang until the first stage of the backup completes.
diff --git a/libanki/src/main/java/com/ichi2/anki/libanki/Collection.kt b/libanki/src/main/java/com/ichi2/anki/libanki/Collection.kt
index 45c78a5e25fe..35dbb228af9d 100644
--- a/libanki/src/main/java/com/ichi2/anki/libanki/Collection.kt
+++ b/libanki/src/main/java/com/ichi2/anki/libanki/Collection.kt
@@ -78,10 +78,13 @@ import com.ichi2.anki.libanki.utils.LibAnkiAlias
import com.ichi2.anki.libanki.utils.NotInPyLib
import com.ichi2.anki.libanki.utils.capitalizePy
import net.ankiweb.rsdroid.Backend
+import net.ankiweb.rsdroid.BackendException
+import net.ankiweb.rsdroid.BackendException.BackendDbException
import net.ankiweb.rsdroid.BackendException.BackendFatalError
import net.ankiweb.rsdroid.BackendException.BackendSearchException
import net.ankiweb.rsdroid.RustCleanup
import net.ankiweb.rsdroid.exceptions.BackendInvalidInputException
+import net.ankiweb.rsdroid.exceptions.BackendIoException
import net.ankiweb.rsdroid.exceptions.BackendNotFoundException
import timber.log.Timber
import java.io.File
@@ -408,17 +411,28 @@ class Collection(
*/
/**
- * (Maybe) create a colpkg backup, while keeping the collection open. If the
- * configured backup interval has not elapsed, and force=false, no backup will be created,
- * and this routine will return false.
+ * (Maybe) create a colpkg backup, while keeping the collection open.
*
- * There must not be an active transaction.
- *
- * If `waitForCompletion` is true, block until the backup completes. Otherwise this routine
- * returns quickly, and the backup can be awaited on a background thread with awaitBackupCompletion()
- * to check for success.
+ * Any active database transaction must be committed or rolled back before creating a backup.
*
* Backups are automatically expired according to the user's settings.
+ *
+ * @param force Bypass the configured minimum backup interval. Unchanged collections are
+ * still skipped, even when this is `true`.
+ * @param waitForCompletion If `true`, block until the backup completes and propagate any failure.
+ * Otherwise, a `true` return value only means the backup started; call [awaitBackupCompletion]
+ * on a background thread to wait for completion and receive any background failure.
+ * @return `false` if the collection has not changed since the last backup, or if [force] is
+ * `false` and the minimum backup interval has not elapsed. Otherwise, `true`.
+ * A `false` result indicates a skipped backup; failures throw instead.
+ * @throws BackendDbException if a database transaction is active, reading collection metadata
+ * fails, the write-ahead log cannot be flushed into the database before copying it, or creating
+ * the backup's temporary compatibility database fails.
+ * @throws BackendIoException if the collection file or backup directory cannot be read,
+ * or backup/temporary files cannot be created or written, for example because of missing
+ * permissions or insufficient disk space.
+ * @throws BackendException if a previous unawaited backup failed. Its original exception is
+ * rethrown before attempting another backup.
*/
@LibAnkiAlias("create_backup")
fun createBackup(
@@ -441,7 +455,10 @@ class Collection(
* failed, and the status has not yet been checked. On failure, an error is only returned
* once; subsequent calls are a no-op until another backup is run.
*
- * @throws Exception if backup creation failed, no-op after first throw
+ * @throws BackendException propagates the backup task's original exception, such as
+ * [BackendIoException] if the backup archive cannot be written or the backup directory cannot
+ * be read, or [BackendDbException] if creating the temporary compatibility database fails.
+ * The failure is reported only once; subsequent calls are a no-op until another backup runs.
*/
@LibAnkiAlias("await_backup_completion")
fun awaitBackupCompletion() {
From e140c334516642a825b02304f31ef4cae2756f3a Mon Sep 17 00:00:00 2001
From: AnkiDroid Translations
Date: Wed, 30 Sep 2026 12:40:14 +0000
Subject: [PATCH 027/150] Updated strings from Crowdin
---
AnkiDroid/src/main/res/values-af/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-am/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-ar/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-az/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-be/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-bg/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-bn/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-ca/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-ckb/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-cs/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-da/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-de/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-el/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-eo/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-es-rAR/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-es-rES/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-et/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-eu/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-fa/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-fi/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-fil/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-fr/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-fy/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-ga/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-gl/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-got/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-gu/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-heb/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-hi/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-hr/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-hu/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-hy/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-ind/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-it/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-iw/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-ja/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-ka/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-kk/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-km/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-kn/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-ko/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-ku/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-ky/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-lt/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-lv/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-mk/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-ml/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-mn/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-mr/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-ms/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-my/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-nl/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-nn/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-no/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-or/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-pa/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-pl/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-pt-rBR/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-pt-rPT/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-ro/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-ru/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-sat/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-sc/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-sk/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-sk/10-preferences.xml | 2 +-
AnkiDroid/src/main/res/values-sl/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-sq/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-sr/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-sv/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-ta/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-te/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-tgl/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-th/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-ti/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-tr/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-tt/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-ug/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-uk/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-ur/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-uz/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-vi/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-zh-rCN/02-strings.xml | 2 ++
AnkiDroid/src/main/res/values-zh-rTW/02-strings.xml | 2 ++
83 files changed, 165 insertions(+), 1 deletion(-)
diff --git a/AnkiDroid/src/main/res/values-af/02-strings.xml b/AnkiDroid/src/main/res/values-af/02-strings.xml
index a44df437282d..261623e198c9 100644
--- a/AnkiDroid/src/main/res/values-af/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-af/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar item
Voer HTML in om voor en na die geselekteerde teks ingevoeg te word\n\nDruk lank op \'n nutsbalk-item om dit te wysig of te verwyder
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
Die beeld is te groot, voeg asseblief die beeld handmatig in
Die videolêer is te groot, voeg asseblief die video handmatig in
Die klanklêer is te groot, voeg asseblief die klank handmatig in
diff --git a/AnkiDroid/src/main/res/values-am/02-strings.xml b/AnkiDroid/src/main/res/values-am/02-strings.xml
index 7cd1529bac88..2a672af3a941 100644
--- a/AnkiDroid/src/main/res/values-am/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-am/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
diff --git a/AnkiDroid/src/main/res/values-ar/02-strings.xml b/AnkiDroid/src/main/res/values-ar/02-strings.xml
index bf13435d2bed..f47706ee4a39 100644
--- a/AnkiDroid/src/main/res/values-ar/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-ar/02-strings.xml
@@ -248,6 +248,8 @@
تعديل الأداة
أدخل الـ«HTML» الذي سيُدرج قبل النص المحدد وبعده\n\nإضغط ضغطة مطولة على هذه الأداة في شريط الأدوات لتعديلها أو حذفها
أتود حذف هذه الأداة؟
+ Button text is required
+ At least one HTML field is required
الصورة كبيرة جدًا للصق، يرجى إدخال الصورة يدويًا
مِلَفّ الفيديو كبير جدًا، يرجى إدخال الفيديو يدويًا
المِلَفّ الصوتي كبير جدًا، يرجى إدخال الصوت يدويًا
diff --git a/AnkiDroid/src/main/res/values-az/02-strings.xml b/AnkiDroid/src/main/res/values-az/02-strings.xml
index 1db0a8126b84..d61ca4798ebd 100644
--- a/AnkiDroid/src/main/res/values-az/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-az/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
diff --git a/AnkiDroid/src/main/res/values-be/02-strings.xml b/AnkiDroid/src/main/res/values-be/02-strings.xml
index 8c0904fe2553..564eaf2a4146 100644
--- a/AnkiDroid/src/main/res/values-be/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-be/02-strings.xml
@@ -240,6 +240,8 @@
Рэдагаваць элемент панэлі інструментаў
Увядзіце HTML, які будзе ўстаўлены да і пасля вылучанага тэксту\n\nДоўгі націск на элемент панэлі інструментаў дазволіць адрэдагаваць або выдаліць яго
Выдаліць элемент панэлі інструментаў?
+ Button text is required
+ At least one HTML field is required
Занадта вялікі відарыс, паспрабуйце ўставіць відарыс уручную
Занадта вялікі відэафайл, паспрабуйце ўставіць відэа ўручную
Занадта вялікі аўдыяфайл, паспрабуйце ўставіць аўдыя ўручную
diff --git a/AnkiDroid/src/main/res/values-bg/02-strings.xml b/AnkiDroid/src/main/res/values-bg/02-strings.xml
index b37a2379eb2c..22c4f3aa375e 100644
--- a/AnkiDroid/src/main/res/values-bg/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-bg/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
diff --git a/AnkiDroid/src/main/res/values-bn/02-strings.xml b/AnkiDroid/src/main/res/values-bn/02-strings.xml
index f565bc9d97bc..b6fb3ccc3454 100644
--- a/AnkiDroid/src/main/res/values-bn/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-bn/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar item
নির্বাচিত পাঠ্যের আগে এবং পরে সন্নিবেশ করতে HTML লিখুন\n\nএকটি টুলবার আইটেম সম্পাদনা বা সরাতে দীর্ঘক্ষণ টিপুন
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
ছবি খুবই বড়, অনুগ্রহ করে ম্যানুয়ালি ছবি ঢোকান
ভিডিও ফাইলটি খুব বড়, দয়া করে ভিডিওটি ম্যানুয়ালি ঢোকান৷
অডিও ফাইলটি খুব বড়, অনুগ্রহ করে ম্যানুয়ালি অডিও ঢোকান৷
diff --git a/AnkiDroid/src/main/res/values-ca/02-strings.xml b/AnkiDroid/src/main/res/values-ca/02-strings.xml
index 165d72ef7e86..3d21db375160 100644
--- a/AnkiDroid/src/main/res/values-ca/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-ca/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar item
Entreu el codi HTML que voleu inserir abans i després del text seleccionat\n\nFeu una pressió prolongada a un element de la barra d\'eines per editar-ho o eliminar-hp
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
La imatge és massa gran per enganxar-la. Inseriu-la manualment si us plau
El vídeo és massa gran per enganxar-lo. Inseriu-lo manualment si us plau
L\'àudio és massa gran per enganxar-lo. Inseriu-lo manualment si us plau
diff --git a/AnkiDroid/src/main/res/values-ckb/02-strings.xml b/AnkiDroid/src/main/res/values-ckb/02-strings.xml
index 33f5940d12b4..278804d8e4f4 100644
--- a/AnkiDroid/src/main/res/values-ckb/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-ckb/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
diff --git a/AnkiDroid/src/main/res/values-cs/02-strings.xml b/AnkiDroid/src/main/res/values-cs/02-strings.xml
index 5b8e11d5cfce..44955879ea60 100644
--- a/AnkiDroid/src/main/res/values-cs/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-cs/02-strings.xml
@@ -242,6 +242,8 @@
Upravit položku nástrojové lišty
Zadejte HTML, které se vloží před a za vybraný text\n\nDlouze stiskněte položku nástrojové lišty, abyste ji upravili nebo odstranili
Odstranit položku nástrojové lišty?
+ Button text is required
+ At least one HTML field is required
Obrázek je příliš velký, vložte ho prosím ručně
Video je příliš velké, vložte ho prosím ručně
Zvukový soubor je příliš velký, vložte ho prosím ručně
diff --git a/AnkiDroid/src/main/res/values-da/02-strings.xml b/AnkiDroid/src/main/res/values-da/02-strings.xml
index 936a6155699c..731b81435f0a 100644
--- a/AnkiDroid/src/main/res/values-da/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-da/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
diff --git a/AnkiDroid/src/main/res/values-de/02-strings.xml b/AnkiDroid/src/main/res/values-de/02-strings.xml
index 811b0f258d0b..0c51e069f932 100644
--- a/AnkiDroid/src/main/res/values-de/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-de/02-strings.xml
@@ -236,6 +236,8 @@
Werkzeugleisten Element bearbeiten
Geben Sie den HTML-Code ein, der vor und nach dem ausgewählten Text eingefügt werden soll\n\nDrücken Sie lange auf ein Symbol in der Symbolleiste, um es zu entfernen
Werkzeugleisten Element entfernen?
+ Button text is required
+ At least one HTML field is required
Das Bild ist zu groß, bitte fügen Sie das Bild manuell ein
Die Videodatei ist zu groß, bitte fügen Sie das Video manuell ein
Die Audiodatei ist zu groß, bitte fügen Sie das Audio manuell ein
diff --git a/AnkiDroid/src/main/res/values-el/02-strings.xml b/AnkiDroid/src/main/res/values-el/02-strings.xml
index 6553ae9f7e50..019eb84ae639 100644
--- a/AnkiDroid/src/main/res/values-el/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-el/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar item
Εισάγετε το HTML που θα εισαχθεί πριν και μετά το επιλεγμένο κείμενο\n\nΠατήστε παρατεταμένα ένα αντικείμενο γραμμής εργαλείων για να το επεξεργαστείτε ή να το αφαιρέσετε
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
Η εικόνα είναι πολύ μεγάλη, παρακαλώ εισάγετε την εικόνα χειροκίνητα
Το αρχείο βίντεο είναι πολύ μεγάλο, παρακαλώ εισάγετε το βίντεο χειροκίνητα
Το αρχείο ήχου είναι πολύ μεγάλο, παρακαλώ εισάγετε ήχο χειροκίνητα
diff --git a/AnkiDroid/src/main/res/values-eo/02-strings.xml b/AnkiDroid/src/main/res/values-eo/02-strings.xml
index e869fb40e474..579b76a0f522 100644
--- a/AnkiDroid/src/main/res/values-eo/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-eo/02-strings.xml
@@ -236,6 +236,8 @@
Redakti elementon de ilarstrio
Enigu HTML-kodon, kiu estos aldonita antaŭ kaj post la elektita teksto\n\nFrapetadu elementon de ilarstrio por redakti aŭ forigi ĝin
Ĉu forigi elementon de ilarstrio?
+ Button text is required
+ At least one HTML field is required
La bildo estas tro granda, enmetu la bildon permane
La filmeto estas tro granda enmetu la filmeton permane
La sonaĵo estas tro granda, enmetu la sonaĵon permane
diff --git a/AnkiDroid/src/main/res/values-es-rAR/02-strings.xml b/AnkiDroid/src/main/res/values-es-rAR/02-strings.xml
index 11f86b3de489..fc9d85fb2c93 100644
--- a/AnkiDroid/src/main/res/values-es-rAR/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-es-rAR/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar item
Introduce HTML para insertarse antes y después del texto seleccionado\n\nManten presionado un elemento de la barra de herramientas para eliminarlo
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
La imagen es demasiado grande, por favor inserta la imagen manualmente
El archivo de vídeo es demasiado grande, por favor inserta el vídeo manualmente
El archivo de audio es demasiado grande, por favor inserta el audio manualmente
diff --git a/AnkiDroid/src/main/res/values-es-rES/02-strings.xml b/AnkiDroid/src/main/res/values-es-rES/02-strings.xml
index 627a810f8c43..b394b9a2d36b 100644
--- a/AnkiDroid/src/main/res/values-es-rES/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-es-rES/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar item
Introduce HTML para insertarse antes y después del texto seleccionado\n\nManten presionado un elemento de la barra de herramientas para eliminarlo
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
La imagen es demasiado grande, por favor inserta la imagen manualmente
El archivo de vídeo es demasiado grande, por favor inserta el vídeo manualmente
El archivo de audio es demasiado grande, por favor inserta el audio manualmente
diff --git a/AnkiDroid/src/main/res/values-et/02-strings.xml b/AnkiDroid/src/main/res/values-et/02-strings.xml
index 889cd3fcacee..7139698c8473 100644
--- a/AnkiDroid/src/main/res/values-et/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-et/02-strings.xml
@@ -236,6 +236,8 @@
Muuda tööriista kirjet
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Kas eemaldada tööriista kirje?
+ Button text is required
+ At least one HTML field is required
Pildifail on liiga suur. Palun sisesta pilt käsitsi
Videofail on liiga suur. Palun sisesta video käsitsi
Helifail on liiga suur. Palun sisesta heli käsitsi
diff --git a/AnkiDroid/src/main/res/values-eu/02-strings.xml b/AnkiDroid/src/main/res/values-eu/02-strings.xml
index bc4e7ee9a0ef..62f42a24c345 100644
--- a/AnkiDroid/src/main/res/values-eu/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-eu/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
diff --git a/AnkiDroid/src/main/res/values-fa/02-strings.xml b/AnkiDroid/src/main/res/values-fa/02-strings.xml
index 4e84ddf873e1..36c8987ba1d3 100644
--- a/AnkiDroid/src/main/res/values-fa/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-fa/02-strings.xml
@@ -238,6 +238,8 @@
ویرایش مورد در نوار ابزار
کد HTMLای که میخواهید قبل و بعد نوشتهتان قرار بگیرد را وارد کنید\n\nبرای ویرایش یا حذف هریک از دکمههای نوارابزار، آنرا نگه دارید
آیا گزینه در نوار ابزار حذف شود؟
+ Button text is required
+ At least one HTML field is required
عکس زیادی بزرگ است، لطفا عکس را دستی وارد کنید
فایل ویدیو زیادی بزرگ است، لطفا ویدیو را دستی وارد کنید
فایل صدا زیادی بزرگ است، لطفا صدا را دستی وارد کنید
diff --git a/AnkiDroid/src/main/res/values-fi/02-strings.xml b/AnkiDroid/src/main/res/values-fi/02-strings.xml
index 3772ce4a602e..902bb4242f07 100644
--- a/AnkiDroid/src/main/res/values-fi/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-fi/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar item
Kirjoita HTML, joka lisätään ennen ja jälkeen valitun tekstin\n\nPaina työkalupalkin kohdetta pitkään muokataksesi sitä tai poistaaksesi sen
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
Kuva on liian suuri, aseta kuva käsin
Videotiedosto on liian suuri, aseta video käsin
Äänitiedosto on liian suuri, aseta ääni käsin
diff --git a/AnkiDroid/src/main/res/values-fil/02-strings.xml b/AnkiDroid/src/main/res/values-fil/02-strings.xml
index ef347bf4a06a..cdb2ef4a08f3 100644
--- a/AnkiDroid/src/main/res/values-fil/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-fil/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar item
Magtala ng HTML na ilalagay bago at pagkatapos ng piniling teksto\n\nIdiin ang toolbar item upang baguhin o tanggalin ito
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
Ang larawan ay masyadong malaki, pakilagay ang larawan nang mano-mano
Ang video file ay masyadong malaki, pakilagay ang video nang mano-mano
Ang audio file ay masyadong malaki, pakilagay ang audio nang mano-mano
diff --git a/AnkiDroid/src/main/res/values-fr/02-strings.xml b/AnkiDroid/src/main/res/values-fr/02-strings.xml
index 01efc1fe3024..e40e3ec6f35e 100644
--- a/AnkiDroid/src/main/res/values-fr/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-fr/02-strings.xml
@@ -236,6 +236,8 @@
Modifier un élément de la barre d’outils
Entrez le code HTML à insérer avant et après le texte sélectionné\n\nAppuyez longuement sur un élément de la barre d\'outils pour le modifier ou le supprimer
Supprimer l\'élément de la barre d\'outils ?
+ Button text is required
+ At least one HTML field is required
L’image est trop grande, veuillez insérer l’image manuellement
Le fichier vidéo est trop volumineux, insérez la vidéo manuellement.
Le fichier audio est trop volumineux, insérez l’audio manuellement.
diff --git a/AnkiDroid/src/main/res/values-fy/02-strings.xml b/AnkiDroid/src/main/res/values-fy/02-strings.xml
index 890ff67b9ce0..d68887697356 100644
--- a/AnkiDroid/src/main/res/values-fy/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-fy/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
diff --git a/AnkiDroid/src/main/res/values-ga/02-strings.xml b/AnkiDroid/src/main/res/values-ga/02-strings.xml
index 29de2baca9ba..b95c9d9c7dfb 100644
--- a/AnkiDroid/src/main/res/values-ga/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-ga/02-strings.xml
@@ -245,6 +245,8 @@
Edit toolbar item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
diff --git a/AnkiDroid/src/main/res/values-gl/02-strings.xml b/AnkiDroid/src/main/res/values-gl/02-strings.xml
index 5d09b14d71f7..c1be1ce2f0c3 100644
--- a/AnkiDroid/src/main/res/values-gl/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-gl/02-strings.xml
@@ -236,6 +236,8 @@
Editar elemento da barra de ferramentas
Insire o HTML para introducir antes e despois do texto seleccionado\n\nMantén premido un botón da barra de ferramentas para editalo ou eliminalo
Eliminar elemento da barra de ferramentas?
+ Button text is required
+ At least one HTML field is required
A imaxe é moi grande. Insire a imaxe manualmente
O ficheiro de vídeo é moi grande. Insire o vídeo manualmente
O ficheiro de son é moi grande. Insire o son manualmente
diff --git a/AnkiDroid/src/main/res/values-got/02-strings.xml b/AnkiDroid/src/main/res/values-got/02-strings.xml
index a971340f88ea..92c7763cd6ce 100644
--- a/AnkiDroid/src/main/res/values-got/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-got/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
𐍆𐍂𐌹𐍃𐌰𐌷𐍄𐍃 𐌼𐌹𐌺𐌹𐌻𐌰 𐌹𐌽 𐌿𐍆𐌰𐍂𐌰𐍃𐍃𐌰𐌿 𐌹𐍃𐍄, 𐌱𐌹𐌳𐌾𐌰𐌼 𐌸𐌿𐌺 𐌹𐌽𐌻𐌰𐌲𐌴𐌹 𐌸𐍉 𐍆𐍂𐌹𐍃𐌰𐌷𐍄 𐌼𐌰𐌽𐌽𐌰𐍅𐌰𐌿𐍂𐌷𐍄𐌰𐌱𐌰
𐌸𐌰𐍄𐌰 𐍆𐌰𐌾𐌻 𐌵𐌹𐍅𐌰𐍆𐍂𐌹𐍃𐌰𐌷𐍄𐌰𐌹𐍃 𐌿𐍆𐌰𐍂𐌼𐌹𐌺𐌹𐌻 𐌹𐍃𐍄, 𐌱𐌹𐌳𐌾𐌰𐌼 𐌸𐌿𐌺 𐌹𐌽𐌻𐌰𐌲𐌾𐌰𐌽 𐌵𐌹𐍅𐌰𐍆𐍂𐌹𐍃𐌰𐌷𐍄 𐌷𐌰𐌽𐌳𐌿𐍅𐌰𐌿𐍂𐌷𐍄𐌰𐌱𐌰
𐌸𐌰𐍄𐌰 𐍆𐌰𐌾𐌻 𐌰𐍅𐌳𐌾𐍉𐌽𐍃 𐌿𐍆𐌰𐍂𐌼𐌹𐌺𐌹𐌻 𐌹𐍃𐍄, 𐌱𐌹𐌳𐌾𐌰𐌼 𐌸𐌿𐌺 𐌹𐌽𐌻𐌰𐌲𐌾𐌰𐌽 𐌰𐍅𐌳𐌾𐍉𐌽 𐌷𐌰𐌽𐌳𐌿𐍅𐌰𐌿𐍂𐌷𐍄𐌰𐌱𐌰
diff --git a/AnkiDroid/src/main/res/values-gu/02-strings.xml b/AnkiDroid/src/main/res/values-gu/02-strings.xml
index c9fa7e32a29f..e7efa1ddc0b4 100644
--- a/AnkiDroid/src/main/res/values-gu/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-gu/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar item
પસંદ કરેલ ટેક્સ્ટ પહેલાં અને પછી દાખલ કરવા માટે HTML દાખલ કરો\n\nટૂલબાર આઇટમને સંપાદિત કરવા અથવા દૂર કરવા માટે તેને લાંબા સમય સુધી દબાવો
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
படம் மிகவும் பெரிதாக உள்ளது, படத்தை கைமுறையாக செருகவும்
வீடியோ கோப்பு மிகவும் பெரியதாக உள்ளது, வீடியோவை கைமுறையாக செருகவும்
ઓડિયો ફાઇલ ખૂબ મોટી છે, મહેરબાની કરીને ઓડિયો ફાઇલ જાતે ઉમેરો
diff --git a/AnkiDroid/src/main/res/values-heb/02-strings.xml b/AnkiDroid/src/main/res/values-heb/02-strings.xml
index eb9130b791b0..e1f175f7f064 100644
--- a/AnkiDroid/src/main/res/values-heb/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-heb/02-strings.xml
@@ -239,6 +239,8 @@
ערוך פריט מסרגל הכלים
הזן HTML שיוכנס לפני ואחרי הטקסט שנבחר \n\n לחץ לחיצה ארוכה על פריט בסרגל הכלים כדי לערוך או להסיר אותו
מחק פריו מסרגל הכלים
+ Button text is required
+ At least one HTML field is required
התמונה גדולה מדי, אנא הכנס אותה באופן ידני
קובץ הווידאו גדול מדי, אנא הכנס אותו באופן ידני
קובץ השמע גדול מדי, אנא הכנס אותו באופן ידני
diff --git a/AnkiDroid/src/main/res/values-hi/02-strings.xml b/AnkiDroid/src/main/res/values-hi/02-strings.xml
index a974dbadff0f..f3e01bf5f69f 100644
--- a/AnkiDroid/src/main/res/values-hi/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-hi/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar item
चयनित टेक्स्ट के पहले और बाद में डालने के लिए हटम्ल दर्ज करें\n\nकिसी टूलबार आइटम को संपादित करने या हटाने के लिए उसे देर तक दबाएँ.
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
छवि बहुत बड़ी है, कृपया छवि को मैन्युअल रूप से डालें
वीडियो फ़ाइल बहुत बड़ी है, कृपया वीडियो को मैन्युअल रूप से डालें
ऑडियो फ़ाइल बहुत बड़ी है, कृपया ऑडियो मैन्युअल रूप से डालें
diff --git a/AnkiDroid/src/main/res/values-hr/02-strings.xml b/AnkiDroid/src/main/res/values-hr/02-strings.xml
index 5386f5e9dd6d..7d2ca0715ec4 100644
--- a/AnkiDroid/src/main/res/values-hr/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-hr/02-strings.xml
@@ -239,6 +239,8 @@
Edit toolbar item
Unesi HTML koji će biti umetnut ispred i iza odabranog teksta\n\nPritisnute dugo na stavku alatne trake kako biste je uredili ili uklonili
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
Slika je prevelika, umetnite sliku ručno
Video datoteka je prevelika, umetnite video ručno
Zvučna datoteka je prevelika, umetnite zvuk ručno
diff --git a/AnkiDroid/src/main/res/values-hu/02-strings.xml b/AnkiDroid/src/main/res/values-hu/02-strings.xml
index 774f21451d64..7ca6b92729a4 100644
--- a/AnkiDroid/src/main/res/values-hu/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-hu/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar item
Add meg a HTML-t, amit a kiválasztott szöveg elé és mögé beszúr. \n\nHosszan nyomd az eszköztár elemét szerkesztéshez vagy törléshez
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
diff --git a/AnkiDroid/src/main/res/values-hy/02-strings.xml b/AnkiDroid/src/main/res/values-hy/02-strings.xml
index 98bda81b1161..cba770174fd6 100644
--- a/AnkiDroid/src/main/res/values-hy/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-hy/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
diff --git a/AnkiDroid/src/main/res/values-ind/02-strings.xml b/AnkiDroid/src/main/res/values-ind/02-strings.xml
index 9b807cbe8f90..f8a3f7f4359e 100644
--- a/AnkiDroid/src/main/res/values-ind/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-ind/02-strings.xml
@@ -233,6 +233,8 @@
Edit toolbar item
Masukkan HTML untuk dimasukkan sebelum dan sesudah teks\n\n yang telah dipilih. Tekan toolbar item beberapa saat untuk mencopotnya.
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
Gambar ini terlalu besar, mohon masukkan gambar secara manual
File video terlalu besar, mohon masukkan video secara manual
File audio terlalu besar, mohon masukkan audio secara manual
diff --git a/AnkiDroid/src/main/res/values-it/02-strings.xml b/AnkiDroid/src/main/res/values-it/02-strings.xml
index 273546ac4ee7..d1946b7349e4 100644
--- a/AnkiDroid/src/main/res/values-it/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-it/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar item
Inserisci l\'HTML da inserire prima e dopo il testo selezionato\n\nTieni premuto un elemento della barra per modificarlo o rimuoverlo
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
L\'immagine è troppo grande, per favore inserisci l\'immagine manualmente
Il file video è troppo grande, per favore inserisci il video manualmente.
Il file audio è troppo grande, inserisci manualmente il file audio.
diff --git a/AnkiDroid/src/main/res/values-iw/02-strings.xml b/AnkiDroid/src/main/res/values-iw/02-strings.xml
index eb9130b791b0..e1f175f7f064 100644
--- a/AnkiDroid/src/main/res/values-iw/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-iw/02-strings.xml
@@ -239,6 +239,8 @@
ערוך פריט מסרגל הכלים
הזן HTML שיוכנס לפני ואחרי הטקסט שנבחר \n\n לחץ לחיצה ארוכה על פריט בסרגל הכלים כדי לערוך או להסיר אותו
מחק פריו מסרגל הכלים
+ Button text is required
+ At least one HTML field is required
התמונה גדולה מדי, אנא הכנס אותה באופן ידני
קובץ הווידאו גדול מדי, אנא הכנס אותו באופן ידני
קובץ השמע גדול מדי, אנא הכנס אותו באופן ידני
diff --git a/AnkiDroid/src/main/res/values-ja/02-strings.xml b/AnkiDroid/src/main/res/values-ja/02-strings.xml
index 8d809ce29318..81c1bbc3a70e 100644
--- a/AnkiDroid/src/main/res/values-ja/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-ja/02-strings.xml
@@ -233,6 +233,8 @@
アイテムを編集
選択した文字列の前後に挿入するHTMLをそれぞれ入力してください\n\n作成したツールバーアイテムを編集または削除したい場合は、そのアイテムのボタンを長押ししてください
このツールバーアイテムを削除しますか?
+ Button text is required
+ At least one HTML field is required
画像が大きすぎます。手動で画像を挿入してください
動画ファイルが大きすぎます。手動で動画を挿入してください
音声ファイルが大きすぎます。手動で音声を挿入してください
diff --git a/AnkiDroid/src/main/res/values-ka/02-strings.xml b/AnkiDroid/src/main/res/values-ka/02-strings.xml
index 93d4595cf4dd..e6c80444694e 100644
--- a/AnkiDroid/src/main/res/values-ka/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-ka/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar item
შეიყვანეთ მონიშნული ტექსტის ორივე ბოლოს ჩასასმელი HTML-კოდი\n\nხელსაწყოთა ზოლს დიდი ხნით დააჭირეთ, რათა დაარედაქტიროთ ან წაშალოთ
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
სურათი ზედმეტად დიდია. გთხოვთ, სურათი ხელით ჩაამატოთ
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
diff --git a/AnkiDroid/src/main/res/values-kk/02-strings.xml b/AnkiDroid/src/main/res/values-kk/02-strings.xml
index 7cd1529bac88..2a672af3a941 100644
--- a/AnkiDroid/src/main/res/values-kk/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-kk/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
diff --git a/AnkiDroid/src/main/res/values-km/02-strings.xml b/AnkiDroid/src/main/res/values-km/02-strings.xml
index 4b89245b4767..e4fd1c86cfe9 100644
--- a/AnkiDroid/src/main/res/values-km/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-km/02-strings.xml
@@ -233,6 +233,8 @@
Edit toolbar item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
diff --git a/AnkiDroid/src/main/res/values-kn/02-strings.xml b/AnkiDroid/src/main/res/values-kn/02-strings.xml
index 21ffb6088742..999213869c5e 100644
--- a/AnkiDroid/src/main/res/values-kn/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-kn/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar item
ಆಯ್ಕೆಮಾಡಿದ ಪಠ್ಯದ ಮೊದಲು ಮತ್ತು ನಂತರ ಸೇರಿಸಲು HTML ಅನ್ನು ನಮೂದಿಸಿ\n\nಎಡಿಟ್ ಮಾಡಲು ಅಥವಾ ತೆಗೆದುಹಾಕಲು ಟೂಲ್ಬಾರ್ ಐಟಂ ಅನ್ನು ದೀರ್ಘವಾಗಿ ಒತ್ತಿರಿ
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
diff --git a/AnkiDroid/src/main/res/values-ko/02-strings.xml b/AnkiDroid/src/main/res/values-ko/02-strings.xml
index b338bd892886..846e628d1bee 100644
--- a/AnkiDroid/src/main/res/values-ko/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-ko/02-strings.xml
@@ -234,6 +234,8 @@
도구 모음 항목 편집
선택된 텍스트 앞뒤로 삽입할 HTML을 입력해주십시오\n\n도구 모음 항목을 길게 눌러 편집 또는 제거합니다.
도구 모음에서 제거할까요?
+ Button text is required
+ At least one HTML field is required
이미지가 너무 큽니다. 수동으로 넣어 주세요.
영상 파일이 너무 큽니다. 수동으로 넣어 주세요.
오디오 파일이 너무 큽니다. 수동으로 넣어 주세요.
diff --git a/AnkiDroid/src/main/res/values-ku/02-strings.xml b/AnkiDroid/src/main/res/values-ku/02-strings.xml
index 12c2a8eabfe9..430c0d016e44 100644
--- a/AnkiDroid/src/main/res/values-ku/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-ku/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
diff --git a/AnkiDroid/src/main/res/values-ky/02-strings.xml b/AnkiDroid/src/main/res/values-ky/02-strings.xml
index 7cd1529bac88..2a672af3a941 100644
--- a/AnkiDroid/src/main/res/values-ky/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-ky/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
diff --git a/AnkiDroid/src/main/res/values-lt/02-strings.xml b/AnkiDroid/src/main/res/values-lt/02-strings.xml
index aed5c19b0ea6..b930077df03f 100644
--- a/AnkiDroid/src/main/res/values-lt/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-lt/02-strings.xml
@@ -242,6 +242,8 @@
Edit toolbar item
Įveskite HTML, kuris bus įterptas prieš ir po pasirinkto teksto\n\nSpauskite įrankių juostos elementą, kad jį redaguotumėte arba pašalintumėte
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
Vaizdas yra per didelis, įdėkite jį rankiniu būdu
Vaizdo failas yra per didelis, įdėkite vaizdo įrašą rankiniu būdu
Garso failas yra per didelis, įdėkite garso įrašą rankiniu būdu
diff --git a/AnkiDroid/src/main/res/values-lv/02-strings.xml b/AnkiDroid/src/main/res/values-lv/02-strings.xml
index 02408933b3e0..e0a438582d53 100644
--- a/AnkiDroid/src/main/res/values-lv/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-lv/02-strings.xml
@@ -239,6 +239,8 @@
Edit toolbar item
Jāievada HTML, ko ievietot pirms un pēc iezīmētā teksta\n\nRīkjoslas vienums ilgi jāpiespiež, lai to labotu vai noņemtu
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
Attēls ir pārāk liels, lūgums pašrocīgi ievietot attēlu
Video datne ir pārāk liela, lūgums pašrocīgi ievietot video
Skaņu datne ir pārāk liela, lūgums ievietot skaņu pašrocīgi
diff --git a/AnkiDroid/src/main/res/values-mk/02-strings.xml b/AnkiDroid/src/main/res/values-mk/02-strings.xml
index 1a4017fd3ea6..bc96b45c585b 100644
--- a/AnkiDroid/src/main/res/values-mk/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-mk/02-strings.xml
@@ -237,6 +237,8 @@
Edit toolbar item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
diff --git a/AnkiDroid/src/main/res/values-ml/02-strings.xml b/AnkiDroid/src/main/res/values-ml/02-strings.xml
index e002ffe7a571..3f571db7314c 100644
--- a/AnkiDroid/src/main/res/values-ml/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-ml/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar item
തിരഞ്ഞെടുത്ത വാചകത്തിന് മുമ്പും ശേഷവും ചേർക്കുന്നതിന് HTML നൽകുക\n\nഒരു ടൂൾബാർ ഇനം എഡിറ്റ് ചെയ്യാനോ നീക്കം ചെയ്യാനോ ദീർഘനേരം അമർത്തുക
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
ചിത്രം വളരെ വലുതാണ്, ചിത്രം നേരിട്ട് ചേർക്കുക
വീഡിയോ ഫയൽ വളരെ വലുതാണ്, വീഡിയോ നേരിട്ട് ചേർക്കുക
ഓഡിയോ ഫയൽ വളരെ വലുതാണ്, ഓഡിയോ നേരിട്ട് ചേർക്കുക
diff --git a/AnkiDroid/src/main/res/values-mn/02-strings.xml b/AnkiDroid/src/main/res/values-mn/02-strings.xml
index 09f15e4e5b40..54c40c1364fc 100644
--- a/AnkiDroid/src/main/res/values-mn/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-mn/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
diff --git a/AnkiDroid/src/main/res/values-mr/02-strings.xml b/AnkiDroid/src/main/res/values-mr/02-strings.xml
index 1090a2965493..fecace54e0b5 100644
--- a/AnkiDroid/src/main/res/values-mr/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-mr/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar item
निवडलेल्या मजकुराच्या आधी आणि नंतर घालण्यासाठी HTML प्रविष्ट करा\n\nटूलबार आयटम संपादित करण्यासाठी किंवा काढण्यासाठी बराच वेळ दाबा
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
diff --git a/AnkiDroid/src/main/res/values-ms/02-strings.xml b/AnkiDroid/src/main/res/values-ms/02-strings.xml
index 4706a2b4a32b..3ec34ac8e53d 100644
--- a/AnkiDroid/src/main/res/values-ms/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-ms/02-strings.xml
@@ -233,6 +233,8 @@
Edit toolbar item
Masukkan HTML sebelum dan selepas pilihan teks\n\nTekan lama item bar alat untuk sunting atau buangnya
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
diff --git a/AnkiDroid/src/main/res/values-my/02-strings.xml b/AnkiDroid/src/main/res/values-my/02-strings.xml
index 18d3e307047a..f34779a56d40 100644
--- a/AnkiDroid/src/main/res/values-my/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-my/02-strings.xml
@@ -233,6 +233,8 @@
Edit toolbar item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
diff --git a/AnkiDroid/src/main/res/values-nl/02-strings.xml b/AnkiDroid/src/main/res/values-nl/02-strings.xml
index 33edd2304344..0416496aa691 100644
--- a/AnkiDroid/src/main/res/values-nl/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-nl/02-strings.xml
@@ -236,6 +236,8 @@
Pas toolbar item aan
Voer HTML in die voor en na de geselecteerde tekst moet worden ingevoegd.\n\nDruk lang op een item op de werkbalk om het te bewerken of te verwijderen.
Toolbar item verwijderen?
+ Button text is required
+ At least one HTML field is required
De afbeelding is te groot, voeg de afbeelding handmatig in
Het videobestand is te groot, voeg de video handmatig in
Het audiobestand is te groot, voeg de audio handmatig in
diff --git a/AnkiDroid/src/main/res/values-nn/02-strings.xml b/AnkiDroid/src/main/res/values-nn/02-strings.xml
index c2318ca8d5f2..80d6326b7ba5 100644
--- a/AnkiDroid/src/main/res/values-nn/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-nn/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
diff --git a/AnkiDroid/src/main/res/values-no/02-strings.xml b/AnkiDroid/src/main/res/values-no/02-strings.xml
index 7a46131dc929..08832c16036e 100644
--- a/AnkiDroid/src/main/res/values-no/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-no/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
diff --git a/AnkiDroid/src/main/res/values-or/02-strings.xml b/AnkiDroid/src/main/res/values-or/02-strings.xml
index 0616e0566827..53e3d26bd087 100644
--- a/AnkiDroid/src/main/res/values-or/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-or/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar item
ମନୋନୀତ ପାଠ୍ୟ ପୂର୍ବରୁ ଏବଂ ପରେ ସନ୍ନିବେଶ କରିବା ଲାଗି HTML ପ୍ରବେଶ କରନ୍ତୁ\n\nଗୋଟିଏ ଉପକରଣ-ଦଣ୍ଡିକାର ଆଇଟମ୍ କୁ ସମ୍ପାଦନ ବା ହଟାଇବା ପାଇଁ ତା\' ଉପରେ ଲମ୍ବା ସ୍ପର୍ଶ କରନ୍ତୁ
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
ଵିଡ଼ିଓ ଫାଇଲ୍ଟି ଅତ୍ୟଧିକ ବଡ଼ ଅଟେ, ଦୟାକରି ହସ୍ତକୃତ ଭାବେ ଵିଡ଼ିଓଟିକୁ ସନ୍ନିବେଶ କରନ୍ତୁ
ଅଡ଼ିଓ ଫାଇଲ୍ଟି ଅତ୍ୟଧିକ ବଡ଼ ଅଟେ, ଦୟାକରି ହସ୍ତକୃତ ଭାବେ ଅଡ଼ିଓଟିକୁ ସନ୍ନିବେଶ କରନ୍ତୁ
diff --git a/AnkiDroid/src/main/res/values-pa/02-strings.xml b/AnkiDroid/src/main/res/values-pa/02-strings.xml
index b72bd15de596..29d2f3938da8 100644
--- a/AnkiDroid/src/main/res/values-pa/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-pa/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar item
ਚੁਣੇ ਗਏ ਟੈਕਸਟ ਤੋਂ ਪਹਿਲਾਂ ਅਤੇ ਬਾਅਦ ਵਿੱਚ ਸੰਮਿਲਿਤ ਕਰਨ ਲਈ HTML ਦਾਖਲ ਕਰੋ\n\nਟੂਲਬਾਰ ਆਈਟਮ ਨੂੰ ਸੰਪਾਦਿਤ ਕਰਨ ਜਾਂ ਹਟਾਉਣ ਲਈ ਦੇਰ ਤੱਕ ਦਬਾਓ
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
ਚਿੱਤਰ ਬਹੁਤ ਵੱਡਾ ਹੈ, ਕਿਰਪਾ ਕਰਕੇ ਚਿੱਤਰ ਨੂੰ ਹੱਥੀਂ ਪਾਓ
ਵੀਡੀਓ ਫਾਈਲ ਬਹੁਤ ਵੱਡੀ ਹੈ, ਕਿਰਪਾ ਕਰਕੇ ਹੱਥੀਂ ਵੀਡੀਓ ਪਾਓ
ਆਡੀਓ ਫ਼ਾਈਲ ਬਹੁਤ ਵੱਡੀ ਹੈ, ਕਿਰਪਾ ਕਰਕੇ ਆਡੀਓ ਨੂੰ ਹੱਥੀਂ ਪਾਓ
diff --git a/AnkiDroid/src/main/res/values-pl/02-strings.xml b/AnkiDroid/src/main/res/values-pl/02-strings.xml
index 3021cfceb443..1b51161176a9 100644
--- a/AnkiDroid/src/main/res/values-pl/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-pl/02-strings.xml
@@ -242,6 +242,8 @@
Edytuj element paska narzędzi
Wprowadź kod HTML, który będzie wstawiony przed i po wybranym tekście\n\nPrzytrzymaj element paska narzędzi, aby go usunąć
Usunąć element paska narzędzi?
+ Button text is required
+ At least one HTML field is required
Obraz jest zbyt duży, wstaw obraz ręcznie
Plik wideo jest za duży, wstaw go ręcznie
Plik audio jest za duży, wstaw go ręcznie
diff --git a/AnkiDroid/src/main/res/values-pt-rBR/02-strings.xml b/AnkiDroid/src/main/res/values-pt-rBR/02-strings.xml
index f885a929159a..36433026e8b9 100644
--- a/AnkiDroid/src/main/res/values-pt-rBR/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-pt-rBR/02-strings.xml
@@ -236,6 +236,8 @@
Editar item na Barra de Ferramentas
Insira o código HTML que deve ser inserido antes e depois do texto selecionado\n\nMantenha o item pressionado para editá-lo ou removê-lo
Remover item na Barra de Ferramentas
+ Button text is required
+ At least one HTML field is required
A imagem é muito grande, por favor insira a imagem manualmente
O arquivo de vídeo é muito grande, por favor insira o vídeo manualmente
O arquivo de áudio é muito grande, por favor insira o áudio manualmente
diff --git a/AnkiDroid/src/main/res/values-pt-rPT/02-strings.xml b/AnkiDroid/src/main/res/values-pt-rPT/02-strings.xml
index 83bc465127f8..77fa5d5a7d0c 100644
--- a/AnkiDroid/src/main/res/values-pt-rPT/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-pt-rPT/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar item
Introduza o HTML a ser inserido antes e depois do texto seleccionado.\n\nPressione durante algum tempo sobre uma das entradas na Barra de Ferramentas para a editar ou remover.
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
A imagem é demasiado grande, por favor insira-a manualmente.
O vídeo é demasiado grande, por favor insira-o manualmente.
O ficheiro de áudio é demasiado grande, por favor insira-o manualmente.
diff --git a/AnkiDroid/src/main/res/values-ro/02-strings.xml b/AnkiDroid/src/main/res/values-ro/02-strings.xml
index f18e08142d12..2abdbb085fd9 100644
--- a/AnkiDroid/src/main/res/values-ro/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-ro/02-strings.xml
@@ -239,6 +239,8 @@
Edit toolbar item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
diff --git a/AnkiDroid/src/main/res/values-ru/02-strings.xml b/AnkiDroid/src/main/res/values-ru/02-strings.xml
index 895e268a3adf..725bb5acbbe8 100644
--- a/AnkiDroid/src/main/res/values-ru/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-ru/02-strings.xml
@@ -242,6 +242,8 @@
Редактировать элемент панели
Введите HTML, который будет вставлен до и после выделенного текста.\n\nНажмите и удерживайте элемент панели инструментов, чтобы изменить или удалить его.
Удалить элемент панели инструментов?
+ Button text is required
+ At least one HTML field is required
Изображение слишком большое. Вставьте изображение вручную
Видео слишком большое. Вставьте видео вручную
Аудио слишком большое. Вставьте аудио вручную
diff --git a/AnkiDroid/src/main/res/values-sat/02-strings.xml b/AnkiDroid/src/main/res/values-sat/02-strings.xml
index 709e5e7e3f11..5193f64a6ae8 100644
--- a/AnkiDroid/src/main/res/values-sat/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-sat/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar item
ᱵᱟᱪᱷᱟᱣ ᱟᱠᱟᱱ ᱴᱮᱠᱥᱴ ᱨᱮᱱᱟᱜ ᱯᱩᱭᱞᱩ ᱟᱨ ᱛᱟᱭᱚᱢ ᱨᱮ ᱥᱮᱞᱮᱫᱚᱜ ᱞᱟᱹᱜᱤᱫ HTML ᱮᱢᱢᱢᱮ\n\nᱥᱟᱯᱲᱟᱣ ᱥᱮ ᱦᱮᱡ ᱞᱟᱹᱜᱤᱫ ᱢᱤᱫ ᱴᱩᱞᱵᱟᱨ ᱮᱢᱮᱰᱤᱴ ᱟᱹᱰᱤ ᱫᱷᱟᱣ ᱞᱤᱱ ᱢᱮ
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
ᱪᱤᱛᱟᱹᱨ ᱫᱚ ᱟᱹᱰᱤ ᱢᱟᱨᱟᱝ ᱠᱟᱱᱟ, ᱫᱟᱭᱟ ᱠᱟᱛᱮ ᱪᱤᱛᱟᱹᱨ ᱥᱮᱞᱮᱫ ᱢᱮ
ᱵᱷᱤᱰᱤᱭᱳ ᱯᱷᱤᱞᱤᱢ ᱫᱚ ᱟᱹᱰᱤ ᱢᱟᱨᱟᱝ ᱠᱟᱱᱟ, ᱫᱟᱭᱟᱠᱟᱛᱮ ᱵᱷᱤᱰᱤᱭᱳ ᱥᱮᱞᱮᱫᱽ ᱢᱮ
ᱚᱰᱤᱭᱚ ᱯᱷᱤᱞ ᱫᱚ ᱟᱹᱰᱤ ᱢᱟᱨᱟᱝ ᱠᱟᱱᱟ, ᱫᱟᱭᱟ ᱠᱟᱛᱮ ᱚᱰᱤᱭᱚ ᱥᱮᱞᱮᱫ ᱢᱮ
diff --git a/AnkiDroid/src/main/res/values-sc/02-strings.xml b/AnkiDroid/src/main/res/values-sc/02-strings.xml
index 4810d605b88b..caf1cd87bbd8 100644
--- a/AnkiDroid/src/main/res/values-sc/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-sc/02-strings.xml
@@ -238,6 +238,8 @@
Edit toolbar item
Inserta s\'HTML de pònnere in antis e a pustis de su testu seletzionadu\n\nIncarca a longu in un\'elementu de sa barra de sas ainas pro lu modificare o bogare
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
S\'immàgine est tropu manna, inserta·la in manera manuale
Custu documentu vìdeu est tropu mannu, inserta·lu in manera manuale
Custu documentu àudio est tropu mannu, inserta·lu in manera manuale
diff --git a/AnkiDroid/src/main/res/values-sk/02-strings.xml b/AnkiDroid/src/main/res/values-sk/02-strings.xml
index faabb2d0419b..0f95af5a76d6 100644
--- a/AnkiDroid/src/main/res/values-sk/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-sk/02-strings.xml
@@ -242,6 +242,8 @@
Upraviť položku lišty nástrojov
Vložte HTML, ktorý má byť pred a po označenom texte\n\nPodržte dlho položku nástrojovej lišty na jej úpravu alebo vymazanie
Vymazať položku lišty nástrojov?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
Video súbor je príliš veľký, prosím vložte video ručne.
The audio file is too large, please insert the audio manually
diff --git a/AnkiDroid/src/main/res/values-sk/10-preferences.xml b/AnkiDroid/src/main/res/values-sk/10-preferences.xml
index dd484891b10a..a82e34972dd3 100644
--- a/AnkiDroid/src/main/res/values-sk/10-preferences.xml
+++ b/AnkiDroid/src/main/res/values-sk/10-preferences.xml
@@ -57,7 +57,7 @@
Security: Unsafe JavaScript call blocked.\nIf you trust all cards and templates in your collection, this can be unblocked in the advanced settings
Povoliť kartičkám spúšťanie iných aplikácií
- Kartičky môžu otvoriť iné aplikácia vo vašom zariadení, potencionálne spúšťajúc nechcenú aktivitu.\nPonechajte vypnuté s výnimkou že veríte všetkým kartičkám a šablónam vo vašej kolekcii.
+ Kartičky môžu otvoriť iné aplikácie vo vašom zariadení, potencionálne spúšťajúc nechcenú aktivitu.\nPonechajte vypnuté s výnimkou že veríte všetkým kartičkám a šablónam vo vašej kolekcii.
Security: Card blocked from launching another app.\nIf you trust all cards and templates in your collection, this can be unblocked in the advanced settings
Hrúbka ťahu
diff --git a/AnkiDroid/src/main/res/values-sl/02-strings.xml b/AnkiDroid/src/main/res/values-sl/02-strings.xml
index 04f6442b2bff..9f109f84c3c9 100644
--- a/AnkiDroid/src/main/res/values-sl/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-sl/02-strings.xml
@@ -242,6 +242,8 @@
Edit toolbar item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
diff --git a/AnkiDroid/src/main/res/values-sq/02-strings.xml b/AnkiDroid/src/main/res/values-sq/02-strings.xml
index f11af2156c59..92f06cd80cae 100644
--- a/AnkiDroid/src/main/res/values-sq/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-sq/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
diff --git a/AnkiDroid/src/main/res/values-sr/02-strings.xml b/AnkiDroid/src/main/res/values-sr/02-strings.xml
index 10ab206c1e13..a0afb59fb4a2 100644
--- a/AnkiDroid/src/main/res/values-sr/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-sr/02-strings.xml
@@ -239,6 +239,8 @@
Edit toolbar item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
diff --git a/AnkiDroid/src/main/res/values-sv/02-strings.xml b/AnkiDroid/src/main/res/values-sv/02-strings.xml
index 322ce619c611..df62c1bfca75 100644
--- a/AnkiDroid/src/main/res/values-sv/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-sv/02-strings.xml
@@ -236,6 +236,8 @@
Redigera objekt i verktygsfältet
Ange HTML som ska infogas före och efter den markerade texten\n\nTryck länge på ett verktyg för att redigera eller ta bort det
Ta bort objekt från verktygsfältet?
+ Button text is required
+ At least one HTML field is required
Bilden är för stor, lägg till bilden manuellt
Videofilen är för stor, lägg till videon manuellt
Ljudfilen är för stor, lägg till ljudet manuellt
diff --git a/AnkiDroid/src/main/res/values-ta/02-strings.xml b/AnkiDroid/src/main/res/values-ta/02-strings.xml
index 0e13bf8e9bb9..3db4d5f85e0b 100644
--- a/AnkiDroid/src/main/res/values-ta/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-ta/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar item
தேர்ந்தெடுக்கப்பட்ட உரைக்கு முன்னும் பின்னும் செருகுவதற்கு HTML ஐ உள்ளிடவும்\n\n கருவிப்பட்டி உருப்படியைத் திருத்த அல்லது அகற்ற நீண்ட நேரம் அழுத்தவும்
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
படம் மிகவும் பெரிதாக உள்ளது, படத்தை கைமுறையாக செருகவும்
வீடியோ கோப்பு மிகவும் பெரியதாக உள்ளது, வீடியோவை கைமுறையாக செருகவும்
ஆடியோ கோப்பு மிகவும் பெரியதாக உள்ளது, ஆடியோவை கைமுறையாக செருகவும்
diff --git a/AnkiDroid/src/main/res/values-te/02-strings.xml b/AnkiDroid/src/main/res/values-te/02-strings.xml
index ba57be640244..f17e8dd7ce73 100644
--- a/AnkiDroid/src/main/res/values-te/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-te/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar item
ఎంచుకున్న వచనానికి ముందు మరియు తర్వాత ఇన్సర్ట్ చేయడానికి HTMLని నమోదు చేయండి\n\nటూల్బార్ అంశాన్ని సవరించడానికి లేదా తీసివేయడానికి ఎక్కువసేపు నొక్కండి
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
చిత్రం చాలా పెద్దది, దయచేసి చిత్రాన్ని మాన్యువల్గా చొప్పించండి
వీడియో ఫైల్ చాలా పెద్దది, దయచేసి వీడియోని మాన్యువల్గా చొప్పించండి
ఆడియో ఫైల్ చాలా పెద్దది, దయచేసి ఆడియోను మాన్యువల్గా చొప్పించండి
diff --git a/AnkiDroid/src/main/res/values-tgl/02-strings.xml b/AnkiDroid/src/main/res/values-tgl/02-strings.xml
index 7728d7f1ccb7..945cfc848056 100644
--- a/AnkiDroid/src/main/res/values-tgl/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-tgl/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
diff --git a/AnkiDroid/src/main/res/values-th/02-strings.xml b/AnkiDroid/src/main/res/values-th/02-strings.xml
index 2a564772ac6e..ea7fbdd5b01e 100644
--- a/AnkiDroid/src/main/res/values-th/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-th/02-strings.xml
@@ -233,6 +233,8 @@
Edit toolbar item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
diff --git a/AnkiDroid/src/main/res/values-ti/02-strings.xml b/AnkiDroid/src/main/res/values-ti/02-strings.xml
index 7cd1529bac88..2a672af3a941 100644
--- a/AnkiDroid/src/main/res/values-ti/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-ti/02-strings.xml
@@ -236,6 +236,8 @@
Edit toolbar item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
diff --git a/AnkiDroid/src/main/res/values-tr/02-strings.xml b/AnkiDroid/src/main/res/values-tr/02-strings.xml
index ce5a8fd3b101..b095e064da79 100644
--- a/AnkiDroid/src/main/res/values-tr/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-tr/02-strings.xml
@@ -236,6 +236,8 @@
Araç çubuğu öğesini düzenle
Seçili yazıdan önce ve sonra eklenecek HTML girin\n\nBir araç çubuğu öğesini değiştirmek ya da kaldırmak için basılı tutun
Araç çubuğu öğesi kaldırılsın mı?
+ Button text is required
+ At least one HTML field is required
Bu resim fazla büyük, lütfen resmi elle ekleyin
Bu video dosyası fazla büyük, lütfen videoyu elle ekleyin
Bu ses dosyası fazla büyük, lütfen sesi elle ekleyin
diff --git a/AnkiDroid/src/main/res/values-tt/02-strings.xml b/AnkiDroid/src/main/res/values-tt/02-strings.xml
index 96876eb71813..7b3122614b7e 100644
--- a/AnkiDroid/src/main/res/values-tt/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-tt/02-strings.xml
@@ -234,6 +234,8 @@
Edit toolbar item
Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
The image is too large, please insert the image manually
The video file is too large, please insert the video manually
The audio file is too large, please insert the audio manually
diff --git a/AnkiDroid/src/main/res/values-ug/02-strings.xml b/AnkiDroid/src/main/res/values-ug/02-strings.xml
index a7b1617eabc4..d32588885df8 100644
--- a/AnkiDroid/src/main/res/values-ug/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-ug/02-strings.xml
@@ -236,6 +236,8 @@
قورال بالداق تۈرىنى تەھرىر
تاللانغان تېكىستتىن ئىلگىرى ۋە كېيىن قىستۇرىدىغان HTML كىرگۈزۈلىدۇ\n\nقورال بالداق ئۇزۇن بېسىلسا ئۇنى تەھرىرلىگىلى ياكى چىقىرىۋەتكىلى بولىدۇ
قورال بالداق تۈرىنى چىقىرىۋېتەمدۇ؟
+ Button text is required
+ At least one HTML field is required
سۈرەت بەك چوڭ، سۈرەتنى قولدا قىستۇرۇڭ
سىن ھۆججىتى بەك چوڭ، سىننى قولدا قىستۇرۇڭ
ئۈن ھۆججىتى بەك چوڭ، ئۈننى قولدا قىستۇرۇڭ
diff --git a/AnkiDroid/src/main/res/values-uk/02-strings.xml b/AnkiDroid/src/main/res/values-uk/02-strings.xml
index 35034b83cd25..c0ddcd6b5884 100644
--- a/AnkiDroid/src/main/res/values-uk/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-uk/02-strings.xml
@@ -242,6 +242,8 @@
Редагувати елемент панелі інструментів
Введіть HTML, який буде вставлений до і після виділеного тексту\n\nДовго натискайте на елемент панелі інструментів, щоб його редагувати або видалити
Видалити елемент панелі інструментів?
+ Button text is required
+ At least one HTML field is required
Зображення завелике, будь ласка, вставте зображення вручну
Відеофайл завеликий, будь ласка, вставте відео вручну
Аудіофайл завеликий, будь ласка, вставте аудіо вручну
diff --git a/AnkiDroid/src/main/res/values-ur/02-strings.xml b/AnkiDroid/src/main/res/values-ur/02-strings.xml
index 9d053df4b36a..0a454a447052 100644
--- a/AnkiDroid/src/main/res/values-ur/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-ur/02-strings.xml
@@ -237,6 +237,8 @@
Edit toolbar item
منتخب کردہ متن سے پہلے اور بعد میں داخل کرنے کے لیے HTML درج کریں\n\nٹول بار آئٹم میں ترمیم کرنے یا اسے ہٹانے کے لیے دیر تک دبائیں
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
تصویر بہت بڑی ہے، براہ کرم دستی طور پر تصویر داخل کریں۔
ویڈیو فائل بہت بڑی ہے، براہ کرم ویڈیو کو دستی طور پر داخل کریں۔
آڈیو فائل بہت بڑی ہے، براہ کرم آڈیو کو دستی طور پر داخل کریں۔
diff --git a/AnkiDroid/src/main/res/values-uz/02-strings.xml b/AnkiDroid/src/main/res/values-uz/02-strings.xml
index 5b779bc38ada..56e43c6015bf 100644
--- a/AnkiDroid/src/main/res/values-uz/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-uz/02-strings.xml
@@ -236,6 +236,8 @@
Asboblar paneli elementini tahrirlash
Tanlangan matndan oldin va keyin kiritiladigan HTMLʼni kiriting\n\nUni tahrirlash yoki olib tashlash uchun asboblar paneli elementini uzoq vaqt bosib turing.
Asboblar paneli elementi olib tashlansinmi?
+ Button text is required
+ At least one HTML field is required
Rasm juda katta, rasmni qoʻlda kiriting
Video juda katta, videoni qoʻlda kiriting
Audio fayl juda katta, audio faylni qoʻlda kiriting
diff --git a/AnkiDroid/src/main/res/values-vi/02-strings.xml b/AnkiDroid/src/main/res/values-vi/02-strings.xml
index cc3ed11f69e8..c961199d458b 100644
--- a/AnkiDroid/src/main/res/values-vi/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-vi/02-strings.xml
@@ -233,6 +233,8 @@
Edit toolbar item
Nhập HTML sẽ được chèn vào trước và sau văn bản đã chọn\n\nNhấn và giữ một mục trên thanh công cụ để chỉnh sửa hoặc xóa nó
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
Hình ảnh quá lớn, vui lòng chèn hình ảnh theo cách thủ công
Tập tin video quá lớn, vui lòng chèn video theo cách thủ công
Tệp âm thanh quá lớn, vui lòng chèn âm thanh thủ công
diff --git a/AnkiDroid/src/main/res/values-zh-rCN/02-strings.xml b/AnkiDroid/src/main/res/values-zh-rCN/02-strings.xml
index 6501ecb6deae..9393732689e8 100644
--- a/AnkiDroid/src/main/res/values-zh-rCN/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-zh-rCN/02-strings.xml
@@ -233,6 +233,8 @@
编辑工具栏项目
输入要在选定文本之前和之后插入的 HTML\n\n 长按工具栏项目来编辑或删除它
删除工具栏项目?
+ Button text is required
+ At least one HTML field is required
图片过大,请手动插入图像
视频文件太大,请手动插入视频
音频文件太大,请手动插入音频
diff --git a/AnkiDroid/src/main/res/values-zh-rTW/02-strings.xml b/AnkiDroid/src/main/res/values-zh-rTW/02-strings.xml
index 923f7ada4718..3f078fc5f09e 100644
--- a/AnkiDroid/src/main/res/values-zh-rTW/02-strings.xml
+++ b/AnkiDroid/src/main/res/values-zh-rTW/02-strings.xml
@@ -233,6 +233,8 @@
Edit toolbar item
請在選取文字的前後出入要添加的HTML碼\n\n長按工具列項目以編輯或移除
Remove toolbar item?
+ Button text is required
+ At least one HTML field is required
此圖像太大,無法貼上,請手動插入
影片太大,無法貼上,請手動插入
此音訊檔太大,無法貼上,請手動插入
From edf4bd2b1fcde1941c0422f44868cbf5e20ba566 Mon Sep 17 00:00:00 2001
From: David Allison <62114487+david-allison@users.noreply.github.com>
Date: Tue, 29 Sep 2026 20:28:33 +0100
Subject: [PATCH 028/150] fix(backups): make selection cancellation consistent
* Cancel returns to the deck list
* Recovery options are preserved if we restore from a DB error
Fixes 22186
Assisted-by: GPT-6
---
.../ichi2/anki/dialogs/DatabaseErrorDialog.kt | 19 +-----
.../BackupSelectionCancellationTest.kt | 61 +++++++++++++++++++
2 files changed, 63 insertions(+), 17 deletions(-)
create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/dialogs/BackupSelectionCancellationTest.kt
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DatabaseErrorDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DatabaseErrorDialog.kt
index 1d8934aaa624..ce92f5375bfc 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DatabaseErrorDialog.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DatabaseErrorDialog.kt
@@ -8,7 +8,6 @@ import android.app.Dialog
import android.os.Bundle
import android.os.Message
import android.os.Parcelable
-import androidx.activity.addCallback
import androidx.annotation.CheckResult
import androidx.annotation.StringRes
import androidx.appcompat.app.AlertDialog
@@ -336,6 +335,8 @@ class DatabaseErrorDialog : AsyncDialogFragment() {
title(CommonString.restore_backup_title)
message(text = message)
positiveButton(CommonString.dialog_continue) {
+ // Cancelling backup selection should return to the deck list, not this warning.
+ dismiss()
showDatabaseErrorDialog(DIALOG_RESTORE_BACKUP)
}
negativeButton(CommonString.dialog_cancel)
@@ -420,22 +421,6 @@ class DatabaseErrorDialog : AsyncDialogFragment() {
}
}
- override fun setupDialog(
- dialog: Dialog,
- style: Int,
- ) {
- super.setupDialog(dialog, style)
-
- if (requireDialogType() == DIALOG_RESTORE_BACKUP) {
- // we don't want to go back to DIALOG_CONFIRM_RESTORE_BACKUP if back is pressed
- // instead, close all dialogs and return to the DeckPicker
- (dialog as AlertDialog).onBackPressedDispatcher.addCallback(this, true) {
- Timber.i("DIALOG_RESTORE_BACKUP caught hardware back button")
- requireActivity().dismissAllDialogFragments()
- }
- }
- }
-
/** @see DeckPicker.showDatabaseErrorDialog */
private fun showDatabaseErrorDialog(errorDialogType: DatabaseErrorDialogType) {
requireDeckPicker().showDatabaseErrorDialog(errorDialogType, exceptionData)
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/BackupSelectionCancellationTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/BackupSelectionCancellationTest.kt
new file mode 100644
index 000000000000..edf81c679a0b
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/BackupSelectionCancellationTest.kt
@@ -0,0 +1,61 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.dialogs
+
+import android.content.DialogInterface
+import androidx.appcompat.app.AlertDialog
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.ichi2.anki.BackupManager
+import com.ichi2.anki.CollectionManager
+import com.ichi2.anki.CommonString
+import com.ichi2.anki.DeckPicker
+import com.ichi2.anki.RobolectricTest
+import com.ichi2.anki.common.storage.CollectionHelper
+import com.ichi2.anki.dialogs.DatabaseErrorDialog.DatabaseErrorDialogType
+import com.ichi2.anki.dialogs.utils.performPositiveClick
+import com.ichi2.anki.dialogs.utils.title
+import com.ichi2.anki.utils.ext.DIALOG_FRAGMENT_TAG
+import com.ichi2.anki.withDeckPicker
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.io.File
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+@RunWith(AndroidJUnit4::class)
+class BackupSelectionCancellationTest : RobolectricTest() {
+ @Test
+ fun `Cancel returns to the deck list without reopening the warning`() =
+ withDeckPicker(deckCount = 0) { activity ->
+ createBackup()
+ activity.showDatabaseErrorDialog(DatabaseErrorDialogType.DIALOG_CONFIRM_RESTORE_BACKUP)
+ activity.currentDialog().performPositiveClick()
+
+ activity.currentDialog().getButton(DialogInterface.BUTTON_NEGATIVE).performClick()
+ advanceRobolectricLooper()
+
+ assertNull(activity.supportFragmentManager.findFragmentByTag(DIALOG_FRAGMENT_TAG))
+ }
+
+ @Test
+ fun `Back returns to recovery options`() =
+ withDeckPicker(deckCount = 0) { activity ->
+ createBackup()
+ CollectionManager.closeCollectionBlocking()
+ activity.showDatabaseErrorDialog(DatabaseErrorDialogType.DIALOG_ERROR_HANDLING)
+ activity.showDatabaseErrorDialog(DatabaseErrorDialogType.DIALOG_RESTORE_BACKUP)
+
+ activity.currentDialog().onBackPressedDispatcher.onBackPressed()
+ advanceRobolectricLooper()
+
+ assertEquals(getResourceString(CommonString.error_handling_title), activity.currentDialog().title)
+ }
+
+ private fun createBackup() {
+ val directory = BackupManager.getBackupDirectory(CollectionHelper.getCurrentAnkiDroidDirectory(targetContext))
+ File(directory, "backup-2026-09-29-12.00.00.colpkg").writeText("backup")
+ }
+
+ private fun DeckPicker.currentDialog(): AlertDialog =
+ (supportFragmentManager.findFragmentByTag(DIALOG_FRAGMENT_TAG) as DatabaseErrorDialog).requireDialog() as AlertDialog
+}
From 5ae799df9de2393217719025fbc13aedbb10f901 Mon Sep 17 00:00:00 2001
From: Brayan Oliveira <69634269+brayandso@users.noreply.github.com>
Date: Sat, 19 Sep 2026 12:05:02 -0300
Subject: [PATCH 029/150] refactor(whiteboard): unify active tool state
Assisted-by: Gemini Flash 3.8
---
.../reviewer/whiteboard/BrushAdapter.kt | 11 +-
.../reviewer/whiteboard/WhiteboardFragment.kt | 65 +++----
.../whiteboard/WhiteboardRepository.kt | 27 +--
.../reviewer/whiteboard/WhiteboardToolbar.kt | 2 +-
.../reviewer/whiteboard/WhiteboardView.kt | 39 ++--
.../whiteboard/WhiteboardViewModel.kt | 176 +++++++++++-------
.../whiteboard/WhiteboardFragmentTest.kt | 39 ++++
.../whiteboard/WhiteboardViewModelTest.kt | 131 +++++++++++++
.../reviewer/whiteboard/WhiteboardViewTest.kt | 39 ++++
9 files changed, 377 insertions(+), 152 deletions(-)
create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardViewModelTest.kt
create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardViewTest.kt
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/BrushAdapter.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/BrushAdapter.kt
index 8ce3947d3eae..b4dfbc49723e 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/BrushAdapter.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/BrushAdapter.kt
@@ -22,12 +22,12 @@ class BrushAdapter(
private val onBrushClick: (View, Int) -> Unit,
private val onBrushLongClick: (Int) -> Unit,
) : RecyclerView.Adapter() {
- private var brushes: List = emptyList()
+ private var brushes: List = emptyList()
private var activeIndex: Int = -1
private var isEraserActive: Boolean = false
fun updateData(
- newBrushes: List,
+ newBrushes: List,
newActiveIndex: Int,
eraserActive: Boolean,
) {
@@ -41,12 +41,15 @@ class BrushAdapter(
newActiveIndex: Int,
eraserActive: Boolean,
) {
+ if (activeIndex == newActiveIndex && isEraserActive == eraserActive) return
val oldIndex = activeIndex
activeIndex = newActiveIndex
isEraserActive = eraserActive
if (oldIndex in brushes.indices) notifyItemChanged(oldIndex)
- if (newActiveIndex in brushes.indices) notifyItemChanged(newActiveIndex)
+ if (newActiveIndex in brushes.indices && newActiveIndex != oldIndex) {
+ notifyItemChanged(newActiveIndex)
+ }
}
override fun getItemCount(): Int = brushes.size
@@ -74,7 +77,7 @@ class BrushAdapter(
private val button: MaterialButton = itemView as MaterialButton
fun bind(
- brush: BrushInfo,
+ brush: WhiteboardTool.Brush,
isSelected: Boolean,
) = button.apply {
isCheckable = true
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardFragment.kt
index 4315da56bc67..8c9653e2cafb 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardFragment.kt
@@ -153,7 +153,7 @@ class WhiteboardFragment :
toolbar.undoButton.setOnClickListener { viewModel.undo() }
toolbar.redoButton.setOnClickListener { viewModel.redo() }
toolbar.eraserButton.setOnClickListener {
- if (viewModel.isEraserActive.value) {
+ if (viewModel.activeTool.value is WhiteboardTool.Eraser) {
toolbar.eraserButton.isChecked = true
if (eraserPopup?.isShowing == true) {
eraserPopup?.dismiss()
@@ -166,7 +166,7 @@ class WhiteboardFragment :
}
toolbar.onBrushClick = { view, index ->
- if (viewModel.activeBrushIndex.value == index && !viewModel.isEraserActive.value) {
+ if (viewModel.activeBrushIndex.value == index && viewModel.activeTool.value !is WhiteboardTool.Eraser) {
showBrushConfigurationPopup(view, index)
} else {
viewModel.setActiveBrush(index)
@@ -223,39 +223,25 @@ class WhiteboardFragment :
viewModel.paths.onEach(whiteboardView::setHistory).launchIn(viewLifecycleOwner.lifecycleScope)
- combine(
- viewModel.brushColor,
- viewModel.activeStrokeWidth,
- ) { color, width ->
- whiteboardView.setCurrentBrush(color, width)
- }.launchIn(viewLifecycleOwner.lifecycleScope)
-
- combine(
- viewModel.isEraserActive,
- viewModel.eraserMode,
- viewModel.eraserDisplayWidth,
- ) { isActive, mode, width ->
- whiteboardView.isEraserActive = isActive
- toolbar.eraserButton.updateState(isActive, mode, width)
- whiteboardView.eraserMode = mode
- if (!isActive) {
- eraserPopup?.dismiss()
- }
- }.launchIn(viewLifecycleOwner.lifecycleScope)
-
- viewModel.brushes
- .onEach { brushesInfo ->
- toolbar.setBrushes(brushesInfo, viewModel.activeBrushIndex.value, viewModel.isEraserActive.value)
+ viewModel.activeTool
+ .onEach { tool ->
+ whiteboardView.activeTool = tool
+ val isEraser = tool is WhiteboardTool.Eraser
+ toolbar.updateSelection(viewModel.activeBrushIndex.value, isEraser)
+ toolbar.eraserButton.updateState(isEraser, viewModel.eraser.mode, viewModel.eraser.width)
+ if (!isEraser) {
+ eraserPopup?.dismiss()
+ }
}.launchIn(viewLifecycleOwner.lifecycleScope)
viewModel.activeBrushIndex
- .onEach {
- toolbar.updateSelection(it, viewModel.isEraserActive.value)
+ .onEach { index ->
+ toolbar.updateSelection(index, viewModel.activeTool.value is WhiteboardTool.Eraser)
}.launchIn(viewLifecycleOwner.lifecycleScope)
- viewModel.isEraserActive
- .onEach {
- toolbar.updateSelection(viewModel.activeBrushIndex.value, it)
+ viewModel.brushes
+ .onEach { brushesInfo ->
+ toolbar.setBrushes(brushesInfo, viewModel.activeBrushIndex.value, viewModel.activeTool.value is WhiteboardTool.Eraser)
}.launchIn(viewLifecycleOwner.lifecycleScope)
viewModel.isStylusOnlyMode
@@ -284,7 +270,7 @@ class WhiteboardFragment :
*/
private fun showAddColorDialog() {
requireContext()
- .showColorPickerDialog(viewModel.brushColor.value) { color ->
+ .showColorPickerDialog(viewModel.currentBrushColor) { color ->
Timber.i("Added brush with color ${color.toRGBAHex()}")
viewModel.addBrush(color)
}
@@ -375,7 +361,7 @@ class WhiteboardFragment :
*/
private fun showChangeColorDialog() {
requireContext()
- .showColorPickerDialog(viewModel.brushColor.value) { color ->
+ .showColorPickerDialog(viewModel.currentBrushColor) { color ->
viewModel.updateBrushColor(color)
brushConfigPopup?.dismiss()
}
@@ -388,16 +374,16 @@ class WhiteboardFragment :
val inflater = LayoutInflater.from(requireContext())
val eraserWidthBinding = PopupEraserOptionsBinding.inflate(inflater)
- eraserWidthBinding.eraserWidthSlider.value = viewModel.eraserDisplayWidth.value
+ eraserWidthBinding.eraserWidthSlider.value = viewModel.eraser.width
eraserWidthBinding.eraserWidthSlider.addOnChangeListener { _, value, fromUser ->
- if (fromUser) viewModel.setActiveStrokeWidth(value)
+ if (fromUser) viewModel.setEraserStrokeWidth(value)
}
eraserWidthBinding.eraserWidthSlider.setLabelFormatter { value: Float ->
value.roundToInt().toString()
}
eraserWidthBinding.eraserModeToggleGroup.clearOnButtonCheckedListeners()
- when (viewModel.eraserMode.value) {
+ when (viewModel.eraser.mode) {
EraserMode.STROKE -> eraserWidthBinding.eraserModeToggleGroup.check(R.id.eraser_mode_stroke)
EraserMode.INK -> eraserWidthBinding.eraserModeToggleGroup.check(R.id.eraser_mode_ink)
}
@@ -406,14 +392,11 @@ class WhiteboardFragment :
when (checkedId) {
R.id.eraser_mode_stroke -> {
viewModel.setEraserMode(EraserMode.STROKE)
- eraserWidthBinding.eraserWidthSlider.value =
- viewModel.strokeEraserStrokeWidth.value
+ eraserWidthBinding.eraserWidthSlider.value = viewModel.eraser.strokeEraserWidth
}
-
R.id.eraser_mode_ink -> {
viewModel.setEraserMode(EraserMode.INK)
- eraserWidthBinding.eraserWidthSlider.value =
- viewModel.inkEraserStrokeWidth.value
+ eraserWidthBinding.eraserWidthSlider.value = viewModel.eraser.inkWidth
}
}
}
@@ -422,7 +405,7 @@ class WhiteboardFragment :
eraserPopup = PopupWindow(eraserWidthBinding.root, 280.dp.toPx(requireContext()), ViewGroup.LayoutParams.WRAP_CONTENT, true)
eraserPopup?.elevation = 8f
eraserPopup?.setOnDismissListener {
- binding.whiteboardToolbar.updateSelection(viewModel.activeBrushIndex.value, viewModel.isEraserActive.value)
+ binding.whiteboardToolbar.updateSelection(viewModel.activeBrushIndex.value, viewModel.activeTool.value is WhiteboardTool.Eraser)
eraserPopup = null
}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardRepository.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardRepository.kt
index 472d5f619f6d..d809a06c41fb 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardRepository.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardRepository.kt
@@ -5,17 +5,6 @@ package com.ichi2.anki.ui.windows.reviewer.whiteboard
import android.content.SharedPreferences
import android.graphics.Color
import androidx.core.content.edit
-import com.ichi2.utils.toRGBAHex
-
-/**
- * Holds the configuration for a single brush.
- */
-data class BrushInfo(
- val color: Int,
- val width: Float,
-) {
- override fun toString(): String = "BrushInfo(color=${color.toRGBAHex()}, width=${"%.1f".format(width)})"
-}
/**
* Repository for handling data operations, specifically for saving and loading
@@ -25,14 +14,14 @@ class WhiteboardRepository(
private val sharedPreferences: SharedPreferences,
) {
fun saveBrushes(
- brushes: List,
+ brushes: List,
isDarkMode: Boolean,
) {
val key = if (isDarkMode) KEY_BRUSHES_DARK else KEY_BRUSHES_LIGHT
sharedPreferences.edit { putString(key, brushes.toPreferenceString()) }
}
- fun loadBrushes(isDarkMode: Boolean): List {
+ fun loadBrushes(isDarkMode: Boolean): List {
val key = if (isDarkMode) KEY_BRUSHES_DARK else KEY_BRUSHES_LIGHT
val saved = sharedPreferences.getString(key, null)
return if (saved.isNullOrEmpty()) {
@@ -85,14 +74,14 @@ class WhiteboardRepository(
get() = sharedPreferences.getBoolean(KEY_IS_TOOLBAR_SHOWN, true)
set(value) = sharedPreferences.edit { putBoolean(KEY_IS_TOOLBAR_SHOWN, value) }
- private fun List.toPreferenceString(): String = this.joinToString(",") { "${it.color}|${it.width}" }
+ private fun List.toPreferenceString(): String = this.joinToString(",") { "${it.color}|${it.width}" }
- private fun String.fromPreferenceString(): List =
+ private fun String.fromPreferenceString(): List =
this.split(',').mapNotNull {
val parts = it.split('|')
if (parts.size == 2) {
try {
- BrushInfo(color = parts[0].toInt(), width = parts[1].toFloat())
+ WhiteboardTool.Brush(color = parts[0].toInt(), width = parts[1].toFloat())
} catch (_: NumberFormatException) {
null
}
@@ -115,14 +104,14 @@ class WhiteboardRepository(
const val DEFAULT_STROKE_WIDTH = 10f
const val DEFAULT_ERASER_WIDTH = 30f
- fun getDefaultBrushes(isDarkMode: Boolean): List =
+ fun getDefaultBrushes(isDarkMode: Boolean): List =
if (isDarkMode) {
listOf(
- BrushInfo(color = Color.WHITE, width = DEFAULT_STROKE_WIDTH),
+ WhiteboardTool.Brush(color = Color.WHITE, width = DEFAULT_STROKE_WIDTH),
)
} else {
listOf(
- BrushInfo(color = Color.BLACK, width = DEFAULT_STROKE_WIDTH),
+ WhiteboardTool.Brush(color = Color.BLACK, width = DEFAULT_STROKE_WIDTH),
)
}
}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardToolbar.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardToolbar.kt
index b4e33a8c8d8e..042d2d7eaf16 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardToolbar.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardToolbar.kt
@@ -142,7 +142,7 @@ class WhiteboardToolbar : LinearLayout {
* Updates the data in the RecyclerView adapter.
*/
fun setBrushes(
- brushes: List,
+ brushes: List,
activeIndex: Int,
isEraserActive: Boolean,
) {
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardView.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardView.kt
index 3ffd73d5ced7..8dfe01cc0db6 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardView.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardView.kt
@@ -30,8 +30,22 @@ class WhiteboardView : View {
var onEraseGestureStart: ((Float, Float) -> Unit)? = null
var onEraseGestureMove: ((Float, Float) -> Unit)? = null
var onEraseGestureEnd: (() -> Unit)? = null
- var isEraserActive: Boolean = false
- var eraserMode: EraserMode = EraserMode.INK
+
+ var activeTool: WhiteboardTool = WhiteboardTool.Brush(Color.BLACK, WhiteboardRepository.DEFAULT_STROKE_WIDTH)
+ set(value) {
+ field = value
+ when (value) {
+ is WhiteboardTool.Brush -> {
+ currentPaint.color = value.color
+ currentPaint.strokeWidth = value.width
+ }
+ is WhiteboardTool.Eraser -> {
+ eraserPreviewPaint.strokeWidth = value.width
+ }
+ }
+ invalidate()
+ }
+
var isStylusOnlyMode: Boolean = false
private val currentPath = SmoothPath()
@@ -97,11 +111,9 @@ class WhiteboardView : View {
// Draw the committed history
canvas.drawBitmap(bufferBitmap, 0f, 0f, canvasPaint)
- // Draw the live preview path for the current gesture
- if (isEraserActive) {
+ if (activeTool is WhiteboardTool.Eraser) {
canvas.drawPath(currentPath, eraserPreviewPaint)
} else {
- // Draw the normal brush or pixel eraser preview
canvas.drawPath(currentPath, currentPaint)
}
}
@@ -125,7 +137,7 @@ class WhiteboardView : View {
val touchX = event.x
val touchY = event.y
- val isPathEraser = isEraserActive && eraserMode == EraserMode.STROKE
+ val isPathEraser = activeTool is WhiteboardTool.Eraser && (activeTool as WhiteboardTool.Eraser).mode == EraserMode.STROKE
when (event.action) {
MotionEvent.ACTION_DOWN -> {
@@ -178,21 +190,6 @@ class WhiteboardView : View {
redrawHistory()
}
- /**
- * Configures the paint for the live drawing preview based on the current tool.
- */
- fun setCurrentBrush(
- color: Int,
- strokeWidth: Float,
- ) {
- currentPaint.strokeWidth = strokeWidth
- currentPaint.xfermode = null
- currentPaint.color = color
-
- // Configure the stroke eraser's preview paint separately
- eraserPreviewPaint.strokeWidth = strokeWidth
- }
-
/**
* Redraws all historical paths onto the offscreen buffer.
*/
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardViewModel.kt
index a20a4c7448ce..55dce05849e8 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardViewModel.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardViewModel.kt
@@ -15,9 +15,9 @@ import com.ichi2.anki.common.utils.ext.indexOfOrNull
import com.ichi2.anki.preferences.reviewer.WhiteboardAction
import com.ichi2.anki.reviewer.BindingProcessor
import com.ichi2.anki.reviewer.ReviewerBinding
+import com.ichi2.utils.toRGBAHex
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
-import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
@@ -43,6 +43,26 @@ enum class EraserMode { INK, STROKE }
*/
enum class ToolbarAlignment { LEFT, RIGHT, BOTTOM }
+sealed interface WhiteboardTool {
+ val width: Float
+
+ data class Brush(
+ val color: Int,
+ override val width: Float,
+ ) : WhiteboardTool {
+ override fun toString(): String = "Brush(color=${color.toRGBAHex()}, width=${"%.1f".format(width)})"
+ }
+
+ data class Eraser(
+ val mode: EraserMode = EraserMode.INK,
+ val inkWidth: Float = WhiteboardRepository.DEFAULT_ERASER_WIDTH,
+ val strokeEraserWidth: Float = WhiteboardRepository.DEFAULT_ERASER_WIDTH,
+ ) : WhiteboardTool {
+ override val width: Float
+ get() = if (mode == EraserMode.INK) inkWidth else strokeEraserWidth
+ }
+}
+
/**
* Represents a command that can be undone and redone.
*/
@@ -76,18 +96,21 @@ class WhiteboardViewModel(
val canUndo = undoStack.map { it.isNotEmpty() }.stateIn(viewModelScope, SharingStarted.Eagerly, false)
val canRedo = redoStack.map { it.isNotEmpty() }.stateIn(viewModelScope, SharingStarted.Eagerly, false)
- // State for brushes
- val brushes = MutableStateFlow>(emptyList())
+ val brushes = MutableStateFlow>(emptyList())
val activeBrushIndex = MutableStateFlow(0)
+ var eraser: WhiteboardTool.Eraser = WhiteboardTool.Eraser()
+ private set
+ val activeTool =
+ MutableStateFlow(
+ WhiteboardTool.Brush(Color.BLACK, WhiteboardRepository.DEFAULT_STROKE_WIDTH),
+ )
+
+ val currentBrushColor: Int
+ get() =
+ (activeTool.value as? WhiteboardTool.Brush)?.color
+ ?: brushes.value.getOrNull(activeBrushIndex.value)?.color
+ ?: Color.BLACK
- // State for the currently active tool's properties
- val inkEraserStrokeWidth = MutableStateFlow(WhiteboardRepository.DEFAULT_ERASER_WIDTH)
- val strokeEraserStrokeWidth = MutableStateFlow(WhiteboardRepository.DEFAULT_ERASER_WIDTH)
-
- val brushColor = MutableStateFlow(Color.BLACK)
- val activeStrokeWidth = MutableStateFlow(WhiteboardRepository.DEFAULT_STROKE_WIDTH)
- val isEraserActive = MutableStateFlow(false)
- val eraserMode = MutableStateFlow(EraserMode.INK)
val isStylusOnlyMode = MutableStateFlow(false)
val toolbarAlignment = MutableStateFlow(ToolbarAlignment.BOTTOM)
val isToolbarShown = MutableStateFlow(true)
@@ -99,11 +122,6 @@ class WhiteboardViewModel(
*/
val isDrawing = MutableStateFlow(false)
- val eraserDisplayWidth =
- combine(eraserMode, inkEraserStrokeWidth, strokeEraserStrokeWidth) { mode, inkWidth, strokeWidth ->
- if (mode == EraserMode.INK) inkWidth else strokeWidth
- }.stateIn(viewModelScope, SharingStarted.Eagerly, WhiteboardRepository.DEFAULT_ERASER_WIDTH)
-
private var eraserLastX = 0.0f
private var eraserLastY = 0.0f
private val pathsErasedInCurrentGesture = mutableListOf()
@@ -116,17 +134,21 @@ class WhiteboardViewModel(
fun loadState(isDarkMode: Boolean) {
this.isDarkMode = isDarkMode
brushes.value = repository.loadBrushes(isDarkMode)
- inkEraserStrokeWidth.value = repository.inkEraserWidth
- strokeEraserStrokeWidth.value = repository.strokeEraserWidth
- eraserMode.value = repository.eraserMode
+ eraser =
+ WhiteboardTool.Eraser(
+ mode = repository.eraserMode,
+ inkWidth = repository.inkEraserWidth,
+ strokeEraserWidth = repository.strokeEraserWidth,
+ )
isStylusOnlyMode.value = repository.stylusOnlyMode
toolbarAlignment.value = repository.toolbarAlignment
isToolbarShown.value = repository.isToolbarShown
- val lastActiveIndex = repository.loadLastActiveBrushIndex(isDarkMode)
+ val savedBrushIndex = repository.loadLastActiveBrushIndex(isDarkMode)
+ activeBrushIndex.value = savedBrushIndex
- if (!isEraserActive.value) {
- setActiveBrush(lastActiveIndex)
+ if (activeTool.value !is WhiteboardTool.Eraser) {
+ setActiveBrush(savedBrushIndex)
}
}
@@ -134,12 +156,19 @@ class WhiteboardViewModel(
* Adds a new completed path to the drawing history.
*/
fun addPath(path: Path) {
- val isPixelEraser = isEraserActive.value && eraserMode.value == EraserMode.INK
+ val tool = activeTool.value
+ val isPixelEraser = tool is WhiteboardTool.Eraser && tool.mode == EraserMode.INK
+ val strokeWidth = tool.width
+ val color =
+ when (tool) {
+ is WhiteboardTool.Brush -> tool.color
+ is WhiteboardTool.Eraser -> Color.BLACK
+ }
val newAction =
DrawingAction(
path,
- brushColor.value,
- activeStrokeWidth.value,
+ color,
+ strokeWidth,
isPixelEraser,
)
paths.update { it + newAction }
@@ -175,9 +204,11 @@ class WhiteboardViewModel(
var pathWasErased = false
val pathsToEvaluate = remainingPaths.filter { it !in pathsErasedInCurrentGesture && !it.isEraser }
+ val eraserRadius =
+ ((activeTool.value as? WhiteboardTool.Eraser)?.width ?: eraser.width) / 2
for (action in pathsToEvaluate) {
- if (isPathIntersectingWithSegment(action, x, y, eraserLastX, eraserLastY, activeStrokeWidth.value / 2)) {
+ if (isPathIntersectingWithSegment(action, x, y, eraserLastX, eraserLastY, eraserRadius)) {
remainingPaths.remove(action)
pathsErasedInCurrentGesture.add(action)
pathWasErased = true
@@ -335,46 +366,59 @@ class WhiteboardViewModel(
fun setActiveBrush(index: Int) {
val brush = brushes.value.getOrNull(index) ?: return
- isEraserActive.value = false
activeBrushIndex.value = index
repository.saveLastActiveBrushIndex(index, isDarkMode)
- brushColor.value = brush.color
- activeStrokeWidth.value = brush.width
+ activeTool.value = brush
}
/**
* Toggles the eraser tool on or off.
*/
fun enableEraser() {
- isEraserActive.value = true
- activeStrokeWidth.value = eraserDisplayWidth.value
+ activeTool.value = eraser
}
/**
* Toggles between the eraser and the last active brush.
*/
fun toggleEraser() {
- if (isEraserActive.value) {
+ if (activeTool.value is WhiteboardTool.Eraser) {
setActiveBrush(activeBrushIndex.value)
} else {
enableEraser()
}
}
+ /**
+ * Updates the eraser state and keeps the active tool in sync if it is currently an eraser.
+ */
+ private fun updateEraser(transform: (WhiteboardTool.Eraser) -> WhiteboardTool.Eraser) {
+ val updated = transform(eraser)
+ eraser = updated
+ if (activeTool.value is WhiteboardTool.Eraser) {
+ activeTool.value = updated
+ }
+ }
+
/**
* Sets the eraser mode (pixel or path).
*/
fun setEraserMode(mode: EraserMode) {
- eraserMode.value = mode
repository.eraserMode = mode
- if (isEraserActive.value) {
- activeStrokeWidth.value =
- if (mode == EraserMode.INK) {
- inkEraserStrokeWidth.value
- } else {
- strokeEraserStrokeWidth.value
- }
+ updateEraser { it.copy(mode = mode) }
+ }
+
+ /**
+ * Sets the stroke width for the eraser tool.
+ */
+ fun setEraserStrokeWidth(newWidth: Float) {
+ if (eraser.mode == EraserMode.INK) {
+ repository.inkEraserWidth = newWidth
+ updateEraser { it.copy(inkWidth = newWidth) }
+ } else {
+ repository.strokeEraserWidth = newWidth
+ updateEraser { it.copy(strokeEraserWidth = newWidth) }
}
}
@@ -382,26 +426,20 @@ class WhiteboardViewModel(
* Sets the stroke width for the currently active tool (brush or eraser).
*/
fun setActiveStrokeWidth(newWidth: Float) {
- if (isEraserActive.value) {
- if (eraserMode.value == EraserMode.INK) {
- inkEraserStrokeWidth.value = newWidth
- repository.inkEraserWidth = newWidth
- } else {
- strokeEraserStrokeWidth.value = newWidth
- repository.strokeEraserWidth = newWidth
+ when (activeTool.value) {
+ is WhiteboardTool.Eraser -> setEraserStrokeWidth(newWidth)
+ is WhiteboardTool.Brush -> {
+ val index = activeBrushIndex.value
+ val updatedBrushes = brushes.value.replaceAt(index) { it.copy(width = newWidth) }
+ brushes.value = updatedBrushes
+ repository.saveBrushes(updatedBrushes, isDarkMode)
+ activeTool.value = updatedBrushes[index]
}
- } else {
- // Update the width of the active brush
- val activeIndex = activeBrushIndex.value
- val updatedBrushes = brushes.value.replaceAt(activeIndex) { it.copy(width = newWidth) }
- brushes.value = updatedBrushes
- repository.saveBrushes(updatedBrushes, isDarkMode)
}
- activeStrokeWidth.value = newWidth
}
@CheckResult
- fun List.replaceAt(
+ private fun List.replaceAt(
index: Int,
replace: (T) -> T,
): List = this.mapIndexed { i, value -> if (i == index) replace(value) else value }
@@ -411,19 +449,21 @@ class WhiteboardViewModel(
*/
fun updateBrushColor(newColor: Int) {
Timber.i("Updating brush color to %d", newColor)
- val activeIndex = activeBrushIndex.value
- val updatedBrushes = brushes.value.replaceAt(activeIndex) { it.copy(color = newColor) }
+ val index = activeBrushIndex.value
+ val updatedBrushes = brushes.value.replaceAt(index) { it.copy(color = newColor) }
brushes.value = updatedBrushes
repository.saveBrushes(brushes.value, isDarkMode)
- brushColor.value = newColor
+ if (activeTool.value is WhiteboardTool.Brush) {
+ activeTool.value = updatedBrushes[index]
+ }
}
/**
* Adds a new brush color to the user's palette.
*/
fun addBrush(color: Int) {
- val newBrush = BrushInfo(color = color, width = WhiteboardRepository.DEFAULT_STROKE_WIDTH)
+ val newBrush = WhiteboardTool.Brush(color = color, width = WhiteboardRepository.DEFAULT_STROKE_WIDTH)
val updatedBrushes = brushes.value + newBrush
brushes.value = updatedBrushes
repository.saveBrushes(brushes.value, isDarkMode)
@@ -441,14 +481,18 @@ class WhiteboardViewModel(
brushes.value = updatedBrushes
repository.saveBrushes(brushes.value, isDarkMode)
- when {
- oldActiveIndex == indexToRemove -> {
- val newIndex = (oldActiveIndex - 1).coerceAtLeast(0)
- setActiveBrush(newIndex)
- }
- oldActiveIndex > indexToRemove -> {
- setActiveBrush(oldActiveIndex - 1)
+ val newIndex =
+ when {
+ oldActiveIndex == indexToRemove -> (oldActiveIndex - 1).coerceAtLeast(0)
+ oldActiveIndex > indexToRemove -> oldActiveIndex - 1
+ else -> oldActiveIndex
}
+
+ if (activeTool.value is WhiteboardTool.Brush) {
+ setActiveBrush(newIndex)
+ } else {
+ activeBrushIndex.value = newIndex
+ repository.saveLastActiveBrushIndex(newIndex, isDarkMode)
}
}
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardFragmentTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardFragmentTest.kt
index e51a12efb3ed..0df49f61c9d5 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardFragmentTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardFragmentTest.kt
@@ -4,13 +4,18 @@ package com.ichi2.anki.ui.windows.reviewer.whiteboard
import androidx.fragment.app.commitNow
import androidx.lifecycle.ViewModelProvider
+import androidx.recyclerview.widget.RecyclerView
import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.google.android.material.button.MaterialButton
+import com.ichi2.anki.R
import com.ichi2.anki.RobolectricTest
import com.ichi2.testutils.launchFragmentInContainer
import org.junit.Test
import org.junit.runner.RunWith
+import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNull
+import kotlin.test.assertTrue
@RunWith(AndroidJUnit4::class)
class WhiteboardFragmentTest : RobolectricTest() {
@@ -35,6 +40,40 @@ class WhiteboardFragmentTest : RobolectricTest() {
}
}
+ @Test
+ fun `selecting an identical brush updates toolbar selection`() {
+ launchFragmentInContainer().use { scenario ->
+ scenario.onFragment { fragment ->
+ val viewModel = ViewModelProvider(fragment)[WhiteboardViewModel::class.java]
+ viewModel.addBrush(viewModel.currentBrushColor)
+ assertEquals(viewModel.brushes.value[0], viewModel.brushes.value[1])
+ }
+ advanceRobolectricLooper()
+ scenario.onFragment { fragment ->
+ ViewModelProvider(fragment)[WhiteboardViewModel::class.java].setActiveBrush(0)
+ }
+ advanceRobolectricLooper()
+ scenario.onFragment { fragment ->
+ assertTrue(fragment.isBrushSelected(0), "The first brush should initially be selected")
+ ViewModelProvider(fragment)[WhiteboardViewModel::class.java].setActiveBrush(1)
+ }
+ advanceRobolectricLooper()
+ scenario.onFragment { fragment ->
+ assertTrue(fragment.isBrushSelected(1), "The second brush should now be selected")
+ assertFalse(fragment.isBrushSelected(0), "The first brush should no longer be selected")
+ }
+ }
+ }
+
+ private fun WhiteboardFragment.isBrushSelected(index: Int): Boolean {
+ val recycler = binding.whiteboardToolbar.findViewById(R.id.brush_recycler_view)
+ val adapter = recycler.adapter as BrushAdapter
+ // Bind a fresh holder so this assertion does not depend on RecyclerView having laid out.
+ val holder = adapter.createViewHolder(recycler, adapter.getItemViewType(index))
+ adapter.bindViewHolder(holder, index)
+ return (holder.itemView as MaterialButton).isChecked
+ }
+
private fun changeToolbarVisibilityBeforeDestroyingView(isShown: Boolean) {
WhiteboardRepository(getPreferences()).isToolbarShown = !isShown
var toolbarUpdated = false
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardViewModelTest.kt
new file mode 100644
index 000000000000..867d84c41423
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardViewModelTest.kt
@@ -0,0 +1,131 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.ui.windows.reviewer.whiteboard
+
+import com.github.ivanshafran.sharedpreferencesmock.SPMockBuilder
+import org.junit.Before
+import org.junit.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertIs
+import kotlin.test.assertTrue
+
+class WhiteboardViewModelTest {
+ private lateinit var repository: WhiteboardRepository
+ private lateinit var viewModel: WhiteboardViewModel
+
+ @Before
+ fun setUp() {
+ repository = WhiteboardRepository(SPMockBuilder().createSharedPreferences())
+ viewModel = WhiteboardViewModel(repository)
+ viewModel.loadState(isDarkMode = false)
+ }
+
+ @Test
+ fun `toggleEraser toggles between eraser and brush`() {
+ assertFalse(viewModel.isEraserActive)
+ val initialBrush = viewModel.activeTool.value
+ assertIs(initialBrush)
+ val initialBrushIndex = viewModel.activeBrushIndex.value
+
+ viewModel.toggleEraser()
+ assertTrue(viewModel.isEraserActive)
+ val eraserTool = viewModel.activeTool.value
+ assertIs(eraserTool)
+ assertEquals(viewModel.eraser.width, eraserTool.width)
+
+ viewModel.toggleEraser()
+ assertFalse(viewModel.isEraserActive)
+ val restoredBrush = viewModel.activeTool.value
+ assertIs(restoredBrush)
+ assertEquals(initialBrushIndex, viewModel.activeBrushIndex.value)
+ }
+
+ @Test
+ fun `setActiveStrokeWidth updates active brush or eraser depending on active tool`() {
+ viewModel.setActiveStrokeWidth(42f)
+ val activeBrush = viewModel.activeTool.value
+ assertIs(activeBrush)
+ assertEquals(42f, activeBrush.width)
+ assertEquals(42f, viewModel.brushes.value[viewModel.activeBrushIndex.value].width)
+
+ viewModel.enableEraser()
+ val eraserTool = viewModel.activeTool.value
+ assertIs(eraserTool)
+ assertEquals(viewModel.eraser.width, eraserTool.width)
+
+ viewModel.setActiveStrokeWidth(35f)
+ val updatedEraser = viewModel.activeTool.value
+ assertIs(updatedEraser)
+ assertEquals(35f, updatedEraser.width)
+ assertEquals(35f, viewModel.eraser.width)
+ assertEquals(35f, viewModel.eraser.inkWidth)
+ }
+
+ @Test
+ fun `setEraserMode updates eraser config and active tool when eraser is active`() {
+ viewModel.enableEraser()
+ viewModel.setActiveStrokeWidth(25f)
+
+ viewModel.setEraserMode(EraserMode.STROKE)
+ assertEquals(EraserMode.STROKE, viewModel.eraser.mode)
+ val eraserTool = viewModel.activeTool.value
+ assertIs(eraserTool)
+ assertEquals(EraserMode.STROKE, eraserTool.mode)
+ assertEquals(viewModel.eraser.strokeEraserWidth, eraserTool.width)
+
+ viewModel.setEraserMode(EraserMode.INK)
+ assertEquals(25f, (viewModel.activeTool.value as WhiteboardTool.Eraser).width)
+ assertEquals(25f, viewModel.eraser.inkWidth)
+ }
+
+ @Test
+ fun `setEraserStrokeWidth updates eraser config even when brush is active`() {
+ assertFalse(viewModel.isEraserActive)
+ viewModel.setEraserStrokeWidth(48f)
+ assertEquals(48f, viewModel.eraser.width)
+ assertEquals(48f, viewModel.eraser.inkWidth)
+ assertFalse(viewModel.isEraserActive)
+
+ viewModel.enableEraser()
+ assertEquals(48f, viewModel.activeTool.value.width)
+ }
+
+ @Test
+ fun `updateBrushColor updates active tool and brushes list`() {
+ viewModel.updateBrushColor(0x123456)
+ val brushTool = viewModel.activeTool.value
+ assertIs(brushTool)
+ assertEquals(0x123456, brushTool.color)
+ assertEquals(0x123456, viewModel.brushes.value[viewModel.activeBrushIndex.value].color)
+ }
+
+ @Test
+ fun `removeBrush updates palette and active brush index`() {
+ viewModel.addBrush(0x111111)
+ viewModel.addBrush(0x222222)
+ val lastIndex = viewModel.brushes.value.lastIndex
+ viewModel.setActiveBrush(lastIndex)
+ assertEquals(lastIndex, viewModel.activeBrushIndex.value)
+
+ viewModel.removeBrush(lastIndex)
+ val newBrush = viewModel.activeTool.value
+ assertIs(newBrush)
+ assertEquals(lastIndex - 1, viewModel.activeBrushIndex.value)
+ }
+
+ @Test
+ fun `currentBrushColor returns active brush color or fallback`() {
+ val initialBrushColor = (viewModel.activeTool.value as WhiteboardTool.Brush).color
+ assertEquals(initialBrushColor, viewModel.currentBrushColor)
+
+ viewModel.updateBrushColor(0x123456)
+ assertEquals(0x123456, viewModel.currentBrushColor)
+
+ viewModel.enableEraser()
+ assertEquals(0x123456, viewModel.currentBrushColor)
+ }
+}
+
+private val WhiteboardViewModel.isEraserActive: Boolean
+ get() = activeTool.value is WhiteboardTool.Eraser
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardViewTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardViewTest.kt
new file mode 100644
index 000000000000..725dae6dc74a
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardViewTest.kt
@@ -0,0 +1,39 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.ui.windows.reviewer.whiteboard
+
+import android.graphics.Color
+import androidx.test.core.app.ApplicationProvider
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import kotlin.test.assertEquals
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+@RunWith(AndroidJUnit4::class)
+class WhiteboardViewTest {
+ private lateinit var whiteboardView: WhiteboardView
+
+ @Before
+ fun setUp() {
+ whiteboardView = WhiteboardView(ApplicationProvider.getApplicationContext())
+ }
+
+ @Test
+ fun `activeTool updates isEraserActive and eraserMode`() {
+ whiteboardView.activeTool = WhiteboardTool.Brush(Color.RED, 15f)
+ assertFalse(whiteboardView.isEraserActive)
+
+ whiteboardView.activeTool = WhiteboardTool.Eraser(mode = EraserMode.STROKE, strokeEraserWidth = 25f)
+ assertTrue(whiteboardView.isEraserActive)
+ assertEquals(EraserMode.STROKE, whiteboardView.eraserMode)
+ }
+}
+
+private val WhiteboardView.isEraserActive: Boolean
+ get() = activeTool is WhiteboardTool.Eraser
+
+private val WhiteboardView.eraserMode: EraserMode
+ get() = (activeTool as? WhiteboardTool.Eraser)?.mode ?: EraserMode.INK
From f9e7a8a6dccf9bd4cd8001a05715347feca3ef77 Mon Sep 17 00:00:00 2001
From: Brayan Oliveira <69634269+brayandso@users.noreply.github.com>
Date: Sat, 19 Sep 2026 11:23:05 -0300
Subject: [PATCH 030/150] feat(whiteboard): support stylus button to toggle
eraser
Assisted-by: Gemini Flash 3.8
---
.../reviewer/whiteboard/WhiteboardFragment.kt | 9 +
.../reviewer/whiteboard/WhiteboardView.kt | 150 +++++++--
.../whiteboard/WhiteboardViewModel.kt | 22 ++
.../whiteboard/WhiteboardFragmentTest.kt | 95 ++++++
.../whiteboard/WhiteboardViewModelTest.kt | 76 +++++
.../reviewer/whiteboard/WhiteboardViewTest.kt | 298 ++++++++++++++++++
6 files changed, 630 insertions(+), 20 deletions(-)
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardFragment.kt
index 8c9653e2cafb..51913866d04c 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardFragment.kt
@@ -86,10 +86,19 @@ class WhiteboardFragment :
observeViewModel(binding.whiteboardView)
setupDoubleBackPress()
+ binding.whiteboardView.isStylusButtonOverriding = viewModel.isStylusOverriding
binding.whiteboardView.onNewPath = viewModel::addPath
binding.whiteboardView.onEraseGestureStart = viewModel::startPathEraseGesture
binding.whiteboardView.onEraseGestureMove = viewModel::erasePathsToPoint
binding.whiteboardView.onEraseGestureEnd = viewModel::endPathEraseGesture
+ binding.whiteboardView.onStylusButtonStateChanged = { isPressed ->
+ viewModel.setStylusButtonPressed(isPressed)
+ binding.whiteboardView.activeTool = viewModel.activeTool.value
+ binding.whiteboardToolbar.updateSelection(
+ viewModel.activeBrushIndex.value,
+ viewModel.activeTool.value is WhiteboardTool.Eraser,
+ )
+ }
}
private fun setupDoubleBackPress() {
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardView.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardView.kt
index 8dfe01cc0db6..9bdd06431f74 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardView.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardView.kt
@@ -30,10 +30,14 @@ class WhiteboardView : View {
var onEraseGestureStart: ((Float, Float) -> Unit)? = null
var onEraseGestureMove: ((Float, Float) -> Unit)? = null
var onEraseGestureEnd: (() -> Unit)? = null
+ var onStylusButtonStateChanged: ((Boolean) -> Unit)? = null
var activeTool: WhiteboardTool = WhiteboardTool.Brush(Color.BLACK, WhiteboardRepository.DEFAULT_STROKE_WIDTH)
set(value) {
field = value
+ if (value is WhiteboardTool.Brush) {
+ isStylusButtonOverriding = false
+ }
when (value) {
is WhiteboardTool.Brush -> {
currentPaint.color = value.color
@@ -48,6 +52,13 @@ class WhiteboardView : View {
var isStylusOnlyMode: Boolean = false
+ /**
+ * Whether the stylus button is currently overriding the active [WhiteboardTool.Brush]
+ * with the eraser tool.
+ */
+ var isStylusButtonOverriding = false
+ internal set
+
private val currentPath = SmoothPath()
private val currentPaint =
Paint().apply {
@@ -131,20 +142,20 @@ class WhiteboardView : View {
return multiTouchDetector.onTouchEvent(event)
}
- if (isStylusOnlyMode && event.getToolType(0) != MotionEvent.TOOL_TYPE_STYLUS) {
+ if (isStylusOnlyMode && !event.isStylus) {
return false
}
val touchX = event.x
val touchY = event.y
- val isPathEraser = activeTool is WhiteboardTool.Eraser && (activeTool as WhiteboardTool.Eraser).mode == EraserMode.STROKE
- when (event.action) {
+ when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
+ updateStylusButtonState(event)
isDrawing = true
hasMoved = false
currentPath.moveTo(touchX, touchY)
- if (isPathEraser) {
+ if (isStrokeEraser) {
onEraseGestureStart?.invoke(touchX, touchY)
}
invalidate()
@@ -152,29 +163,48 @@ class WhiteboardView : View {
MotionEvent.ACTION_MOVE -> {
if (!isDrawing) return false
- hasMoved = true
- currentPath.drawAlong(event)
- if (isPathEraser) {
- onEraseGestureMove?.invoke(touchX, touchY)
+ val isButtonPressed = event.hasStylusButtonPressed()
+ if (isButtonPressed != isStylusButtonOverriding && isToolChange(isButtonPressed)) {
+ hasMoved = true
+ currentPath.drawAlong(event)
+ if (isStrokeEraser) {
+ onEraseGestureMove?.invoke(touchX, touchY)
+ }
+ handleMidStrokeToolChange(touchX, touchY, isButtonPressed)
+ } else {
+ hasMoved = true
+ currentPath.drawAlong(event)
+ if (isStrokeEraser) {
+ onEraseGestureMove?.invoke(touchX, touchY)
+ }
+ invalidate()
+ }
+ }
+ MotionEvent.ACTION_BUTTON_PRESS,
+ MotionEvent.ACTION_BUTTON_RELEASE,
+ -> {
+ if (!isDrawing) {
+ updateStylusButtonState(event)
+ return true
+ }
+ val isButtonPressed = event.hasStylusButtonPressed()
+ if (isButtonPressed != isStylusButtonOverriding && isToolChange(isButtonPressed)) {
+ handleMidStrokeToolChange(touchX, touchY, isButtonPressed)
}
- invalidate()
}
MotionEvent.ACTION_UP -> {
if (!isDrawing) return false
- if (isPathEraser) {
- onEraseGestureEnd?.invoke()
- } else {
- if (!hasMoved) {
- // A single tap. Add a tiny line segment to ensure it has a non-zero length,
- // which makes it more robust for path operations.
- currentPath.lineTo(touchX + 0.2f, touchY + 0.2f)
- }
- onNewPath?.invoke(currentPath.clone())
- }
- // Reset the path for the next gesture
+ commitCurrentStroke(touchX, touchY)
+ currentPath.reset()
+ isDrawing = false
+ updateStylusButtonState(event)
+ invalidate()
+ }
+ MotionEvent.ACTION_CANCEL -> {
currentPath.reset()
isDrawing = false
+ updateStylusButtonState(event)
invalidate()
}
else -> return false
@@ -182,6 +212,86 @@ class WhiteboardView : View {
return true
}
+ override fun onHoverEvent(event: MotionEvent): Boolean {
+ updateStylusButtonState(event)
+ return super.onHoverEvent(event)
+ }
+
+ private val isStrokeEraser: Boolean
+ get() = activeTool is WhiteboardTool.Eraser && (activeTool as WhiteboardTool.Eraser).mode == EraserMode.STROKE
+
+ /**
+ * Determines whether [isButtonPressed] represents a tool change.
+ *
+ * A button press is a tool change only if the active tool is a [WhiteboardTool.Brush].
+ * A button release is a tool change only if the button was actively overriding a brush.
+ */
+ private fun isToolChange(isButtonPressed: Boolean): Boolean =
+ if (isButtonPressed) {
+ activeTool is WhiteboardTool.Brush
+ } else {
+ isStylusButtonOverriding
+ }
+
+ private fun commitCurrentStroke(
+ touchX: Float,
+ touchY: Float,
+ ) {
+ if (isStrokeEraser) {
+ onEraseGestureEnd?.invoke()
+ } else {
+ if (!hasMoved) {
+ // A single tap. Add a tiny line segment to ensure it has a non-zero length,
+ // which makes it more robust for path operations.
+ currentPath.lineTo(touchX + 0.2f, touchY + 0.2f)
+ }
+ onNewPath?.invoke(currentPath.clone())
+ }
+ }
+
+ private fun handleMidStrokeToolChange(
+ touchX: Float,
+ touchY: Float,
+ isButtonPressed: Boolean,
+ ) {
+ commitCurrentStroke(touchX, touchY)
+ currentPath.reset()
+ hasMoved = false
+ currentPath.moveTo(touchX, touchY)
+
+ isStylusButtonOverriding = isButtonPressed
+ onStylusButtonStateChanged?.invoke(isButtonPressed)
+
+ if (isStrokeEraser) {
+ onEraseGestureStart?.invoke(touchX, touchY)
+ }
+ invalidate()
+ }
+
+ private val MotionEvent.isStylus: Boolean
+ get() =
+ (0 until pointerCount).any {
+ val toolType = getToolType(it)
+ toolType == MotionEvent.TOOL_TYPE_STYLUS || toolType == MotionEvent.TOOL_TYPE_ERASER
+ }
+
+ private fun MotionEvent.hasStylusButtonPressed(): Boolean {
+ if (!isStylus) return false
+ val stylusButtons =
+ MotionEvent.BUTTON_STYLUS_PRIMARY or
+ MotionEvent.BUTTON_STYLUS_SECONDARY or
+ MotionEvent.BUTTON_SECONDARY
+ return (buttonState and stylusButtons) != 0
+ }
+
+ private fun updateStylusButtonState(event: MotionEvent) {
+ val isButtonPressed = event.hasStylusButtonPressed()
+ if (isButtonPressed != isStylusButtonOverriding && isToolChange(isButtonPressed)) {
+ isStylusButtonOverriding = isButtonPressed
+ onStylusButtonStateChanged?.invoke(isButtonPressed)
+ }
+ }
+
/**
* Replaces the current drawing history with a new set of actions and redraws the buffer.
*/
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardViewModel.kt
index 55dce05849e8..846ee84c67ef 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardViewModel.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardViewModel.kt
@@ -115,6 +115,9 @@ class WhiteboardViewModel(
val toolbarAlignment = MutableStateFlow(ToolbarAlignment.BOTTOM)
val isToolbarShown = MutableStateFlow(true)
+ var isStylusOverriding = false
+ internal set
+
/**
* Whether the whiteboard is hosted in a "drawing" flow (e.g. multimedia drawing
* capture) rather than the reviewer. In drawing mode the host owns the back
@@ -366,6 +369,7 @@ class WhiteboardViewModel(
fun setActiveBrush(index: Int) {
val brush = brushes.value.getOrNull(index) ?: return
+ isStylusOverriding = false
activeBrushIndex.value = index
repository.saveLastActiveBrushIndex(index, isDarkMode)
@@ -376,6 +380,7 @@ class WhiteboardViewModel(
* Toggles the eraser tool on or off.
*/
fun enableEraser() {
+ isStylusOverriding = false
activeTool.value = eraser
}
@@ -496,6 +501,23 @@ class WhiteboardViewModel(
}
}
+ /**
+ * Enables or disables the eraser based on whether the stylus button is pressed.
+ */
+ fun setStylusButtonPressed(isPressed: Boolean) {
+ if (isPressed) {
+ if (activeTool.value is WhiteboardTool.Brush) {
+ isStylusOverriding = true
+ activeTool.value = eraser
+ }
+ } else {
+ if (isStylusOverriding) {
+ isStylusOverriding = false
+ setActiveBrush(activeBrushIndex.value)
+ }
+ }
+ }
+
/**
* Toggles the stylus-only drawing mode.
*/
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardFragmentTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardFragmentTest.kt
index 0df49f61c9d5..4432ee8605ed 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardFragmentTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardFragmentTest.kt
@@ -2,6 +2,8 @@
package com.ichi2.anki.ui.windows.reviewer.whiteboard
+import android.view.InputDevice
+import android.view.MotionEvent
import androidx.fragment.app.commitNow
import androidx.lifecycle.ViewModelProvider
import androidx.recyclerview.widget.RecyclerView
@@ -14,7 +16,9 @@ import org.junit.Test
import org.junit.runner.RunWith
import kotlin.test.assertEquals
import kotlin.test.assertFalse
+import kotlin.test.assertIs
import kotlin.test.assertNull
+import kotlin.test.assertSame
import kotlin.test.assertTrue
@RunWith(AndroidJUnit4::class)
@@ -40,6 +44,55 @@ class WhiteboardFragmentTest : RobolectricTest() {
}
}
+ @Test
+ fun `recreated view restores brush when stylus button is no longer pressed`() {
+ launchFragmentInContainer().use { scenario ->
+ lateinit var retainedViewModel: WhiteboardViewModel
+ scenario.onFragment { fragment ->
+ retainedViewModel = ViewModelProvider(fragment)[WhiteboardViewModel::class.java]
+ val view = fragment.binding.whiteboardView
+ view.dispatchStylusTouch(MotionEvent.ACTION_DOWN, MotionEvent.BUTTON_STYLUS_PRIMARY)
+ view.dispatchStylusTouch(MotionEvent.ACTION_UP, MotionEvent.BUTTON_STYLUS_PRIMARY)
+ assertIs(retainedViewModel.activeTool.value)
+ }
+
+ // The button is released while the old view is unavailable to receive the event.
+ scenario.recreate()
+ scenario.onFragment { fragment ->
+ assertSame(retainedViewModel, ViewModelProvider(fragment)[WhiteboardViewModel::class.java])
+ val view = fragment.binding.whiteboardView
+ view.dispatchStylusTouch(MotionEvent.ACTION_DOWN)
+ assertIs(view.activeTool)
+ }
+ }
+ }
+
+ @Test
+ fun `recreated view restores brush when stylus button is released mid stroke`() {
+ launchFragmentInContainer().use { scenario ->
+ lateinit var retainedViewModel: WhiteboardViewModel
+ scenario.onFragment { fragment ->
+ retainedViewModel = ViewModelProvider(fragment)[WhiteboardViewModel::class.java]
+ val view = fragment.binding.whiteboardView
+ view.dispatchStylusTouch(MotionEvent.ACTION_DOWN, MotionEvent.BUTTON_STYLUS_PRIMARY)
+ view.dispatchStylusTouch(MotionEvent.ACTION_UP, MotionEvent.BUTTON_STYLUS_PRIMARY)
+ assertIs(retainedViewModel.activeTool.value)
+ }
+
+ // The button stays held while the activity is recreated and the next stroke begins.
+ scenario.recreate()
+ scenario.onFragment { fragment ->
+ assertSame(retainedViewModel, ViewModelProvider(fragment)[WhiteboardViewModel::class.java])
+ val view = fragment.binding.whiteboardView
+ view.dispatchStylusTouch(MotionEvent.ACTION_DOWN, MotionEvent.BUTTON_STYLUS_PRIMARY)
+ assertIs(view.activeTool)
+
+ view.dispatchStylusTouch(MotionEvent.ACTION_MOVE)
+ assertIs(view.activeTool, "Releasing the button should restore the brush during this stroke")
+ }
+ }
+ }
+
@Test
fun `selecting an identical brush updates toolbar selection`() {
launchFragmentInContainer().use { scenario ->
@@ -74,6 +127,48 @@ class WhiteboardFragmentTest : RobolectricTest() {
return (holder.itemView as MaterialButton).isChecked
}
+ private fun WhiteboardView.dispatchStylusTouch(
+ action: Int,
+ buttonState: Int = 0,
+ ) {
+ val properties =
+ arrayOf(
+ MotionEvent.PointerProperties().apply {
+ id = 0
+ toolType = MotionEvent.TOOL_TYPE_STYLUS
+ },
+ )
+ val coordinates =
+ arrayOf(
+ MotionEvent.PointerCoords().apply {
+ x = 50f
+ y = 50f
+ },
+ )
+ val event =
+ MotionEvent.obtain(
+ 0L,
+ 0L,
+ action,
+ 1,
+ properties,
+ coordinates,
+ 0,
+ buttonState,
+ 1f,
+ 1f,
+ 0,
+ 0,
+ InputDevice.SOURCE_STYLUS,
+ 0,
+ )
+ try {
+ dispatchTouchEvent(event)
+ } finally {
+ event.recycle()
+ }
+ }
+
private fun changeToolbarVisibilityBeforeDestroyingView(isShown: Boolean) {
WhiteboardRepository(getPreferences()).isToolbarShown = !isShown
var toolbarUpdated = false
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardViewModelTest.kt
index 867d84c41423..fb6f7515cc7e 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardViewModelTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardViewModelTest.kt
@@ -21,6 +21,82 @@ class WhiteboardViewModelTest {
viewModel.loadState(isDarkMode = false)
}
+ @Test
+ fun `pressing stylus button enables eraser when brush was active`() {
+ assertFalse(viewModel.isEraserActive)
+ val initialTool = viewModel.activeTool.value
+ assertIs(initialTool)
+ val initialBrushWidth = initialTool.width
+ val initialBrushColor = initialTool.color
+
+ viewModel.setStylusButtonPressed(true)
+
+ assertTrue(viewModel.isEraserActive)
+ val eraserTool = viewModel.activeTool.value
+ assertIs(eraserTool)
+ assertEquals(viewModel.eraser.width, eraserTool.width)
+
+ viewModel.setStylusButtonPressed(false)
+
+ assertFalse(viewModel.isEraserActive)
+ val restoredTool = viewModel.activeTool.value
+ assertIs(restoredTool)
+ assertEquals(initialBrushWidth, restoredTool.width)
+ assertEquals(initialBrushColor, restoredTool.color)
+ }
+
+ @Test
+ fun `releasing stylus button leaves eraser active if eraser was already active before pressing`() {
+ viewModel.enableEraser()
+ assertTrue(viewModel.isEraserActive)
+ assertIs(viewModel.activeTool.value)
+
+ viewModel.setStylusButtonPressed(true)
+ assertTrue(viewModel.isEraserActive)
+ assertIs(viewModel.activeTool.value)
+
+ viewModel.setStylusButtonPressed(false)
+ assertTrue(viewModel.isEraserActive)
+ assertIs(viewModel.activeTool.value)
+ }
+
+ @Test
+ fun `selecting brush while stylus button is pressed keeps brush after button is released`() {
+ viewModel.setStylusButtonPressed(true)
+ assertTrue(viewModel.isEraserActive)
+
+ viewModel.setActiveBrush(0)
+ assertFalse(viewModel.isEraserActive)
+ assertIs(viewModel.activeTool.value)
+
+ viewModel.setStylusButtonPressed(false)
+ assertFalse(viewModel.isEraserActive)
+ assertIs(viewModel.activeTool.value)
+ }
+
+ @Test
+ fun `stylus button preserves selected brush index when restoring`() {
+ viewModel.addBrush(0xFF00FF)
+ val secondBrushIndex = viewModel.brushes.value.lastIndex
+ viewModel.setActiveBrush(secondBrushIndex)
+ val selectedBrush = viewModel.activeTool.value
+ assertIs(selectedBrush)
+ val selectedBrushWidth = selectedBrush.width
+ val selectedBrushColor = selectedBrush.color
+
+ viewModel.setStylusButtonPressed(true)
+ assertTrue(viewModel.isEraserActive)
+ assertIs(viewModel.activeTool.value)
+
+ viewModel.setStylusButtonPressed(false)
+ assertFalse(viewModel.isEraserActive)
+ val restoredTool = viewModel.activeTool.value
+ assertIs(restoredTool)
+ assertEquals(secondBrushIndex, viewModel.activeBrushIndex.value)
+ assertEquals(selectedBrushWidth, restoredTool.width)
+ assertEquals(selectedBrushColor, restoredTool.color)
+ }
+
@Test
fun `toggleEraser toggles between eraser and brush`() {
assertFalse(viewModel.isEraserActive)
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardViewTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardViewTest.kt
index 725dae6dc74a..3968ebd10d88 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardViewTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/whiteboard/WhiteboardViewTest.kt
@@ -3,6 +3,9 @@
package com.ichi2.anki.ui.windows.reviewer.whiteboard
import android.graphics.Color
+import android.graphics.Path
+import android.view.InputDevice
+import android.view.MotionEvent
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Before
@@ -21,6 +24,263 @@ class WhiteboardViewTest {
whiteboardView = WhiteboardView(ApplicationProvider.getApplicationContext())
}
+ @Test
+ fun `onTouchEvent notifies onStylusButtonStateChanged when stylus button is pressed and released`() {
+ val states = mutableListOf()
+ whiteboardView.onStylusButtonStateChanged = { states.add(it) }
+
+ // Touch with stylus button pressed
+ val downEvent = createStylusMotionEvent(MotionEvent.ACTION_DOWN, MotionEvent.BUTTON_STYLUS_PRIMARY)
+ whiteboardView.onTouchEvent(downEvent)
+ downEvent.recycle()
+
+ assertEquals(listOf(true), states)
+
+ // Move with stylus button still pressed
+ val moveEvent = createStylusMotionEvent(MotionEvent.ACTION_MOVE, MotionEvent.BUTTON_STYLUS_PRIMARY)
+ whiteboardView.onTouchEvent(moveEvent)
+ moveEvent.recycle()
+
+ // State shouldn't be re-emitted if unchanged
+ assertEquals(listOf(true), states)
+
+ // Move with stylus button released
+ val moveReleasedEvent = createStylusMotionEvent(MotionEvent.ACTION_MOVE, 0)
+ whiteboardView.onTouchEvent(moveReleasedEvent)
+ moveReleasedEvent.recycle()
+
+ assertEquals(listOf(true, false), states)
+ }
+
+ @Test
+ fun `onHoverEvent notifies onStylusButtonStateChanged during hover`() {
+ val states = mutableListOf()
+ whiteboardView.onStylusButtonStateChanged = { states.add(it) }
+
+ val hoverEvent = createStylusMotionEvent(MotionEvent.ACTION_HOVER_MOVE, MotionEvent.BUTTON_STYLUS_PRIMARY)
+ whiteboardView.onHoverEvent(hoverEvent)
+ hoverEvent.recycle()
+
+ assertEquals(listOf(true), states)
+
+ val hoverReleasedEvent = createStylusMotionEvent(MotionEvent.ACTION_HOVER_MOVE, 0)
+ whiteboardView.onHoverEvent(hoverReleasedEvent)
+ hoverReleasedEvent.recycle()
+
+ assertEquals(listOf(true, false), states)
+ }
+
+ @Test
+ fun `onHoverEvent without button pressed does not notify onStylusButtonStateChanged`() {
+ val states = mutableListOf()
+ whiteboardView.onStylusButtonStateChanged = { states.add(it) }
+
+ val hoverEvent = createStylusMotionEvent(MotionEvent.ACTION_HOVER_MOVE, 0)
+ whiteboardView.onHoverEvent(hoverEvent)
+ hoverEvent.recycle()
+
+ assertTrue(states.isEmpty())
+ }
+
+ @Test
+ fun `mid-stroke tool change commits in-progress stroke and continues drawing`() {
+ val completedPaths = mutableListOf()
+ val toolsAtCommit = mutableListOf()
+ whiteboardView.onNewPath = {
+ toolsAtCommit.add(whiteboardView.activeTool)
+ completedPaths.add(it)
+ }
+ whiteboardView.onStylusButtonStateChanged = { isPressed ->
+ whiteboardView.activeTool =
+ if (isPressed) {
+ WhiteboardTool.Eraser(inkWidth = 20f)
+ } else {
+ WhiteboardTool.Brush(Color.BLACK, 10f)
+ }
+ }
+
+ // 1. Start drawing with Brush (button not pressed)
+ val downEvent = createStylusMotionEvent(MotionEvent.ACTION_DOWN, 0, x = 10f, y = 10f)
+ whiteboardView.onTouchEvent(downEvent)
+ downEvent.recycle()
+
+ val move1 = createStylusMotionEvent(MotionEvent.ACTION_MOVE, 0, x = 20f, y = 20f)
+ whiteboardView.onTouchEvent(move1)
+ move1.recycle()
+
+ // 2. Button is pressed mid-stroke: brush stroke commits, drawing continues with ink eraser
+ val moveWithButton = createStylusMotionEvent(MotionEvent.ACTION_MOVE, MotionEvent.BUTTON_STYLUS_PRIMARY, x = 30f, y = 30f)
+ whiteboardView.onTouchEvent(moveWithButton)
+ moveWithButton.recycle()
+
+ assertEquals(1, completedPaths.size)
+ assertTrue(whiteboardView.isEraserActive)
+
+ // Continue moving with eraser
+ val move2 = createStylusMotionEvent(MotionEvent.ACTION_MOVE, MotionEvent.BUTTON_STYLUS_PRIMARY, x = 40f, y = 40f)
+ whiteboardView.onTouchEvent(move2)
+ move2.recycle()
+
+ // 3. Button is released mid-stroke: eraser stroke commits, drawing continues with brush
+ val moveWithoutButton = createStylusMotionEvent(MotionEvent.ACTION_MOVE, 0, x = 50f, y = 50f)
+ whiteboardView.onTouchEvent(moveWithoutButton)
+ moveWithoutButton.recycle()
+
+ assertEquals(2, completedPaths.size)
+ assertFalse(whiteboardView.isEraserActive)
+
+ // 4. Finish stroke on UP: last brush stroke commits
+ val upEvent = createStylusMotionEvent(MotionEvent.ACTION_UP, 0, x = 60f, y = 60f)
+ whiteboardView.onTouchEvent(upEvent)
+ upEvent.recycle()
+
+ assertEquals(3, completedPaths.size)
+ assertEquals(
+ listOf(
+ WhiteboardTool.Brush(Color.BLACK, 10f),
+ WhiteboardTool.Eraser(inkWidth = 20f),
+ WhiteboardTool.Brush(Color.BLACK, 10f),
+ ),
+ toolsAtCommit,
+ )
+ }
+
+ @Test
+ fun `mid-stroke tool change with stroke eraser starts and ends erase gesture`() {
+ val completedPaths = mutableListOf()
+ val eraseGestureEvents = mutableListOf()
+ whiteboardView.onNewPath = { completedPaths.add(it) }
+ whiteboardView.onEraseGestureStart = { x, y -> eraseGestureEvents.add("start $x,$y") }
+ whiteboardView.onEraseGestureMove = { x, y -> eraseGestureEvents.add("move $x,$y") }
+ whiteboardView.onEraseGestureEnd = { eraseGestureEvents.add("end") }
+
+ whiteboardView.onStylusButtonStateChanged = { isPressed ->
+ whiteboardView.activeTool =
+ if (isPressed) {
+ WhiteboardTool.Eraser(mode = EraserMode.STROKE, strokeEraserWidth = 20f)
+ } else {
+ WhiteboardTool.Brush(Color.BLACK, 10f)
+ }
+ }
+
+ // 1. Draw brush
+ val downEvent = createStylusMotionEvent(MotionEvent.ACTION_DOWN, 0, x = 10f, y = 10f)
+ whiteboardView.onTouchEvent(downEvent)
+ downEvent.recycle()
+
+ val move1 = createStylusMotionEvent(MotionEvent.ACTION_MOVE, 0, x = 20f, y = 20f)
+ whiteboardView.onTouchEvent(move1)
+ move1.recycle()
+
+ // 2. Press button: brush stroke commits, stroke erase gesture starts
+ val moveWithButton = createStylusMotionEvent(MotionEvent.ACTION_MOVE, MotionEvent.BUTTON_STYLUS_PRIMARY, x = 30f, y = 30f)
+ whiteboardView.onTouchEvent(moveWithButton)
+ moveWithButton.recycle()
+
+ assertEquals(1, completedPaths.size)
+ assertEquals(listOf("start 30.0,30.0"), eraseGestureEvents)
+
+ // 3. Move with eraser
+ val move2 = createStylusMotionEvent(MotionEvent.ACTION_MOVE, MotionEvent.BUTTON_STYLUS_PRIMARY, x = 40f, y = 40f)
+ whiteboardView.onTouchEvent(move2)
+ move2.recycle()
+
+ assertEquals(listOf("start 30.0,30.0", "move 40.0,40.0"), eraseGestureEvents)
+
+ // 4. Release button: stroke erase gesture ends, brush stroke starts
+ val moveWithoutButton = createStylusMotionEvent(MotionEvent.ACTION_MOVE, 0, x = 50f, y = 50f)
+ whiteboardView.onTouchEvent(moveWithoutButton)
+ moveWithoutButton.recycle()
+
+ assertEquals(listOf("start 30.0,30.0", "move 40.0,40.0", "move 50.0,50.0", "end"), eraseGestureEvents)
+
+ // 5. Finish on UP: brush stroke commits
+ val upEvent = createStylusMotionEvent(MotionEvent.ACTION_UP, 0, x = 60f, y = 60f)
+ whiteboardView.onTouchEvent(upEvent)
+ upEvent.recycle()
+
+ assertEquals(2, completedPaths.size)
+ }
+
+ @Test
+ fun `when activeTool is already ink eraser pressing stylus button does not stop stroke`() {
+ val completedPaths = mutableListOf()
+ whiteboardView.onNewPath = { completedPaths.add(it) }
+ whiteboardView.activeTool = WhiteboardTool.Eraser(inkWidth = 20f)
+
+ val downEvent = createStylusMotionEvent(MotionEvent.ACTION_DOWN, 0, x = 10f, y = 10f)
+ whiteboardView.onTouchEvent(downEvent)
+ downEvent.recycle()
+
+ val move1 = createStylusMotionEvent(MotionEvent.ACTION_MOVE, 0, x = 20f, y = 20f)
+ whiteboardView.onTouchEvent(move1)
+ move1.recycle()
+
+ // Press button while already using eraser: stroke should NOT stop
+ val moveWithButton = createStylusMotionEvent(MotionEvent.ACTION_MOVE, MotionEvent.BUTTON_STYLUS_PRIMARY, x = 30f, y = 30f)
+ whiteboardView.onTouchEvent(moveWithButton)
+ moveWithButton.recycle()
+
+ assertEquals(0, completedPaths.size)
+
+ // Release button while already using eraser: stroke should NOT stop
+ val moveWithoutButton = createStylusMotionEvent(MotionEvent.ACTION_MOVE, 0, x = 40f, y = 40f)
+ whiteboardView.onTouchEvent(moveWithoutButton)
+ moveWithoutButton.recycle()
+
+ assertEquals(0, completedPaths.size)
+
+ // Finish on UP: single complete stroke commits
+ val upEvent = createStylusMotionEvent(MotionEvent.ACTION_UP, 0, x = 50f, y = 50f)
+ whiteboardView.onTouchEvent(upEvent)
+ upEvent.recycle()
+
+ assertEquals(1, completedPaths.size)
+ }
+
+ @Test
+ fun `when activeTool is already stroke eraser pressing stylus button does not stop stroke`() {
+ val eraseGestureEvents = mutableListOf()
+ whiteboardView.onEraseGestureStart = { x, y -> eraseGestureEvents.add("start $x,$y") }
+ whiteboardView.onEraseGestureMove = { x, y -> eraseGestureEvents.add("move $x,$y") }
+ whiteboardView.onEraseGestureEnd = { eraseGestureEvents.add("end") }
+ whiteboardView.activeTool = WhiteboardTool.Eraser(mode = EraserMode.STROKE, strokeEraserWidth = 20f)
+
+ val downEvent = createStylusMotionEvent(MotionEvent.ACTION_DOWN, 0, x = 10f, y = 10f)
+ whiteboardView.onTouchEvent(downEvent)
+ downEvent.recycle()
+
+ val move1 = createStylusMotionEvent(MotionEvent.ACTION_MOVE, 0, x = 20f, y = 20f)
+ whiteboardView.onTouchEvent(move1)
+ move1.recycle()
+
+ // Press button: should continue same erase gesture
+ val moveWithButton = createStylusMotionEvent(MotionEvent.ACTION_MOVE, MotionEvent.BUTTON_STYLUS_PRIMARY, x = 30f, y = 30f)
+ whiteboardView.onTouchEvent(moveWithButton)
+ moveWithButton.recycle()
+
+ // Release button: should continue same erase gesture
+ val moveWithoutButton = createStylusMotionEvent(MotionEvent.ACTION_MOVE, 0, x = 40f, y = 40f)
+ whiteboardView.onTouchEvent(moveWithoutButton)
+ moveWithoutButton.recycle()
+
+ // Finish on UP
+ val upEvent = createStylusMotionEvent(MotionEvent.ACTION_UP, 0, x = 50f, y = 50f)
+ whiteboardView.onTouchEvent(upEvent)
+ upEvent.recycle()
+
+ assertEquals(
+ listOf(
+ "start 10.0,10.0",
+ "move 20.0,20.0",
+ "move 30.0,30.0",
+ "move 40.0,40.0",
+ "end",
+ ),
+ eraseGestureEvents,
+ )
+ }
+
@Test
fun `activeTool updates isEraserActive and eraserMode`() {
whiteboardView.activeTool = WhiteboardTool.Brush(Color.RED, 15f)
@@ -30,6 +290,44 @@ class WhiteboardViewTest {
assertTrue(whiteboardView.isEraserActive)
assertEquals(EraserMode.STROKE, whiteboardView.eraserMode)
}
+
+ private fun createStylusMotionEvent(
+ action: Int,
+ buttonState: Int,
+ x: Float = 50f,
+ y: Float = 50f,
+ ): MotionEvent {
+ val properties =
+ arrayOf(
+ MotionEvent.PointerProperties().apply {
+ id = 0
+ toolType = MotionEvent.TOOL_TYPE_STYLUS
+ },
+ )
+ val coords =
+ arrayOf(
+ MotionEvent.PointerCoords().apply {
+ this.x = x
+ this.y = y
+ },
+ )
+ return MotionEvent.obtain(
+ 0L,
+ 0L,
+ action,
+ 1,
+ properties,
+ coords,
+ 0,
+ buttonState,
+ 1f,
+ 1f,
+ 0,
+ 0,
+ InputDevice.SOURCE_STYLUS,
+ 0,
+ )
+ }
}
private val WhiteboardView.isEraserActive: Boolean
From d5e0f73934acad1f23fdfdaa26f2787fded03ac1 Mon Sep 17 00:00:00 2001
From: GuTS805
Date: Thu, 17 Sep 2026 13:05:00 +0530
Subject: [PATCH 031/150] fix(note-editor): don't silently discard
unsaved-changes warning on note type switch
Switching note type on the Add Note screen while unsaved field content
exists used to reset the unsaved-changes baseline to the user's own
typed text, so backing out lost it with no warning.
The baseline is now remapped by field index on a note type switch
instead of being reset from current content: existing fields keep
their baseline value, and any field the new note type added is
treated as baseline-empty. This also fixes a false-positive dialog
when a field is typed then cleared before switching to a note type
with more fields.
Switching note type also re-populates every field, which silently
converts literal br tags in the field content into a real newline
character. The baseline remap now applies the same conversion, so a
sticky field containing br text doesn't falsely appear edited on the
next switch.
Assisted-by: Claude Sonnet 5 [root cause analysis, test authoring]
---
.../main/java/com/ichi2/anki/FieldEditText.kt | 15 ++--
.../java/com/ichi2/anki/NoteEditorFragment.kt | 24 +++++-
.../java/com/ichi2/anki/NoteEditorTest.kt | 77 +++++++++++++++++++
3 files changed, 104 insertions(+), 12 deletions(-)
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/FieldEditText.kt b/AnkiDroid/src/main/java/com/ichi2/anki/FieldEditText.kt
index 725a779b58e4..c658b29e4066 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/FieldEditText.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/FieldEditText.kt
@@ -126,14 +126,7 @@ class FieldEditText :
content: String?,
replaceNewLine: Boolean,
) {
- val text =
- if (content == null) {
- ""
- } else if (replaceNewLine) {
- content.replace("
".toRegex(), NEW_LINE)
- } else {
- content
- }
+ val text = if (content == null) "" else convertBrToNewline(content, replaceNewLine)
setText(text)
}
@@ -229,5 +222,11 @@ class FieldEditText :
companion object {
val NEW_LINE: String = System.getProperty("line.separator")!!
+
+ /** Converts stored `
` HTML line breaks to [NEW_LINE], matching [setContent]'s handling. */
+ fun convertBrToNewline(
+ content: String,
+ replaceNewLine: Boolean,
+ ): String = if (replaceNewLine) content.replace("
".toRegex(), NEW_LINE) else content
}
}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt
index ca00f223fd6a..727b59e8085c 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt
@@ -2230,11 +2230,27 @@ class NoteEditorFragment :
}
} else {
populateEditFields(changeType)
- if (changeType.type != Type.CHANGE_FIELD_COUNT) {
+ if (changeType.type == Type.CHANGE_FIELD_COUNT) {
+ // A note type switch may carry the user's just-typed text into the new fields,
+ // so don't re-capture the baseline from current content. Instead, remap the
+ // existing baseline the same way the field content itself was remapped: keep
+ // baseline values for fields that still exist (put through the same
-to-newline
+ // conversion `populateEditFields` just applied to the actual field content, so a
+ // sticky field containing literal `
` text doesn't look edited), and treat any
+ // field the new note type added as baseline-empty, since it didn't exist for the
+ // user to edit before.
+ if (addNote) {
+ addNoteFieldBaseline =
+ List(editFields!!.size) { i ->
+ val old = addNoteFieldBaseline.getOrElse(i) { "" }
+ FieldEditText.convertBrToNewline(old, changeType.replaceNewlines)
+ }
+ }
+ } else {
updateFieldsFromStickyText()
- }
- if (addNote) {
- addNoteFieldBaseline = editFields!!.map { it.text?.toString() ?: "" }
+ if (addNote) {
+ addNoteFieldBaseline = editFields!!.map { it.text?.toString() ?: "" }
+ }
}
}
}
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/NoteEditorTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/NoteEditorTest.kt
index 7c5b153bb1cb..0c6a5cbe01b3 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/NoteEditorTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/NoteEditorTest.kt
@@ -815,6 +815,83 @@ class NoteEditorTest : RobolectricTest() {
)
}
+ @Test
+ fun `hasUnsavedChanges - sticky field with a br line break survives a note type switch`() =
+ runTest {
+ val basic = makeNoteForType(NoteType.BASIC)
+ basic!!.fields[0].sticky = true
+
+ val editor =
+ getNoteEditorAdding(NoteType.BASIC)
+ .withFirstField("Hello
World")
+ .build()
+
+ editor.saveNote()
+ advanceRobolectricLooper()
+ assertFalse(editor.hasUnsavedChanges(), "fresh screen after save: no real edits yet")
+
+ // switching note type re-populates every field from its own current content, which
+ // converts the sticky field's `
` into a real line break - this must not look like
+ // an edit, since the field's rendered content is unchanged from the user's perspective
+ editor.noteType = col.notetypes.byName("Basic (optional reversed card)")!!
+ advanceRobolectricLooper()
+
+ assertFalse(
+ editor.hasUnsavedChanges(),
+ "the sticky field's content round-tripped through a
/newline conversion but was never actually edited",
+ )
+ }
+
+ @Test
+ fun `hasUnsavedChanges - changing note type while adding does not discard unsaved field content`() =
+ runTest {
+ val editor =
+ getNoteEditorAdding(NoteType.BASIC)
+ .build()
+
+ // user types content into the first field but hasn't saved yet
+ editor.setFieldValueFromUi(0, "unsaved user content")
+ assertTrue(editor.hasUnsavedChanges(), "typing into a field is an unsaved change")
+
+ // user switches the note type (Basic -> Basic (and reversed)); both have a "Front" field
+ // at index 0, so the typed content is carried over into the new field layout
+ editor.noteType = col.notetypes.basicAndReversed
+ advanceRobolectricLooper()
+
+ assertThat(
+ "content should be preserved across the note type change",
+ editor.currentFieldStrings.toList(),
+ contains("unsaved user content", ""),
+ )
+ assertTrue(
+ editor.hasUnsavedChanges(),
+ "the user's unsaved content survived the note type change, so it must still be " +
+ "reported as an unsaved change - otherwise pressing back silently discards it",
+ )
+ }
+
+ @Test
+ fun `hasUnsavedChanges - typing then clearing a field before a note type switch that adds fields is not an unsaved change`() =
+ runTest {
+ val editor =
+ getNoteEditorAdding(NoteType.BASIC)
+ .build()
+
+ // user types into the first field, then reverts their own edit before switching
+ editor.setFieldValueFromUi(0, "x")
+ editor.setFieldValueFromUi(0, "")
+ assertFalse(editor.hasUnsavedChanges(), "user's only edit was reverted; nothing to save")
+
+ // switch to a note type with an extra field (Basic -> Basic (optional reversed card))
+ editor.noteType = col.notetypes.byName("Basic (optional reversed card)")!!
+ advanceRobolectricLooper()
+
+ assertFalse(
+ editor.hasUnsavedChanges(),
+ "all fields are still blank after the note type switch added a field; there is nothing to save",
+ )
+ }
+
@Test
fun `changing deck with multiple card ids moves all sibling cards`() =
runTest {
From acea8e4c80042ba640f48ff6d829ce46b0f13df9 Mon Sep 17 00:00:00 2001
From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>
Date: Wed, 30 Sep 2026 13:12:38 +0000
Subject: [PATCH 032/150] build(deps): bump brace-expansion in
/tools/localization
Bumps [brace-expansion](https://github.com/juliangruber/brace-expansion) from 1.1.16 to 1.1.21.
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v1.1.16...v1.1.21)
---
updated-dependencies:
- dependency-name: brace-expansion
dependency-version: 1.1.21
dependency-type: indirect
...
Signed-off-by: dependabot[bot]
---
tools/localization/yarn.lock | 17 ++++-------------
1 file changed, 4 insertions(+), 13 deletions(-)
diff --git a/tools/localization/yarn.lock b/tools/localization/yarn.lock
index 93527ff95bdf..e36343dc78e6 100644
--- a/tools/localization/yarn.lock
+++ b/tools/localization/yarn.lock
@@ -2104,12 +2104,12 @@ __metadata:
linkType: hard
"brace-expansion@npm:^1.1.7":
- version: 1.1.16
- resolution: "brace-expansion@npm:1.1.16"
+ version: 1.1.21
+ resolution: "brace-expansion@npm:1.1.21"
dependencies:
balanced-match: "npm:^1.0.0"
concat-map: "npm:0.0.1"
- checksum: 10c0/b2a915bbedbf4e45840d1fb9a4d391bbf26a79475bd134714d3cee34f1f0edb0ce982738028843be5fbaf8039429f71fa487df8c915b6065ced542c83e58fae6
+ checksum: 10c0/8f0a68a720f9cb28c2ecaea5cdaf15fafd686d6b6e4e722a81c9d4bd2f68c1fd0619731cac05875d842221eaf4d97d564d329a12eb3c4c22137502fb3c65be8b
languageName: node
linkType: hard
@@ -2122,16 +2122,7 @@ __metadata:
languageName: node
linkType: hard
-"brace-expansion@npm:^5.0.5":
- version: 5.0.6
- resolution: "brace-expansion@npm:5.0.6"
- dependencies:
- balanced-match: "npm:^4.0.2"
- checksum: 10c0/8c919869b90f61d533b341d3340be5ee4413232ea89b8246cbc2f38eb014f1d8182785c98a006eaf6111d02dc9eeffefdc240d5ac158625b2ed084dccd4bbf9b
- languageName: node
- linkType: hard
-
-"brace-expansion@npm:^5.0.8":
+"brace-expansion@npm:^5.0.5, brace-expansion@npm:^5.0.8":
version: 5.0.9
resolution: "brace-expansion@npm:5.0.9"
dependencies:
From 0cba7ca06099ff5795ba1bb2b7803462db1a47ff Mon Sep 17 00:00:00 2001
From: David Allison <62114487+david-allison@users.noreply.github.com>
Date: Tue, 29 Sep 2026 19:09:24 +0100
Subject: [PATCH 033/150] fix: reopen collection after failed backup
If reopening fails, the exception is attached as suppressed.
Fixes 22185
Assisted-by: GPT-6 (Codex)
---
.../ichi2/anki/CollectionManagerImportTest.kt | 88 +++++++++++++++++++
.../com/ichi2/anki/CollectionManager.kt | 14 ++-
2 files changed, 101 insertions(+), 1 deletion(-)
create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerImportTest.kt
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerImportTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerImportTest.kt
new file mode 100644
index 000000000000..36b228f9a6a5
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerImportTest.kt
@@ -0,0 +1,88 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki
+
+import androidx.core.content.edit
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.ichi2.anki.common.storage.CollectionHelper
+import net.ankiweb.rsdroid.BackendException
+import net.ankiweb.rsdroid.BackendException.BackendDbException.BackendDbLockedException
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.io.File
+import kotlin.test.assertEquals
+import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+import kotlin.test.assertIs
+import kotlin.test.assertNotNull
+
+@RunWith(AndroidJUnit4::class)
+class CollectionManagerImportTest : RobolectricTest() {
+ override fun getCollectionStorageMode() = CollectionStorageMode.ON_DISK
+
+ @Before
+ fun setCollectionPath() {
+ getPreferences().edit {
+ putString(CollectionHelper.PREF_COLLECTION_PATH, tempFolder.newFolder("collection").absolutePath)
+ }
+ }
+
+ @Test
+ fun `invalid backup leaves the original collection open`() =
+ runTest {
+ val backup = tempFolder.newFile("invalid.colpkg").apply { writeText("invalid backup") }
+
+ assertFailedImportKeepsCollectionOpen(backup)
+ }
+
+ @Test
+ fun `missing backup leaves the original collection open`() =
+ runTest {
+ assertFailedImportKeepsCollectionOpen(File(tempFolder.root, "missing.colpkg"))
+ }
+
+ @Test
+ fun `failure to reopen does not replace the import error`() =
+ runTest {
+ addBasicNote()
+ val backup = tempFolder.newFile("invalid.colpkg").apply { writeText("invalid backup") }
+ CollectionManager.emulatedOpenFailure = CollectionManager.CollectionOpenFailure.LOCKED
+ try {
+ val failure = assertFailsWith { CollectionManager.importColpkg(backup.path) }
+
+ assertFalse(failure is BackendDbLockedException)
+ assertIs(failure.suppressed.single())
+ } finally {
+ CollectionManager.emulatedOpenFailure = null
+ }
+ }
+
+ @Test
+ fun `valid backup replaces the collection`() =
+ runTest {
+ val note = addBasicNote("backed up", "answer")
+ val backupDirectory = tempFolder.newFolder("backups")
+ col.createBackup(backupDirectory.path, force = true, waitForCompletion = true)
+ val backup = backupDirectory.listFiles()!!.single { it.extension == "colpkg" }
+ addBasicNote("added after backup", "answer")
+
+ CollectionManager.importColpkg(backup.path)
+
+ CollectionManager.withCol {
+ assertEquals(1, noteCount())
+ assertEquals("backed up", getNote(note.id).fields[0])
+ }
+ }
+
+ private suspend fun assertFailedImportKeepsCollectionOpen(backup: File) {
+ val note = addBasicNote("original", "answer")
+
+ assertFailsWith { CollectionManager.importColpkg(backup.path) }
+
+ // withCol would reopen the collection and hide the regression.
+ val fields = CollectionManager.withOpenColOrNull { getNote(note.id).fields.toList() }
+ assertNotNull(fields, "The original collection should already be open after the import fails")
+ assertEquals(listOf("original", "answer"), fields)
+ }
+}
diff --git a/anki-common/src/main/kotlin/com/ichi2/anki/CollectionManager.kt b/anki-common/src/main/kotlin/com/ichi2/anki/CollectionManager.kt
index ff0979b888b2..f4b26a86135e 100644
--- a/anki-common/src/main/kotlin/com/ichi2/anki/CollectionManager.kt
+++ b/anki-common/src/main/kotlin/com/ichi2/anki/CollectionManager.kt
@@ -518,12 +518,24 @@ object CollectionManager {
/**
* Replace the collection with the provided colpkg file if it is valid.
+ * On success, leave the replacement collection closed; callers must reopen it, for example with [withCol].
+ * On failure, try to reopen the original collection before rethrowing the import error.
+ * If reopening also fails, attach that failure as a suppressed exception to the import error.
*/
suspend fun importColpkg(colpkgPath: String) {
withQueue {
ensureClosedInner()
ensureBackendInner()
- importCollectionPackage(backend!!, collectionPathInValidFolder(), colpkgPath)
+ try {
+ importCollectionPackage(backend!!, collectionPathInValidFolder(), colpkgPath)
+ } catch (importError: Exception) {
+ try {
+ ensureOpenInner()
+ } catch (reopenError: Exception) {
+ importError.addSuppressed(reopenError)
+ }
+ throw importError
+ }
}
}
From 08f1ff3daedfbc0aa6ed6beea2b38493b485236e Mon Sep 17 00:00:00 2001
From: David Allison <62114487+david-allison@users.noreply.github.com>
Date: Tue, 29 Sep 2026 21:50:28 +0100
Subject: [PATCH 034/150] docs: document collection package import exceptions
Part of 22185
Assisted-by: GPT-6
---
.../src/main/kotlin/com/ichi2/anki/CollectionManager.kt | 9 +++++++++
1 file changed, 9 insertions(+)
diff --git a/anki-common/src/main/kotlin/com/ichi2/anki/CollectionManager.kt b/anki-common/src/main/kotlin/com/ichi2/anki/CollectionManager.kt
index f4b26a86135e..71488f398d9d 100644
--- a/anki-common/src/main/kotlin/com/ichi2/anki/CollectionManager.kt
+++ b/anki-common/src/main/kotlin/com/ichi2/anki/CollectionManager.kt
@@ -41,8 +41,11 @@ import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import net.ankiweb.rsdroid.Backend
import net.ankiweb.rsdroid.BackendException
+import net.ankiweb.rsdroid.BackendException.BackendImportException
import net.ankiweb.rsdroid.BackendFactory
import net.ankiweb.rsdroid.Translations
+import net.ankiweb.rsdroid.exceptions.BackendIoException
+import net.ankiweb.rsdroid.exceptions.BackendSyncException
import timber.log.Timber
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
@@ -521,6 +524,12 @@ object CollectionManager {
* On success, leave the replacement collection closed; callers must reopen it, for example with [withCol].
* On failure, try to reopen the original collection before rethrowing the import error.
* If reopening also fails, attach that failure as a suppressed exception to the import error.
+ *
+ * @throws BackendIoException if the colpkg file is missing or an I/O error occurs.
+ * @throws BackendSyncException if the colpkg file is not a valid archive.
+ * The backend maps ZIP errors to sync errors, even during a local import.
+ * TODO: to be fixed in https://github.com/ankitects/anki/issues/5732
+ * @throws BackendImportException if the archive contains an invalid collection database.
*/
suspend fun importColpkg(colpkgPath: String) {
withQueue {
From c34add825de66698b460d962b5022114cf266e98 Mon Sep 17 00:00:00 2001
From: David Allison <62114487+david-allison@users.noreply.github.com>
Date: Tue, 29 Sep 2026 19:26:32 +0100
Subject: [PATCH 035/150] test(backups): cover import failures & corruption
Part of 22185
Assisted-by: GPT-6
---
.../ichi2/anki/CollectionManagerImportTest.kt | 44 +++++++++++++++----
1 file changed, 35 insertions(+), 9 deletions(-)
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerImportTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerImportTest.kt
index 36b228f9a6a5..857142fe1dfc 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerImportTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerImportTest.kt
@@ -7,13 +7,17 @@ import androidx.test.ext.junit.runners.AndroidJUnit4
import com.ichi2.anki.common.storage.CollectionHelper
import net.ankiweb.rsdroid.BackendException
import net.ankiweb.rsdroid.BackendException.BackendDbException.BackendDbLockedException
+import net.ankiweb.rsdroid.BackendException.BackendImportException
+import net.ankiweb.rsdroid.exceptions.BackendIoException
+import net.ankiweb.rsdroid.exceptions.BackendSyncException
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
+import java.util.zip.ZipEntry
+import java.util.zip.ZipOutputStream
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
-import kotlin.test.assertFalse
import kotlin.test.assertIs
import kotlin.test.assertNotNull
@@ -33,25 +37,34 @@ class CollectionManagerImportTest : RobolectricTest() {
runTest {
val backup = tempFolder.newFile("invalid.colpkg").apply { writeText("invalid backup") }
- assertFailedImportKeepsCollectionOpen(backup)
+ assertFailedImportKeepsCollectionOpen(backup)
}
@Test
fun `missing backup leaves the original collection open`() =
runTest {
- assertFailedImportKeepsCollectionOpen(File(tempFolder.root, "missing.colpkg"))
+ assertFailedImportKeepsCollectionOpen(File(tempFolder.root, "missing.colpkg"))
+ }
+
+ @Test
+ fun `corrupt database in a valid archive leaves the original collection open`() =
+ runTest {
+ val failure = assertFailedImportKeepsCollectionOpen(corruptDatabaseBackup())
+
+ assertEquals(CollectionManager.TR.importingTheProvidedFileIsNotA(), failure.localizedMessage)
}
@Test
fun `failure to reopen does not replace the import error`() =
runTest {
addBasicNote()
- val backup = tempFolder.newFile("invalid.colpkg").apply { writeText("invalid backup") }
+ val backup = corruptDatabaseBackup()
+ val expectedMessage = CollectionManager.TR.importingTheProvidedFileIsNotA()
CollectionManager.emulatedOpenFailure = CollectionManager.CollectionOpenFailure.LOCKED
try {
- val failure = assertFailsWith { CollectionManager.importColpkg(backup.path) }
+ val failure = assertFailsWith { CollectionManager.importColpkg(backup.path) }
- assertFalse(failure is BackendDbLockedException)
+ assertEquals(expectedMessage, failure.localizedMessage)
assertIs(failure.suppressed.single())
} finally {
CollectionManager.emulatedOpenFailure = null
@@ -64,7 +77,10 @@ class CollectionManagerImportTest : RobolectricTest() {
val note = addBasicNote("backed up", "answer")
val backupDirectory = tempFolder.newFolder("backups")
col.createBackup(backupDirectory.path, force = true, waitForCompletion = true)
- val backup = backupDirectory.listFiles()!!.single { it.extension == "colpkg" }
+ val files = assertNotNull(backupDirectory.listFiles(), "Unable to list backup directory: $backupDirectory")
+ val backups = files.filter { it.extension == "colpkg" }
+ assertEquals(1, backups.size, "Expected one collection backup in $backupDirectory; found ${files.map { it.name }}")
+ val backup = backups.single()
addBasicNote("added after backup", "answer")
CollectionManager.importColpkg(backup.path)
@@ -75,14 +91,24 @@ class CollectionManagerImportTest : RobolectricTest() {
}
}
- private suspend fun assertFailedImportKeepsCollectionOpen(backup: File) {
+ private fun corruptDatabaseBackup(): File =
+ tempFolder.newFile("corrupt.colpkg").apply {
+ ZipOutputStream(outputStream()).use { archive ->
+ archive.putNextEntry(ZipEntry("collection.anki2"))
+ archive.write("not a SQLite database".toByteArray())
+ archive.closeEntry()
+ }
+ }
+
+ private suspend inline fun assertFailedImportKeepsCollectionOpen(backup: File): T {
val note = addBasicNote("original", "answer")
- assertFailsWith { CollectionManager.importColpkg(backup.path) }
+ val failure = assertFailsWith { CollectionManager.importColpkg(backup.path) }
// withCol would reopen the collection and hide the regression.
val fields = CollectionManager.withOpenColOrNull { getNote(note.id).fields.toList() }
assertNotNull(fields, "The original collection should already be open after the import fails")
assertEquals(listOf("original", "answer"), fields)
+ return failure
}
}
From 00e21ba49d7cb951f99360b1f68161490e4c2aad Mon Sep 17 00:00:00 2001
From: David Allison <62114487+david-allison@users.noreply.github.com>
Date: Tue, 29 Sep 2026 19:32:36 +0100
Subject: [PATCH 036/150] test(deck-picker): recovery after failed backup
restore
Part of 22185
Assisted-by: GPT-6
---
.../ichi2/anki/DeckPickerBackupRestoreTest.kt | 87 +++++++++++++++++++
1 file changed, 87 insertions(+)
create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerBackupRestoreTest.kt
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerBackupRestoreTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerBackupRestoreTest.kt
new file mode 100644
index 000000000000..6e57c98e03ee
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerBackupRestoreTest.kt
@@ -0,0 +1,87 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki
+
+import androidx.appcompat.app.AlertDialog
+import androidx.core.content.edit
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.ichi2.anki.CollectionManager.TR
+import com.ichi2.anki.common.storage.CollectionHelper
+import com.ichi2.testutils.BackupManagerTestUtilities
+import com.ichi2.utils.positiveButton
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.shadows.ShadowDialog
+import java.io.File
+import java.util.zip.ZipOutputStream
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
+import kotlin.test.assertTrue
+import kotlin.test.fail
+
+@RunWith(AndroidJUnit4::class)
+class DeckPickerBackupRestoreTest : RobolectricTest() {
+ override fun getCollectionStorageMode() = CollectionStorageMode.ON_DISK
+
+ @Before
+ fun setCollectionPath() {
+ getPreferences().edit {
+ putString(CollectionHelper.PREF_COLLECTION_PATH, tempFolder.newFolder("collection").absolutePath)
+ }
+ }
+
+ @After
+ fun resetBackupSpace() {
+ BackupManagerTestUtilities.reset()
+ }
+
+ @Test
+ fun `failed restore reports the error and leaves the original collection open`() {
+ val note = addBasicNote("original", "answer")
+ val backup = createInvalidBackup()
+
+ withDeckPicker(deckCount = 0) { deckPicker ->
+ throwOnShowError = false
+ deckPicker.importColpkgListener = ImportColpkgListener { fail("Failed restore reported success") }
+
+ deckPicker.restoreBackupAndWaitForError(backup)
+
+ assertEquals(TR.importingTheProvidedFileIsNotA(), getAlertDialogText(checkDismissed = false))
+ // Check before accessing col: that getter would reopen it and hide the regression.
+ assertTrue(CollectionManager.isOpenUnsafe(), "Failed restore left the collection closed")
+
+ clickAlertDialogButton { positiveButton }
+ assertTrue(CollectionManager.isOpenUnsafe(), "Dismissing the restore error left the collection closed")
+ assertEquals(listOf("original", "answer"), col.getNote(note.id).fields.toList())
+ }
+ }
+
+ private fun createInvalidBackup(): File {
+ val backupDirectory = BackupManager.getBackupDirectory(CollectionManager.getCollectionDirectory())
+ val backup = File(backupDirectory, "backup-2020-07-07-07.00.00.colpkg")
+ // A valid ZIP missing its collection produces the localized invalid-backup error.
+ ZipOutputStream(backup.outputStream()).use { }
+ return backup
+ }
+
+ private fun DeckPicker.restoreBackupAndWaitForError(backup: File) {
+ assertTrue(shadowOf(this).clickMenuItem(R.id.action_restore_backup))
+ advanceRobolectricLooper()
+ clickAlertDialogButton { positiveButton }
+ val chooser = assertIs(ShadowDialog.getLatestDialog())
+ val listView = chooser.listView
+ assertEquals(1, listView.count)
+ assertTrue(
+ listView.performItemClick(listView.getChildAt(0), 0, listView.adapter.getItemId(0)),
+ "Selecting the backup did not invoke its restore listener",
+ )
+
+ advanceRobolectricLooperUntil(lazyMessage = { "Expected a restore error dialog after selecting ${backup.name}" }) {
+ val dialog = ShadowDialog.getLatestDialog()
+ dialog is AlertDialog && dialog !== chooser && dialog.isShowing
+ }
+ }
+}
From bb4c51af589339b6e18c8e315a62566119f2013d Mon Sep 17 00:00:00 2001
From: David Allison <62114487+david-allison@users.noreply.github.com>
Date: Tue, 29 Sep 2026 18:53:21 +0100
Subject: [PATCH 037/150] fix(backups): report when manual backup is skipped
Improve the output message to a user.
Note: false = 'backup not created', not 'backup failed'.
```diff
- Backup created.
+ No changes since latest backup.
```
Fixes 22184
Assisted-by: GPT-6
---
.../java/com/ichi2/anki/BackendBackups.kt | 26 +++++----
.../main/java/com/ichi2/anki/DeckPicker.kt | 10 ++--
.../com/ichi2/anki/DeckPickerBackupTest.kt | 58 +++++++++++++++++++
3 files changed, 79 insertions(+), 15 deletions(-)
create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerBackupTest.kt
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/BackendBackups.kt b/AnkiDroid/src/main/java/com/ichi2/anki/BackendBackups.kt
index 39778269e3f3..225d01f16fdb 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/BackendBackups.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/BackendBackups.kt
@@ -27,14 +27,16 @@ import net.ankiweb.rsdroid.BackendException
* Attempts a backup and awaits its completion.
*
* @param force Bypass the minimum backup interval. Unchanged collections are still skipped.
+ * @return `true` if a backup completed successfully; `false` if the collection has not changed
+ * since the last backup, or if [force] is `false` and the minimum backup interval has not elapsed.
* @throws BackendException if backup creation or completion fails. See [Collection.createBackup]
* for full exception details.
*/
-suspend fun performBackupInBackground(force: Boolean = false) {
+suspend fun performBackupInBackground(force: Boolean = false): Boolean {
// Wait a second to allow the deck list to finish loading first, or it
// will hang until the first stage of the backup completes.
delay(1000)
- createBackup(force = force)
+ return createBackup(force = force)
}
fun Activity.importColpkg(colpkgPath: String) where Activity : AnkiActivity, Activity : ImportColpkgListener {
@@ -53,17 +55,19 @@ fun Activity.importColpkg(colpkgPath: String) where Activity : AnkiAc
}
}
-private suspend fun createBackup(force: Boolean) {
- withCol {
- // this two-step approach releases the backend lock after the initial copy
- createBackup(
- BackupManager.getBackupDirectoryFromCollection(colDb),
- force,
- waitForCompletion = false,
- )
- }
+private suspend fun createBackup(force: Boolean): Boolean {
+ val created =
+ withCol {
+ // this two-step approach releases the backend lock after the initial copy
+ createBackup(
+ BackupManager.getBackupDirectoryFromCollection(colDb),
+ force,
+ waitForCompletion = false,
+ )
+ }
// move this outside 'withCol' to avoid blocking
withContext(Dispatchers.IO) {
CollectionManager.getBackend().awaitBackupCompletion()
}
+ return created
}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt
index 374c8ae2d3f5..cd92040409ba 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt
@@ -1459,10 +1459,12 @@ open class DeckPicker :
private fun createBackup() {
launchCatchingTask {
- withProgress(message = TR.sentenceCase.creatingBackup) {
- performBackupInBackground(true)
- }
- showThemedToast(this@DeckPicker, TR.profilesBackupCreated(), false)
+ val created =
+ withProgress(message = TR.sentenceCase.creatingBackup) {
+ performBackupInBackground(true)
+ }
+ val message = if (created) TR.profilesBackupCreated() else TR.profilesBackupUnchanged()
+ showThemedToast(this@DeckPicker, message, false)
}
}
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerBackupTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerBackupTest.kt
new file mode 100644
index 000000000000..2a5c4a6a5817
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerBackupTest.kt
@@ -0,0 +1,58 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.ichi2.anki.CollectionManager.TR
+import com.ichi2.testutils.BackupManagerTestUtilities
+import com.ichi2.testutils.ext.menu
+import org.junit.After
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.shadows.ShadowToast
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+@RunWith(AndroidJUnit4::class)
+class DeckPickerBackupTest : RobolectricTest() {
+ override fun getCollectionStorageMode() = CollectionStorageMode.ON_DISK
+
+ @After
+ fun resetBackupSpace() {
+ BackupManagerTestUtilities.reset()
+ }
+
+ @Test
+ fun `create backup reports when collection is unchanged`() {
+ withDeckPicker(deckCount = 0, withCards = true) { deckPicker ->
+ assertTrue(
+ col.createBackup(
+ BackupManager.getBackupDirectoryFromCollection(col.colDb),
+ force = true,
+ waitForCompletion = true,
+ ),
+ )
+
+ deckPicker.createBackupFromMenu()
+
+ assertEquals(TR.profilesBackupUnchanged(), ShadowToast.getTextOfLatestToast())
+ assertEquals(1, BackupManager.getBackups(col.colDb).size)
+ }
+ }
+
+ @Test
+ fun `create backup reports success after writing backup`() {
+ withDeckPicker(deckCount = 0, withCards = true) { deckPicker ->
+ deckPicker.createBackupFromMenu()
+
+ assertEquals(TR.profilesBackupCreated(), ShadowToast.getTextOfLatestToast())
+ assertEquals(1, BackupManager.getBackups(col.colDb).size)
+ }
+ }
+
+ private fun DeckPicker.createBackupFromMenu() {
+ ShadowToast.reset()
+ assertTrue(onOptionsItemSelected(menu().findItem(R.id.action_create_backup)))
+ advanceRobolectricLooperUntil { ShadowToast.getTextOfLatestToast() != null }
+ }
+}
From 87057f9b9fac693374a7824e23788c53ce8734bd Mon Sep 17 00:00:00 2001
From: David Allison <62114487+david-allison@users.noreply.github.com>
Date: Tue, 29 Sep 2026 20:10:09 +0100
Subject: [PATCH 038/150] fix(sync): handle skipped backup result
Assisted-by: GPT-6
---
AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt | 14 +++++++++-----
1 file changed, 9 insertions(+), 5 deletions(-)
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt b/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt
index 54ea704fb7d4..c5ed0caf6574 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt
@@ -245,11 +245,15 @@ private suspend fun handleDownload(
onCancel = ::cancelSync,
) {
try {
- createBackup(
- BackupManager.getBackupDirectoryFromCollection(colDb),
- force = true,
- waitForCompletion = true,
- )
+ val created =
+ createBackup(
+ BackupManager.getBackupDirectoryFromCollection(colDb),
+ force = true,
+ waitForCompletion = true,
+ )
+ if (!created) {
+ Timber.i("Sync: Backup skipped before full download: collection unchanged")
+ }
close(downgrade = false, forFullSync = true)
fullUploadOrDownload(auth, upload = false, serverUsn = mediaUsn)
} finally {
From 8e46d963e420f108bdd6e000612e3df06f5896ce Mon Sep 17 00:00:00 2001
From: David Allison <62114487+david-allison@users.noreply.github.com>
Date: Tue, 29 Sep 2026 18:59:32 +0100
Subject: [PATCH 039/150] refactor(backups): add `@CheckResult`
Assisted-by: GPT-6
---
AnkiDroid/src/main/java/com/ichi2/anki/BackendBackups.kt | 2 ++
.../test/java/com/ichi2/anki/CollectionManagerImportTest.kt | 3 ++-
libanki/src/main/java/com/ichi2/anki/libanki/Collection.kt | 1 +
3 files changed, 5 insertions(+), 1 deletion(-)
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/BackendBackups.kt b/AnkiDroid/src/main/java/com/ichi2/anki/BackendBackups.kt
index 225d01f16fdb..800b64a6351c 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/BackendBackups.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/BackendBackups.kt
@@ -16,6 +16,7 @@
package com.ichi2.anki
+import androidx.annotation.CheckResult
import com.ichi2.anki.CollectionManager.withCol
import com.ichi2.anki.libanki.Collection
import kotlinx.coroutines.Dispatchers
@@ -32,6 +33,7 @@ import net.ankiweb.rsdroid.BackendException
* @throws BackendException if backup creation or completion fails. See [Collection.createBackup]
* for full exception details.
*/
+@CheckResult
suspend fun performBackupInBackground(force: Boolean = false): Boolean {
// Wait a second to allow the deck list to finish loading first, or it
// will hang until the first stage of the backup completes.
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerImportTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerImportTest.kt
index 857142fe1dfc..16a6eef4aea0 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerImportTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerImportTest.kt
@@ -20,6 +20,7 @@ import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertIs
import kotlin.test.assertNotNull
+import kotlin.test.assertTrue
@RunWith(AndroidJUnit4::class)
class CollectionManagerImportTest : RobolectricTest() {
@@ -76,7 +77,7 @@ class CollectionManagerImportTest : RobolectricTest() {
runTest {
val note = addBasicNote("backed up", "answer")
val backupDirectory = tempFolder.newFolder("backups")
- col.createBackup(backupDirectory.path, force = true, waitForCompletion = true)
+ assertTrue(col.createBackup(backupDirectory.path, force = true, waitForCompletion = true))
val files = assertNotNull(backupDirectory.listFiles(), "Unable to list backup directory: $backupDirectory")
val backups = files.filter { it.extension == "colpkg" }
assertEquals(1, backups.size, "Expected one collection backup in $backupDirectory; found ${files.map { it.name }}")
diff --git a/libanki/src/main/java/com/ichi2/anki/libanki/Collection.kt b/libanki/src/main/java/com/ichi2/anki/libanki/Collection.kt
index 35dbb228af9d..507a4dbb1588 100644
--- a/libanki/src/main/java/com/ichi2/anki/libanki/Collection.kt
+++ b/libanki/src/main/java/com/ichi2/anki/libanki/Collection.kt
@@ -434,6 +434,7 @@ class Collection(
* @throws BackendException if a previous unawaited backup failed. Its original exception is
* rethrown before attempting another backup.
*/
+ @CheckResult
@LibAnkiAlias("create_backup")
fun createBackup(
backupFolder: String,
From 9f241b219c8cea3d482fec27ac8e5dca0be20742 Mon Sep 17 00:00:00 2001
From: David Allison <62114487+david-allison@users.noreply.github.com>
Date: Tue, 29 Sep 2026 15:42:40 +0100
Subject: [PATCH 040/150] refactor(view-model): inject IdsFile results
Now handled in the ViewModel factory - removes the file access from
construction of the class.
Prep for handling a 'failing' IdsFile load.
Part of 19572
Assisted-by: GPT-6
---
.../com/ichi2/anki/previewer/PreviewerFragment.kt | 15 ++++++++++++++-
.../ichi2/anki/previewer/PreviewerViewModel.kt | 5 +----
.../anki/previewer/PreviewerViewModelTest.kt | 10 +---------
3 files changed, 16 insertions(+), 14 deletions(-)
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerFragment.kt
index 099d8fcdec13..622cca69a0b8 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerFragment.kt
@@ -15,8 +15,11 @@ import androidx.core.view.ViewCompat
import androidx.core.view.doOnLayout
import androidx.core.view.isVisible
import androidx.fragment.app.viewModels
+import androidx.lifecycle.createSavedStateHandle
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.viewmodel.initializer
+import androidx.lifecycle.viewmodel.viewModelFactory
import com.google.android.material.slider.Slider
import com.ichi2.anki.CollectionManager.TR
import com.ichi2.anki.CommonString
@@ -35,6 +38,7 @@ import com.ichi2.anki.snackbar.BaseSnackbarBuilderProvider
import com.ichi2.anki.snackbar.SnackbarBuilder
import com.ichi2.anki.ui.internationalization.sentenceCase
import com.ichi2.anki.utils.ext.collectIn
+import com.ichi2.anki.utils.ext.require
import com.ichi2.anki.utils.ext.setIconRes
import com.ichi2.anki.utils.ext.sharedPrefs
import com.ichi2.anki.workarounds.SafeWebViewLayout
@@ -49,7 +53,16 @@ class PreviewerFragment :
BaseSnackbarBuilderProvider,
DispatchKeyEventListener,
BindingProcessor {
- override val viewModel: PreviewerViewModel by viewModels()
+ override val viewModel: PreviewerViewModel by viewModels {
+ viewModelFactory {
+ initializer {
+ val handle = createSavedStateHandle()
+ // Read before constructing the ViewModel, which immediately loads the first card.
+ val ids = handle.require(CARD_IDS_FILE_ARG).getIds()
+ PreviewerViewModel(handle, ids)
+ }
+ }
+ }
private val binding by viewBinding(FragmentPreviewerBinding::bind)
override val webViewLayout: SafeWebViewLayout get() = binding.webViewLayout
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerViewModel.kt
index f51be3daa4f8..2f47242e4507 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerViewModel.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerViewModel.kt
@@ -8,7 +8,6 @@ import anki.collection.OpChanges
import com.ichi2.anki.CollectionManager.withCol
import com.ichi2.anki.Flag
import com.ichi2.anki.asyncIO
-import com.ichi2.anki.browser.IdsFile
import com.ichi2.anki.cardviewer.SingleCardSide
import com.ichi2.anki.common.annotations.NeedsTest
import com.ichi2.anki.common.destinations.NoteEditorDestination
@@ -31,6 +30,7 @@ import timber.log.Timber
class PreviewerViewModel(
savedStateHandle: SavedStateHandle,
+ @VisibleForTesting val selectedCardIds: List,
) : CardViewerViewModel(savedStateHandle),
ChangeManager.Subscriber {
val currentIndex =
@@ -42,9 +42,6 @@ class PreviewerViewModel(
val isMarked = MutableStateFlow(false)
val flag: MutableStateFlow = MutableStateFlow(Flag.NONE)
- @VisibleForTesting
- val selectedCardIds: List = savedStateHandle.require(PreviewerFragment.CARD_IDS_FILE_ARG).getIds()
-
val isBackButtonEnabled =
combine(currentIndex, showingAnswer, backSideOnly) { index, showingAnswer, isBackSideOnly ->
index != 0 || (showingAnswer && !isBackSideOnly)
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/previewer/PreviewerViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/previewer/PreviewerViewModelTest.kt
index 604df8745b99..783fa5fe9c9d 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/previewer/PreviewerViewModelTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/previewer/PreviewerViewModelTest.kt
@@ -6,13 +6,10 @@ import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.ichi2.anki.Flag
-import com.ichi2.anki.browser.IdsFile
import com.ichi2.anki.servicelayer.NoteService
import com.ichi2.anki.utils.ext.flag
import com.ichi2.testutils.JvmTest
import io.mockk.coEvery
-import io.mockk.every
-import io.mockk.mockk
import io.mockk.spyk
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
@@ -28,8 +25,6 @@ import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class PreviewerViewModelTest : JvmTest() {
- private val idsFile: IdsFile = mockk()
-
private lateinit var viewModel: PreviewerViewModel
private fun TestScope.onNextButtonClick() {
@@ -60,15 +55,12 @@ class PreviewerViewModelTest : JvmTest() {
val note = addBasicNote()
note.cards().map { it.id }
}
- every { idsFile.getIds() } returns cardIds
-
val savedStateHandle =
SavedStateHandle().apply {
set(PreviewerFragment.CURRENT_INDEX_ARG, 0)
- set(PreviewerFragment.CARD_IDS_FILE_ARG, idsFile)
}
- viewModel = spyk(PreviewerViewModel(savedStateHandle))
+ viewModel = spyk(PreviewerViewModel(savedStateHandle, cardIds))
// the default implementation requires the Collection media directory,
// which needs Robolectric with CollectionStorageMode.IN_MEMORY_WITH_MEDIA or ON_DISK
coEvery { viewModel.prepareCardTextForDisplay(any()) } answers { firstArg() }
From a23539c28a9abf42b03bde1a2b25a51e81e2e016 Mon Sep 17 00:00:00 2001
From: David Allison <62114487+david-allison@users.noreply.github.com>
Date: Tue, 29 Sep 2026 15:43:08 +0100
Subject: [PATCH 041/150] fix(tags): only read tags file if necessary
`viewModelFactory {` would be called on rotation
but we only need the IdsFile results if a new ViewModel is created
(`initializer {`)
Part of 19572
Assisted-by: GPT-6
---
.../com/ichi2/anki/dialogs/tags/TagsDialog.kt | 16 ++++++++--------
1 file changed, 8 insertions(+), 8 deletions(-)
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt
index 0e4d436bc7ff..da07a2c802f6 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt
@@ -97,16 +97,16 @@ class TagsDialog : AnalyticsDialogFragment {
@VisibleForTesting
val viewModel: TagsDialogViewModel by viewModels {
- val idsFile = requireArguments().requireParcelable(ARG_TAGS_FILE)
- val noteIds = idsFile.getIds()
- val checkedTags =
- requireNotNull(requireArguments().getStringArrayList(ARG_CHECKED_TAGS)) {
- "$ARG_CHECKED_TAGS is required"
- }
- val type = BundleCompat.getParcelable(requireArguments(), ARG_DIALOG_TYPE, DialogType::class.java)
- val isCustomStudying = type != null && type == DialogType.CUSTOM_STUDY
viewModelFactory {
initializer {
+ val idsFile = requireArguments().requireParcelable(ARG_TAGS_FILE)
+ val noteIds = idsFile.getIds()
+ val checkedTags =
+ requireNotNull(requireArguments().getStringArrayList(ARG_CHECKED_TAGS)) {
+ "$ARG_CHECKED_TAGS is required"
+ }
+ val type = BundleCompat.getParcelable(requireArguments(), ARG_DIALOG_TYPE, DialogType::class.java)
+ val isCustomStudying = type != null && type == DialogType.CUSTOM_STUDY
TagsDialogViewModel(
noteIds = noteIds,
checkedTags = checkedTags,
From c55f2f84069391726cd7d2e34ea0b6836b4674a8 Mon Sep 17 00:00:00 2001
From: David Allison <62114487+david-allison@users.noreply.github.com>
Date: Wed, 30 Sep 2026 18:53:09 +0100
Subject: [PATCH 042/150] test(reviewer): use `Reviewer.kt` button IDs
This added about 15s to test execution
Part of 22200 - made day rollover issues more likely to occur
Assisted-by: GPT-6
---
.../java/com/ichi2/anki/ReviewerTest.kt | 36 ++++---------------
1 file changed, 6 insertions(+), 30 deletions(-)
diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerTest.kt
index cc45317df731..53f4a01f1b4d 100755
--- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerTest.kt
+++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerTest.kt
@@ -6,14 +6,12 @@ package com.ichi2.anki
import androidx.core.content.edit
import androidx.recyclerview.widget.RecyclerView
import androidx.test.espresso.Espresso.onView
-import androidx.test.espresso.NoMatchingViewException
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.contrib.RecyclerViewActions
import androidx.test.espresso.matcher.ViewMatchers.hasDescendant
import androidx.test.espresso.matcher.ViewMatchers.isDisplayed
import androidx.test.espresso.matcher.ViewMatchers.withId
-import androidx.test.espresso.matcher.ViewMatchers.withResourceName
import androidx.test.espresso.matcher.ViewMatchers.withText
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
@@ -36,7 +34,6 @@ import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import timber.log.Timber
-import java.lang.AssertionError
import java.util.UUID
@RunWith(AndroidJUnit4::class)
@@ -174,23 +171,11 @@ class ReviewerTest : InstrumentedTest() {
private fun clickShowAnswerAndAnswerGood() {
clickShowAnswer()
ensureAnswerButtonsAreDisplayed()
- try {
- // ...on the command line it has resource name "good_button"...
- onView(withResourceName("good_button")).perform(click())
- } catch (e: NoMatchingViewException) {
- // ...but in Android Studio it has resource name "flashcard_layout_ease3" !?
- onView(withResourceName("flashcard_layout_ease3")).perform(click())
- }
+ onView(withId(R.id.flashcard_layout_ease3)).perform(click())
}
private fun clickShowAnswer() {
- try {
- // ... on the command line, it has resource name "show_answer"...
- onView(withResourceName("show_answer")).perform(click())
- } catch (e: NoMatchingViewException) {
- // ... but in Android Studio it has resource name "flashcard_layout_flip" !?
- onView(withResourceName("flashcard_layout_flip")).perform(click())
- }
+ onView(withId(R.id.flashcard_layout_flip)).perform(click())
}
private fun ensureAnswerButtonsAreDisplayed() {
@@ -198,19 +183,10 @@ class ReviewerTest : InstrumentedTest() {
// the messages to be passed in and out of the WebView when evaluating
// the custom JS scheduler code. The ease buttons are hidden until the
// custom scheduler has finished running
- try {
- // ...on the command line it has resource name "good_button"...
- onView(withResourceName("good_button")).checkWithTimeout(
- matches(isDisplayed()),
- 100,
- )
- } catch (e: AssertionError) {
- // ...but in Android Studio it has resource name "flashcard_layout_ease3" !?
- onView(withResourceName("flashcard_layout_ease3")).checkWithTimeout(
- matches(isDisplayed()),
- 100,
- )
- }
+ onView(withId(R.id.flashcard_layout_ease3)).checkWithTimeout(
+ matches(isDisplayed()),
+ 100,
+ )
}
private fun disableNewReviewer() {
From b5bb4dc423a6094453a89dfd44bed10feb8d3ede Mon Sep 17 00:00:00 2001
From: David Allison <62114487+david-allison@users.noreply.github.com>
Date: Wed, 30 Sep 2026 20:19:15 +0100
Subject: [PATCH 043/150] fix(browser): check for fragment before reading
preferences
This fixes a legitimate failure in `ActivityStartupUnderBackupTest`
which was masked by a custom SharedPreferences implementation.
Now we no longer need a `SharedPreferences` instance on a failure path.
Assisted-by: GPT-6 - investigated, implemented, and tested the fix
---
AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt
index bffb0b738e02..c64b825197a7 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt
@@ -127,8 +127,8 @@ open class CardBrowser :
val menuHost: MenuHost?
get() =
- if (useSearchView) {
- if (this::cardBrowserFragment.isInitialized) cardBrowserFragment else null
+ if (this::cardBrowserFragment.isInitialized && useSearchView) {
+ cardBrowserFragment
} else {
null
}
From 75d185f4207ca68b9430184fd147c38f39e0e9d8 Mon Sep 17 00:00:00 2001
From: David Allison <62114487+david-allison@users.noreply.github.com>
Date: Wed, 30 Sep 2026 20:19:15 +0100
Subject: [PATCH 044/150] fix(prefs): get preferences through a provider
Fixed flaky test: `PrefsSearchBarTest`, which failed after
`NotificationServiceTest` because `Prefs` kept the previous
test's `SharedPreferences`.
Prefs now uses a `SharedPreferencesProvider`
Issue 22207
Assisted-by: GPT-6 - investigated, implemented, and tested the fix
---
.../java/com/ichi2/anki/settings/Prefs.kt | 13 ++++--
.../ichi2/anki/settings/PrefsSingletonTest.kt | 42 +++++++++++++++++++
2 files changed, 52 insertions(+), 3 deletions(-)
create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/settings/PrefsSingletonTest.kt
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/settings/Prefs.kt b/AnkiDroid/src/main/java/com/ichi2/anki/settings/Prefs.kt
index 069388c1cf33..3b2a89ebf967 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/settings/Prefs.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/settings/Prefs.kt
@@ -15,6 +15,7 @@ import com.ichi2.anki.cardviewer.TapGestureMode
import com.ichi2.anki.common.preferences.AnimationPreferences
import com.ichi2.anki.common.preferences.sharedPrefs
import com.ichi2.anki.common.utils.isRunningAsUnitTest
+import com.ichi2.anki.preferences.SharedPreferencesProvider
import com.ichi2.anki.settings.enums.AppTheme
import com.ichi2.anki.settings.enums.DayTheme
import com.ichi2.anki.settings.enums.FrameStyle
@@ -28,15 +29,21 @@ import kotlin.reflect.KProperty
// TODO move this to `com.ichi2.anki.preferences`
// after the UI classes of that package are moved to `com.ichi2.anki.ui.preferences`
-object Prefs : PrefsRepository(AnkiDroidApp.sharedPrefs(), AnkiDroidApp.appResources)
+// Use a provider to avoid retaining SharedPreferences from a previous Robolectric test.
+object Prefs : PrefsRepository(AnkiDroidApp.sharedPreferencesProvider, AnkiDroidApp.appResources)
// TODO: enforce that `preferences.xml` is used
-open class PrefsRepository(
- val sharedPrefs: SharedPreferences,
+open class PrefsRepository protected constructor(
+ private val preferences: SharedPreferencesProvider,
private val resources: Resources,
) : AnimationPreferences {
+ constructor(sharedPrefs: SharedPreferences, resources: Resources) : this(SharedPreferencesProvider { sharedPrefs }, resources)
+
constructor(context: Context) : this(context.sharedPrefs(), context.resources)
+ val sharedPrefs: SharedPreferences
+ get() = preferences.sharedPrefs()
+
@VisibleForTesting
fun key(
@StringRes resId: Int,
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/settings/PrefsSingletonTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/settings/PrefsSingletonTest.kt
new file mode 100644
index 000000000000..98961c35ecae
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/settings/PrefsSingletonTest.kt
@@ -0,0 +1,42 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package com.ichi2.anki.settings
+
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import com.github.ivanshafran.sharedpreferencesmock.SPMockBuilder
+import com.ichi2.anki.AnkiDroidApp
+import com.ichi2.anki.R
+import com.ichi2.anki.RobolectricTest
+import com.ichi2.testutils.withBooleanPreference
+import org.junit.Test
+import org.junit.runner.RunWith
+import kotlin.test.assertFalse
+import kotlin.test.assertTrue
+
+@RunWith(AndroidJUnit4::class)
+class PrefsSingletonTest : RobolectricTest() {
+ @Test
+ fun `reads and writes follow the current application preferences`() =
+ withBooleanPreference(R.string.pref_new_review_reminders, true) {
+ val original = Prefs.sharedPrefs
+ val originalOverride = AnkiDroidApp.sharedPreferencesTestingOverride
+ val replacement = SPMockBuilder().createSharedPreferences()
+ val key = targetContext.getString(R.string.pref_new_review_reminders)
+
+ // Replace SharedPreferences to simulate starting another Robolectric test.
+ // Verify that Prefs uses the provider instead of keeping the previous instance.
+ AnkiDroidApp.sharedPreferencesTestingOverride = replacement
+ try {
+ assertFalse(Prefs.newReviewRemindersEnabled)
+ Prefs.newReviewRemindersEnabled = true
+ assertTrue(replacement.getBoolean(key, false))
+ Prefs.newReviewRemindersEnabled = false
+ assertFalse(replacement.getBoolean(key, true))
+ assertTrue(original.getBoolean(key, false))
+ } finally {
+ AnkiDroidApp.sharedPreferencesTestingOverride = originalOverride
+ }
+
+ assertTrue(Prefs.newReviewRemindersEnabled)
+ }
+}
From 9ac902bb8c1f2e888f09aceb0b5dec9d6beec019 Mon Sep 17 00:00:00 2001
From: David Allison <62114487+david-allison@users.noreply.github.com>
Date: Wed, 30 Sep 2026 20:19:56 +0100
Subject: [PATCH 045/150] test(prefs): cover both reminder search modes
Fixes an incorrect assertion: review reminders do not map through
getFragmentFromXmlRes().
Issue 22207
Assisted-by: GPT-6
---
.../anki/preferences/PrefsSearchBarTest.kt | 38 ++++++++++++++++++-
1 file changed, 36 insertions(+), 2 deletions(-)
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/preferences/PrefsSearchBarTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/preferences/PrefsSearchBarTest.kt
index f372593fbaca..0eab812a1c70 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/preferences/PrefsSearchBarTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/preferences/PrefsSearchBarTest.kt
@@ -5,21 +5,35 @@ package com.ichi2.anki.preferences
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.bytehamster.lib.preferencesearch.PreferenceItem
import com.bytehamster.lib.preferencesearch.SearchConfiguration
+import com.bytehamster.lib.preferencesearch.SearchPreferenceResult
+import com.ichi2.anki.R
import com.ichi2.anki.RobolectricTest
import com.ichi2.anki.common.destinations.PreferencesDestination
+import com.ichi2.anki.reviewreminders.ScheduleRemindersFragment
+import com.ichi2.anki.settings.Prefs
import com.ichi2.testutils.requireAccessibleJavaField
import org.hamcrest.MatcherAssert.assertThat
import org.hamcrest.Matchers.equalTo
import org.junit.Test
import org.junit.runner.RunWith
+import org.mockito.kotlin.doReturn
+import org.mockito.kotlin.mock
import org.robolectric.Robolectric
+import kotlin.test.assertEquals
+import kotlin.test.assertIs
import kotlin.test.assertNotNull
@RunWith(AndroidJUnit4::class)
class PrefsSearchBarTest : RobolectricTest() {
@Test
+ fun `indexed resources resolve with legacy reminders`() = assertIndexedResources(newReviewRemindersEnabled = false)
+
+ @Test
+ fun `indexed resources resolve with new reminders`() = assertIndexedResources(newReviewRemindersEnabled = true)
+
@Suppress("UNCHECKED_CAST")
- fun `All indexed XML resIDs lead to the correct fragments on getFragmentFromXmlRes`() {
+ private fun assertIndexedResources(newReviewRemindersEnabled: Boolean) {
+ Prefs.newReviewRemindersEnabled = newReviewRemindersEnabled
// TODO try mocking the activity
val preferencesActivity = getPreferencesActivity()
val searchConfig = SearchConfiguration(preferencesActivity)
@@ -51,8 +65,16 @@ class PrefsSearchBarTest : RobolectricTest() {
.plus(prefItemsResIds)
.distinct() as List
- // Check if all indexed XML resIDs lead to the correct fragments on getFragmentFromXmlRes
+ assertEquals(newReviewRemindersEnabled, R.xml.preferences_review_reminders in allResIds)
+ assertEquals(!newReviewRemindersEnabled, R.xml.preferences_notifications in allResIds)
+
+ // Check both the normal settings mapping and the custom review reminders destination.
for (resId in allResIds) {
+ if (resId == R.xml.preferences_review_reminders) {
+ assertReviewRemindersDestination(preferencesActivity)
+ continue
+ }
+
val fragment = getFragmentFromXmlRes(resId)
val resName = targetContext.resources.getResourceName(resId)
@@ -74,6 +96,18 @@ class PrefsSearchBarTest : RobolectricTest() {
}
}
+ private fun assertReviewRemindersDestination(activity: PreferencesActivity) {
+ val result =
+ mock {
+ on { resourceFile } doReturn R.xml.preferences_review_reminders
+ }
+ activity.onSearchResultClicked(result)
+ advanceRobolectricLooper()
+
+ val preferences = activity.fragment as PreferencesFragment
+ assertIs(preferences.childFragmentManager.findFragmentById(R.id.settings_container))
+ }
+
private fun getPreferencesActivity(): PreferencesActivity {
val intent = PreferencesDestination.Root.toIntent(targetContext)
val controller =
From 37fea03ee83458f2dccddf9b1394ef58c7267a52 Mon Sep 17 00:00:00 2001
From: Mike Hardy
Date: Wed, 30 Sep 2026 20:32:10 +0100
Subject: [PATCH 046/150] test(notifications): reset failure before teardown
This stops `debugEnsureNoOpenPointers` from being skipped.
Issue 22207
Co-authored-by: David Allison <62114487+david-allison@users.noreply.github.com>
Assisted-by: GPT-6
---
.../test/java/com/ichi2/anki/services/NotificationServiceTest.kt | 1 +
1 file changed, 1 insertion(+)
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/services/NotificationServiceTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/services/NotificationServiceTest.kt
index abadc97cc3cb..4cbffda2ff62 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/services/NotificationServiceTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/services/NotificationServiceTest.kt
@@ -70,6 +70,7 @@ class NotificationServiceTest : RobolectricTest() {
@After
override fun tearDown() {
+ CollectionManager.emulatedOpenFailure = null
super.tearDown()
unmockkAll()
TimeManager.reset()
From 5d140edc5d172ffbd3c70d31b33171e4d5d1501c Mon Sep 17 00:00:00 2001
From: David Allison <62114487+david-allison@users.noreply.github.com>
Date: Wed, 30 Sep 2026 23:41:16 +0100
Subject: [PATCH 047/150] refactor: replace `bundleOf` with `Bundle()`
deprecated in androidx.core
Assisted-by: GPT-6
---
.../java/com/ichi2/anki/AnkiActivitySavedStateTest.kt | 8 ++++++--
1 file changed, 6 insertions(+), 2 deletions(-)
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/AnkiActivitySavedStateTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/AnkiActivitySavedStateTest.kt
index e6642bdd73b6..76604081cf8a 100644
--- a/AnkiDroid/src/test/java/com/ichi2/anki/AnkiActivitySavedStateTest.kt
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/AnkiActivitySavedStateTest.kt
@@ -3,7 +3,7 @@
package com.ichi2.anki
import android.content.Intent
-import androidx.core.os.bundleOf
+import android.os.Bundle
import androidx.fragment.app.Fragment
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.ichi2.testutils.EmptyAnkiActivity
@@ -34,7 +34,11 @@ class AnkiActivitySavedStateTest : RobolectricTest() {
fun savedSize(payload: ByteArray): Int =
savedStateSize(
SingleFragmentActivity::class.java,
- SingleFragmentActivity.getIntent(targetContext, Fragment::class, bundleOf("payload" to payload)),
+ SingleFragmentActivity.getIntent(
+ targetContext,
+ Fragment::class,
+ Bundle().apply { putByteArray("payload", payload) },
+ ),
)
val payload = ByteArray(300_000)
From 5b91b48a35fe9fbb0569c904882267764e484a27 Mon Sep 17 00:00:00 2001
From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com>
Date: Tue, 29 Sep 2026 15:30:07 +0530
Subject: [PATCH 048/150] fix(media-check): show errors instead of crashing
Opening Check Media crashed when the backend failed. On GrapheneOS
with Storage Scopes, listing an empty media folder returns
'Operation not permitted (os error 1)', so every check crashed.
Assisted-by: Opus 5.5 (Analysis of root cause)
---
.../anki/mediacheck/MediaCheckFragment.kt | 10 +--
.../anki/mediacheck/MediaCheckViewModel.kt | 25 +++---
.../anki/mediacheck/MediaCheckFragmentTest.kt | 87 +++++++++++++++++++
.../mediacheck/MediaCheckViewModelTest.kt | 10 +--
4 files changed, 110 insertions(+), 22 deletions(-)
create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/mediacheck/MediaCheckFragmentTest.kt
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckFragment.kt
index f7aa110c484d..f7ad03c8f980 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckFragment.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckFragment.kt
@@ -66,7 +66,7 @@ class MediaCheckFragment : Fragment(R.layout.fragment_media_check) {
(requireActivity() as AppCompatActivity).setSupportActionBar(binding.toolbar)
observeProgress(viewModel) { progress -> getString(progress.messageRes) }
- viewModel.checkMedia()
+ launchCatchingTask { viewModel.checkMedia().await() }
lifecycleScope.launch {
viewModel.mediaCheckResult.collectLatest { result ->
@@ -149,7 +149,7 @@ class MediaCheckFragment : Fragment(R.layout.fragment_media_check) {
setOnClickListener {
launchCatchingTask {
- viewModel.tagMissing(TR.mediaCheckMissingMediaTag()).join()
+ viewModel.tagMissing(TR.mediaCheckMissingMediaTag()).await()
showResultDialog(
CommonString.check_media_tags_added,
TR.browsingNotesUpdated(viewModel.taggedFiles),
@@ -169,14 +169,14 @@ class MediaCheckFragment : Fragment(R.layout.fragment_media_check) {
private fun confirmMediaRestore() {
launchCatchingTask {
- viewModel.restoreTrash().join()
+ viewModel.restoreTrash().await()
showTrashRestoredDialog()
}
}
private fun deleteTrash() {
launchCatchingTask {
- viewModel.deleteTrash().join()
+ viewModel.deleteTrash().await()
showTrashDeletedDialog()
}
}
@@ -191,7 +191,7 @@ class MediaCheckFragment : Fragment(R.layout.fragment_media_check) {
private fun handleDeleteConfirmation() {
launchCatchingTask {
- viewModel.deleteUnusedMedia().join()
+ viewModel.deleteUnusedMedia().await()
showDeletionResult()
}
}
diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckViewModel.kt
index d031bbc9f326..876803dc3375 100644
--- a/AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckViewModel.kt
+++ b/AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckViewModel.kt
@@ -25,10 +25,10 @@ import com.ichi2.anki.common.annotations.NeedsTest
import com.ichi2.anki.observability.undoableOp
import com.ichi2.anki.progress.HasProgress
import com.ichi2.anki.progress.ProgressManager
-import kotlinx.coroutines.Job
+import kotlinx.coroutines.Deferred
+import kotlinx.coroutines.async
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.launch
/** The operation [MediaCheckViewModel] is running, shown as a progress message by the UI. */
enum class MediaCheckProgress {
@@ -37,6 +37,7 @@ enum class MediaCheckProgress {
DELETING_MEDIA,
}
+// TODO: handle errors in the ViewModel instead of returning a Deferred for the UI to await
@NeedsTest("Test the media check process i.e. the buttons and views")
class MediaCheckViewModel :
ViewModel(),
@@ -55,8 +56,8 @@ class MediaCheckViewModel :
val taggedFiles: Int
get() = taggedFilesCount.value
- fun tagMissing(tag: String): Job =
- viewModelScope.launch {
+ fun tagMissing(tag: String): Deferred =
+ viewModelScope.async {
progressManager.withProgress(message = MediaCheckProgress.ADDING_TAGS) {
val taggedNotes =
undoableOp {
@@ -66,26 +67,26 @@ class MediaCheckViewModel :
}
}
- fun checkMedia(): Job =
- viewModelScope.launch {
+ fun checkMedia(): Deferred =
+ viewModelScope.async {
progressManager.withProgress(message = MediaCheckProgress.CHECKING_MEDIA) {
mediaCheckResult.value = withCol { media.check() }
}
}
- fun deleteTrash(): Job =
- viewModelScope.launch {
+ fun deleteTrash(): Deferred =
+ viewModelScope.async {
progressManager.withProgress { withCol { media.emptyTrash() } }
}
- fun restoreTrash(): Job =
- viewModelScope.launch {
+ fun restoreTrash(): Deferred =
+ viewModelScope.async {
progressManager.withProgress { withCol { media.restoreTrash() } }
}
// TODO: investigate: the underlying implementation exposes progress, which we do not yet handle.
- fun deleteUnusedMedia(): Job =
- viewModelScope.launch {
+ fun deleteUnusedMedia(): Deferred =
+ viewModelScope.async {
progressManager.withProgress(message = MediaCheckProgress.DELETING_MEDIA) {
val unused = mediaCheckResult.value?.unusedList ?: listOf()
withCol { media.trashFiles(unused) }
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/mediacheck/MediaCheckFragmentTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/mediacheck/MediaCheckFragmentTest.kt
new file mode 100644
index 000000000000..dbb99dd7d673
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/mediacheck/MediaCheckFragmentTest.kt
@@ -0,0 +1,87 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+// SPDX-FileCopyrightText: Copyright (c) 2026 Ashish Yadav
+package com.ichi2.anki.mediacheck
+
+import android.widget.Button
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import anki.backend.BackendError
+import anki.backend.backendError
+import anki.media.CheckMediaResponse
+import com.ichi2.anki.CollectionManager
+import com.ichi2.anki.R
+import com.ichi2.anki.RobolectricTest
+import com.ichi2.anki.SingleFragmentActivity
+import com.ichi2.anki.throwOnShowError
+import com.ichi2.utils.positiveButton
+import net.ankiweb.rsdroid.Backend
+import net.ankiweb.rsdroid.BackendFactory
+import net.ankiweb.rsdroid.exceptions.BackendIoException
+import org.junit.After
+import org.junit.Test
+import org.junit.runner.RunWith
+import java.io.File
+import kotlin.test.assertEquals
+
+@RunWith(AndroidJUnit4::class)
+class MediaCheckFragmentTest : RobolectricTest() {
+ private val failure =
+ BackendIoException(
+ backendError {
+ kind = BackendError.Kind.IO_ERROR
+ message = "Operation not permitted (os error 1)"
+ },
+ )
+
+ override fun getCollectionStorageMode() = CollectionStorageMode.ON_DISK
+
+ @After
+ fun resetBackendFactory() {
+ BackendFactory.setOverride(null)
+ }
+
+ /** Issue 22075 */
+ @Test
+ fun `failed media check shows an error`() =
+ runTest {
+ useBackend {
+ object : Backend() {
+ override fun checkMedia(): CheckMediaResponse = throw failure
+ }
+ }
+ throwOnShowError = false
+
+ startMediaCheck()
+
+ assertEquals(failure.localizedMessage, getAlertDialogText(checkDismissed = true))
+ }
+
+ @Test
+ fun `failed deletion shows an error instead of the result`() =
+ runTest {
+ useBackend {
+ object : Backend() {
+ override fun trashMediaFiles(fnames: Iterable): Unit = throw failure
+ }
+ }
+ File(col.media.dir, "unused.png").writeText("not an image")
+ throwOnShowError = false
+
+ val activity = startMediaCheck()
+ activity.findViewById