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