From cee8622b5c1d7feb65154b9a40c0c48f2b316214 Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:21:38 +0530 Subject: [PATCH 01/53] fix(shared-decks): return to the deck after an AnkiWeb login Logging in or signing up from the shared decks screen left people on AnkiWeb's own deck list, and Back could not get past its login page. Once AnkiWeb lands on its after-login page, reload the last shared decks page and drop the login pages from history. Back still goes through the shared pages opened before logging in. Assisted-by: Claude Opus 5.5 --- .../anki/shareddeck/SharedDecksActivity.kt | 48 ++++- .../shareddeck/SharedDecksActivityTest.kt | 165 ++++++++++++++++++ 2 files changed, 209 insertions(+), 4 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksActivity.kt index 74762b9f77e5..aff232c28c40 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksActivity.kt @@ -11,6 +11,7 @@ import android.view.MenuItem import android.view.ViewGroup.MarginLayoutParams import android.webkit.CookieManager import android.webkit.URLUtil +import android.webkit.WebBackForwardList import android.webkit.WebResourceError import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse @@ -58,13 +59,22 @@ class SharedDecksActivity : AnkiActivity(R.layout.activity_shared_decks) { private var shouldHistoryBeCleared = false + private var isReturningAfterLogin = false + + private val pagesBeforeLogin = mutableListOf() + private var loginRequiredDialog: AlertDialog? = null private val allowedHosts = listOf(Regex("""^(?:.*\.)?ankiweb\.net$"""), Regex("""^ankiuser\.net$"""), Regex("""^ankisrs\.net$""")) private val onBackPressedCallback = object : OnBackPressedCallback(true) { override fun handleOnBackPressed() { - if (binding.webView.canGoBack()) binding.webView.goBack() + if (isReturningAfterLogin) return + if (binding.webView.canGoBack()) { + binding.webView.goBack() + } else { + pagesBeforeLogin.removeLastOrNull()?.let { returnTo(it) } + } } } @@ -83,7 +93,14 @@ class SharedDecksActivity : AnkiActivity(R.layout.activity_shared_decks) { isReload: Boolean, ) { super.doUpdateVisitedHistory(view, url, isReload) - onBackPressedCallback.isEnabled = binding.webView.canGoBack() + if (isReturningAfterLogin && url?.startsWith(SHARED_PAGES_PREFIX) == true) { + binding.webView.clearHistory() + isReturningAfterLogin = false + } + onBackPressedCallback.isEnabled = binding.webView.canGoBack() || pagesBeforeLogin.isNotEmpty() + if (url?.startsWith(AFTER_LOGIN_URL) == true && isLoggedInToAnkiWeb()) { + returnToSharedDecks() + } } override fun onPageFinished( @@ -167,8 +184,6 @@ class SharedDecksActivity : AnkiActivity(R.layout.activity_shared_decks) { private fun redirectToLogin() { val url = getString(R.string.shared_decks_login_url) Timber.i("HTTP 429, redirecting to login: '$url'") - // TODO: the result of login is typically redirecting the user to their decks - // this should be improved binding.webView.loadUrl(url) } @@ -178,6 +193,24 @@ class SharedDecksActivity : AnkiActivity(R.layout.activity_shared_decks) { binding.webView.loadUrl(url) } + private fun returnToSharedDecks() { + if (isReturningAfterLogin) return + val history = binding.webView.copyBackForwardList() + val sharedPages = + history.urlsBeforeCurrent + .filter { it.startsWith(SHARED_PAGES_PREFIX) } + .toList() + val url = sharedPages.lastOrNull() ?: return + Timber.d("Logged in to AnkiWeb, returning to '$url'") + pagesBeforeLogin += sharedPages.dropLast(1) + returnTo(url) + } + + private fun returnTo(url: String) { + isReturningAfterLogin = true + binding.webView.loadUrl(url) + } + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal val webViewClient = SharedDeckWebViewClient() @@ -186,6 +219,9 @@ class SharedDecksActivity : AnkiActivity(R.layout.activity_shared_decks) { const val ARG_DOWNLOAD_FILE = "DownloadFile" const val HTTP_STATUS_TOO_MANY_REQUESTS = 429 + + private const val AFTER_LOGIN_URL = "https://ankiweb.net/account/login?afterAuth=1" + private const val SHARED_PAGES_PREFIX = "https://ankiweb.net/shared/" } // Show WebView with AnkiWeb shared decks with the functionality to capture downloads and import decks. @@ -280,6 +316,7 @@ class SharedDecksActivity : AnkiActivity(R.layout.activity_shared_decks) { if (item.itemId == R.id.home) { // R.id.home refers to a custom 'home' menu item defined in your app resources (res/menu/...). shouldHistoryBeCleared = true + pagesBeforeLogin.clear() binding.webView.loadUrl(resources.getString(R.string.shared_decks_url)) } else if (item.itemId == android.R.id.home) { // android.R.id.home refers to the system-provided "up" button in the app toolbar @@ -341,3 +378,6 @@ internal fun isLoggedInToAnkiWeb(): Boolean = Timber.w(e, "Could not determine login status") false } + +private val WebBackForwardList.urlsBeforeCurrent: Sequence + get() = (0 until currentIndex).asSequence().map { getItemAtIndex(it).url } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/shareddeck/SharedDecksActivityTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/shareddeck/SharedDecksActivityTest.kt index 8002204c9509..098638c9bb66 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/shareddeck/SharedDecksActivityTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/shareddeck/SharedDecksActivityTest.kt @@ -3,6 +3,7 @@ package com.ichi2.anki.shareddeck +import android.util.Log import android.webkit.CookieManager import android.webkit.WebResourceResponse import android.webkit.WebView @@ -22,9 +23,13 @@ import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.robolectric.Robolectric import org.robolectric.Shadows +import org.robolectric.fakes.RoboMenuItem import org.robolectric.shadows.ShadowDialog +import timber.log.Timber import kotlin.test.assertEquals +import kotlin.test.assertFalse import kotlin.test.assertNull +import kotlin.test.assertTrue /** Tests for [SharedDecksActivity] */ @RunWith(AndroidJUnit4::class) @@ -82,6 +87,138 @@ class SharedDecksActivityTest : RobolectricTest() { assertEquals(getResourceString(R.string.shared_decks_url), activity.lastLoadedUrl) } + @Test + fun `logging in to AnkiWeb returns to the shared deck`() { + val activity = startOnSharedDeck() + + activity.logInToAnkiWeb() + + assertEquals(SHARED_DECK, activity.webView.url) + } + + @Test + fun `back after logging in to AnkiWeb returns to the search results`() { + val searchResults = "https://ankiweb.net/shared/decks/Japanese" + val activity = startSharedDecks() + activity.visit(searchResults) + activity.visit(SHARED_DECK) + + activity.logInToAnkiWeb() + + activity.visit(SHARED_DECK) + assertEquals(SHARED_DECK, activity.webView.url) + + activity.onBackPressedDispatcher.onBackPressed() + + assertFalse(activity.isFinishing, "Back should return to the search results instead of closing the screen") + assertEquals(searchResults, activity.webView.url) + } + + @Test + fun `back after logging in to AnkiWeb returns to each earlier shared page once`() { + val sharedDecks = getResourceString(R.string.shared_decks_url) + val searchResults = "https://ankiweb.net/shared/decks/Japanese" + val activity = startSharedDecks() + activity.visit(sharedDecks) + activity.visit(searchResults) + activity.visit(SHARED_DECK) + activity.logInToAnkiWeb() + activity.visit(AFTER_LOGIN) + activity.visit(SHARED_DECK) + + activity.pressBack() + assertEquals(searchResults, activity.webView.url) + activity.pressBack() + assertEquals(sharedDecks, activity.webView.url) + activity.pressBack() + assertTrue(activity.isFinishing) + } + + @Test + fun `back from home after logging in to AnkiWeb closes the screen`() { + val activity = startOnSharedDeck() + activity.logInToAnkiWeb() + activity.visit(SHARED_DECK) + + activity.onOptionsItemSelected(RoboMenuItem(R.id.home)) + activity.visit(getResourceString(R.string.shared_decks_url)) + activity.pressBack() + + assertTrue(activity.isFinishing) + } + + @Test + fun `logging in after AnkiWeb refuses a download returns to the deck page`() { + val activity = startOnSharedDeck() + activity.visit(DECK_DOWNLOAD) + + activity.logInToAnkiWeb() + + assertEquals(SHARED_DECK, activity.webView.url) + } + + @Test + fun `AnkiWeb login without a session stays on AnkiWeb`() { + val activity = startOnSharedDeck() + activity.openAnkiWebLogin() + + activity.visit(AFTER_LOGIN) + + assertEquals(AFTER_LOGIN, activity.webView.url) + } + + @Test + fun `back while returning after login must not reopen login`() { + val activity = startOnSharedDeck() + activity.logInToAnkiWeb() + // loadUrl was requested, but the return navigation has not committed yet. + activity.onBackPressedDispatcher.onBackPressed() + assertFalse( + activity.webView.url?.startsWith(getResourceString(R.string.shared_decks_login_url)) == true, + "Back reopened the login page while the return to the deck was pending", + ) + } + + @Test + fun `returning after a login does not log the deck URL at info`() { + val activity = startOnSharedDeck() + + val logs = captureInfoLogs { activity.logInToAnkiWeb() } + + assertEquals(SHARED_DECK, activity.webView.url) + assertFalse(logs.any { it.contains(SHARED_DECK) }, "Deck URL was logged at info or above: $logs") + } + + private fun startOnSharedDeck(): SharedDecksActivity = + startSharedDecks().apply { + visit(getResourceString(R.string.shared_decks_url)) + visit(SHARED_DECK) + } + + private fun SharedDecksActivity.openAnkiWebLogin() { + receiveTooManyRequests() + clickAlertDialogButton { positiveButton } + visit(getResourceString(R.string.shared_decks_login_url)) + } + + private fun SharedDecksActivity.logInToAnkiWeb() { + openAnkiWebLogin() + CookieManager.getInstance().setCookie("https://ankiweb.net", "has_auth=1") + visit(AFTER_LOGIN) + } + + private fun SharedDecksActivity.visit(url: String) { + if (webView.copyBackForwardList().currentItem?.url != url) { + Shadows.shadowOf(webView).pushEntryToHistory(url) + } + webViewClient.doUpdateVisitedHistory(webView, url, false) + } + + private fun SharedDecksActivity.pressBack() { + onBackPressedDispatcher.onBackPressed() + if (!isFinishing) visit(requireNotNull(webView.url)) + } + private fun startSharedDecks(): SharedDecksActivity { val controller = Robolectric @@ -94,6 +231,28 @@ class SharedDecksActivityTest : RobolectricTest() { return controller.get() } + private fun captureInfoLogs(block: () -> Unit): List { + val messages = mutableListOf() + val tree = + object : Timber.Tree() { + override fun log( + priority: Int, + tag: String?, + message: String, + t: Throwable?, + ) { + if (priority >= Log.INFO) messages += message + } + } + Timber.plant(tree) + try { + block() + } finally { + Timber.uproot(tree) + } + return messages + } + private fun SharedDecksActivity.receiveTooManyRequests() { val response = mock { on { statusCode } doReturn HTTP_STATUS_TOO_MANY_REQUESTS } webViewClient.onReceivedHttpError(webView, mock(), response) @@ -104,4 +263,10 @@ class SharedDecksActivityTest : RobolectricTest() { private val SharedDecksActivity.lastLoadedUrl: String? get() = Shadows.shadowOf(webView).lastLoadedUrl + + companion object { + private const val SHARED_DECK = "https://ankiweb.net/shared/info/1234" + private const val DECK_DOWNLOAD = "https://ankiweb.net/svc/shared/download-deck/1234?t=key" + private const val AFTER_LOGIN = "https://ankiweb.net/account/login?afterAuth=1" + } } From 4467ae393e99dd5c3dc23a6430d079fc0542f467 Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:57:21 +0530 Subject: [PATCH 02/53] fix(shared-decks): update Back after the home button clears history The home button clears the history once the page has loaded, after Back was already worked out, so it relied on AnkiWeb reporting the page again. Update Back right after the clear. The tests now finish each page load and keep the current page on a clear, as a real WebView does. Assisted-by: Claude Opus 5.5 --- .../java/com/ichi2/anki/shareddeck/SharedDecksActivity.kt | 7 ++++++- .../com/ichi2/anki/shareddeck/SharedDecksActivityTest.kt | 5 +++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksActivity.kt index aff232c28c40..6bfa07aacda4 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/shareddeck/SharedDecksActivity.kt @@ -97,7 +97,7 @@ class SharedDecksActivity : AnkiActivity(R.layout.activity_shared_decks) { binding.webView.clearHistory() isReturningAfterLogin = false } - onBackPressedCallback.isEnabled = binding.webView.canGoBack() || pagesBeforeLogin.isNotEmpty() + updateBackPressedCallback() if (url?.startsWith(AFTER_LOGIN_URL) == true && isLoggedInToAnkiWeb()) { returnToSharedDecks() } @@ -111,6 +111,7 @@ class SharedDecksActivity : AnkiActivity(R.layout.activity_shared_decks) { if (shouldHistoryBeCleared) { binding.webView.clearHistory() shouldHistoryBeCleared = false + updateBackPressedCallback() } super.onPageFinished(view, url) } @@ -211,6 +212,10 @@ class SharedDecksActivity : AnkiActivity(R.layout.activity_shared_decks) { binding.webView.loadUrl(url) } + private fun updateBackPressedCallback() { + onBackPressedCallback.isEnabled = binding.webView.canGoBack() || pagesBeforeLogin.isNotEmpty() + } + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) internal val webViewClient = SharedDeckWebViewClient() diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/shareddeck/SharedDecksActivityTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/shareddeck/SharedDecksActivityTest.kt index 098638c9bb66..9f62dccc7034 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/shareddeck/SharedDecksActivityTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/shareddeck/SharedDecksActivityTest.kt @@ -212,6 +212,11 @@ class SharedDecksActivityTest : RobolectricTest() { Shadows.shadowOf(webView).pushEntryToHistory(url) } webViewClient.doUpdateVisitedHistory(webView, url, false) + webViewClient.onPageFinished(webView, url) + // Robolectric clears every history entry; real WebView keeps the current page. + if (webView.copyBackForwardList().size == 0) { + Shadows.shadowOf(webView).pushEntryToHistory(url) + } } private fun SharedDecksActivity.pressBack() { From 9d3bc91cab2ddd8d5e238ce13f04c21a2e7c696d Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:38:20 +0000 Subject: [PATCH 03/53] refactor(theme): move dark/black theme definitions to `:common:android` Part of 22694 Assisted-by: GPT-6.1-Sol xhigh Assisted-by: GPT-6-Astra xhigh --- .../ichi2/anki/settings/enums/NightTheme.kt | 5 +- AnkiDroid/src/main/res/values/theme_black.xml | 145 -------------- AnkiDroid/src/main/res/values/theme_dark.xml | 187 ------------------ .../java/com/ichi2/compose/theme/ThemeTest.kt | 10 +- .../src/main/res/color/faded_primary.xml | 0 .../src/main/res/values-v29/theme_dark.xml | 0 .../src/main/res/values/theme_black.xml | 142 +++++++++++++ .../src/main/res/values/theme_dark.xml | 184 +++++++++++++++++ 8 files changed, 333 insertions(+), 340 deletions(-) delete mode 100644 AnkiDroid/src/main/res/values/theme_black.xml delete mode 100644 AnkiDroid/src/main/res/values/theme_dark.xml rename {AnkiDroid => common/android}/src/main/res/color/faded_primary.xml (100%) rename {AnkiDroid => common/android}/src/main/res/values-v29/theme_dark.xml (100%) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/settings/enums/NightTheme.kt b/AnkiDroid/src/main/java/com/ichi2/anki/settings/enums/NightTheme.kt index 42493d7c59a3..482ca8c1a184 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/settings/enums/NightTheme.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/settings/enums/NightTheme.kt @@ -2,7 +2,6 @@ package com.ichi2.anki.settings.enums -import com.ichi2.anki.R import com.ichi2.anki.common.android.R as CommonR /** [CommonR.array.night_theme_values] */ @@ -10,6 +9,6 @@ enum class NightTheme( override val entryResId: Int, override val styleResId: Int, ) : Theme { - BLACK(CommonR.string.theme_black_value, R.style.Theme_Dark_Black), - DARK(CommonR.string.theme_dark_value, R.style.Theme_Dark), + BLACK(CommonR.string.theme_black_value, CommonR.style.Theme_Dark_Black), + DARK(CommonR.string.theme_dark_value, CommonR.style.Theme_Dark), } diff --git a/AnkiDroid/src/main/res/values/theme_black.xml b/AnkiDroid/src/main/res/values/theme_black.xml deleted file mode 100644 index 8ac13482e7f3..000000000000 --- a/AnkiDroid/src/main/res/values/theme_black.xml +++ /dev/null @@ -1,145 +0,0 @@ - - - - - - - - - - - - - - - - + + + + + + + + + + + + - - + + + From ec888859e34a8b64b7e7433c2f64968e5e15b6d1 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:13:36 +0100 Subject: [PATCH 42/53] fix(tags): preserve casing in row equality for renames For renaming: ``` parent::child -> `PARENT::child` ``` the leaf name remained 'child'. `TagName` treats the paths as equal so the change could be missed. The exact text is now compared, ensuring a future Compose screen would handle the update. Part of 10397 Assisted-by: GPT-6.1-Sol xhigh Assisted-by: GPT-6-Astra xhigh --- .../java/com/ichi2/anki/tags/ManageTagsViewModel.kt | 12 +++++++----- .../java/com/ichi2/anki/tags/ManageTagsTestUtils.kt | 5 +---- .../com/ichi2/anki/tags/ManageTagsViewModelTest.kt | 12 ++++++++++++ 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt index 79a513faf39c..e910a52b272b 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt @@ -272,7 +272,7 @@ class ManageTagsViewModel : ViewModel() { val fullTag = if (parentFullTag.isEmpty()) child.name else "$parentFullTag::${child.name}" result.add( TagListItemState( - fullTag = TagName.build(fullTag) ?: error("Invalid tag provided"), + fullTagName = fullTag, displayName = child.name, level = child.level - 1, hasChildren = child.childrenList.isNotEmpty(), @@ -345,21 +345,23 @@ class ManageTagsViewModel : ViewModel() { } /** - * A flattened representation of a tag for display in a RecyclerView. + * A flattened tag row. Equality includes the full spelling so UI updates reach Compose. * - * @param fullTag full hierarchical tag path, e.g. "science::biology" + * @param fullTagName full hierarchical tag path, e.g. "science::biology" * @param displayName leaf name only, e.g. "biology" * @param level tree depth (0 = top-level) */ data class TagListItemState( - val fullTag: TagName, + val fullTagName: String, val displayName: String, val level: Int, val hasChildren: Boolean, val collapsed: Boolean, ) { + val fullTag: TagName = requireNotNull(TagName.build(fullTagName)) + // cache the tag name to support fast case-insensitive searches - private val fullTagLower: String = fullTag.value.lowercase() + private val fullTagLower: String = fullTagName.lowercase() /** Case-insensitive check against the full tag path. [query] must be pre-lowercased. */ fun containsLowercase(query: String): Boolean = fullTagLower.contains(query) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsTestUtils.kt b/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsTestUtils.kt index 4caecf27dec9..9c1fb50e4c9c 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsTestUtils.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsTestUtils.kt @@ -2,9 +2,6 @@ package com.ichi2.anki.tags -/** Unwraps [TagListItemState.fullTag] to a plain [String] for test assertions */ -internal val TagListItemState.fullTagName: String get() = fullTag.value - internal val ManageTagsState.Content.visibleTagNames: List get() = visibleNodes.map { it.fullTagName } @@ -30,7 +27,7 @@ internal fun tagListItem( hasChildren: Boolean, collapsed: Boolean, ) = TagListItemState( - fullTag = TagName.asValid(fullTag), + fullTagName = fullTag, displayName = displayName, level = level, hasChildren = hasChildren, diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt index 0e5e398dd9e7..2172bae81666 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt @@ -31,6 +31,7 @@ import kotlin.system.measureTimeMillis import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertIs +import kotlin.test.assertNotEquals import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue @@ -43,6 +44,17 @@ import kotlin.time.Duration.Companion.seconds "Preserve RobolectricTest's scoped CollectionManager dispatcher when switching to JvmTest; tag operations must finish before assertions and teardown.", ) class ManageTagsViewModelTest : RobolectricTest() { + @Test + fun `row equality preserves parent spelling while tag identity ignores case`() { + val original = tagListItem("parent::child", "child", 1, hasChildren = false, collapsed = false) + val renamed = original.copy(fullTagName = "PARENT::child") + + assertEquals(original.fullTag, renamed.fullTag) + assertEquals(original.displayName, renamed.displayName) + assertNotEquals(original, renamed) + assertEquals("PARENT::child", renamed.fullTag.value) + } + @Test fun `tagExists follows backend identity for hidden Unicode tags`() = runTest { From b043592e75eb27307c0c7ea2ec990a7a34984a46 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 25 Oct 2026 12:01:33 +0000 Subject: [PATCH 43/53] fix(tags): searches no longer expand subtrees towards leaves In: `a::b::c`, searching for `b` no longer expands `a::b::c` But if `a` was collapsed, it would be expanded Part of 10397 Assisted-by: GPT-6-Astra xhigh --- .../ichi2/anki/tags/ManageTagsViewModel.kt | 34 +++++++++++------ .../com/ichi2/anki/tags/FlattenTreeTest.kt | 38 +++++++++++++++++++ .../anki/tags/ManageTagsViewModelTest.kt | 28 ++++++++++++++ 3 files changed, 88 insertions(+), 12 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt index e910a52b272b..a419530e8855 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt @@ -309,7 +309,8 @@ class ManageTagsViewModel : ViewModel() { /** * Filters a flat tag list to items matching [searchQuery] (case-insensitive substring), * plus their ancestors to preserve hierarchy. - * Collapsed nodes are expanded if their children match, ensuring leaf matches are visible. + * Expands ancestors only when a match reaches a child's own name. A match inherited + * from a parent's name preserves the subtree's existing collapsed state. * * O(n*d) where n = number of tags and d = maximum tag depth. */ @@ -320,26 +321,28 @@ class ManageTagsViewModel : ViewModel() { ): List { val queryLowercase = searchQuery.lowercase() val visibleTags = mutableSetOf() - val parentsWithVisibleChildren = mutableSetOf() + val ancestorsToExpand = mutableSetOf() for (item in allNodes) { if (item.containsLowercase(queryLowercase)) { visibleTags.add(item.fullTag.value) + val expandAncestors = item.matchesOwnComponent(queryLowercase) for (ancestor in item.fullTag.ancestors) { visibleTags.add(ancestor) - parentsWithVisibleChildren.add(ancestor) + if (expandAncestors) ancestorsToExpand.add(ancestor) } } } - return allNodes - .filter { it.fullTag.value in visibleTags } - .map { - // expand a collapsed parent if a child matches the search - if (it.collapsed && it.fullTag.value in parentsWithVisibleChildren) { - it.copy(collapsed = false) - } else { - it + val filteredNodes = + allNodes + .filter { it.fullTag.value in visibleTags } + .map { + if (it.collapsed && it.fullTag.value in ancestorsToExpand) { + it.copy(collapsed = false) + } else { + it + } } - } + return applyCollapsedVisibility(filteredNodes) } } } @@ -365,6 +368,13 @@ data class TagListItemState( /** Case-insensitive check against the full tag path. [query] must be pre-lowercased. */ fun containsLowercase(query: String): Boolean = fullTagLower.contains(query) + + /** Whether pre-lowercased [query] reaches this tag's own name or its preceding separator. */ + fun matchesOwnComponent(query: String): Boolean { + val matchStart = fullTagLower.lastIndexOf(query) + // Include the separator so a query such as "parent::" reveals its immediate children. + return matchStart >= 0 && matchStart + query.length > fullTagLower.lastIndexOf("::") + } } sealed class ManageTagsState { diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/tags/FlattenTreeTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/tags/FlattenTreeTest.kt index 42d3f5716c48..5a299f1e10cb 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/tags/FlattenTreeTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/tags/FlattenTreeTest.kt @@ -360,6 +360,44 @@ class FlattenTreeTest { MatcherAssert.assertThat(result[1].fullTag.value, Matchers.equalTo("d")) } + @Test + fun `search only expands ancestors needed to reach the matching component`() { + val items = + listOf( + tagListItem("a", "a", level = 0, hasChildren = true, collapsed = true), + tagListItem("a::b", "b", level = 1, hasChildren = true, collapsed = true), + tagListItem("a::b::c", "c", level = 2, hasChildren = true, collapsed = true), + tagListItem("a::b::c::d", "d", level = 3, hasChildren = false, collapsed = false), + ) + for ((query, expectedNames) in listOf( + "a" to listOf("a"), + "b" to listOf("a", "a::b"), + "A::B" to listOf("a", "a::b"), + "a::" to listOf("a", "a::b"), + "c" to listOf("a", "a::b", "a::b::c"), + "::" to items.map { it.fullTagName }, + )) { + val result = ManageTagsViewModel.applySearchFilter(items, query) + MatcherAssert.assertThat("Query: $query", result.map { it.fullTagName }, Matchers.equalTo(expectedNames)) + MatcherAssert.assertThat(result.dropLast(1).all { !it.collapsed }, Matchers.equalTo(true)) + MatcherAssert.assertThat(result.last().collapsed, Matchers.equalTo(result.last().hasChildren)) + } + } + + @Test + fun `a match in both parent and child names reveals the child`() { + val items = + listOf( + tagListItem("a", "a", level = 0, hasChildren = true, collapsed = true), + tagListItem("a::cat", "cat", level = 1, hasChildren = true, collapsed = true), + tagListItem("a::cat::dog", "dog", level = 2, hasChildren = false, collapsed = false), + ) + val result = ManageTagsViewModel.applySearchFilter(items, "a") + MatcherAssert.assertThat(result.map { it.fullTagName }, Matchers.equalTo(listOf("a", "a::cat"))) + MatcherAssert.assertThat(result[0].collapsed, Matchers.equalTo(false)) + MatcherAssert.assertThat(result[1].collapsed, Matchers.equalTo(true)) + } + @Test fun `applySearchFilter includes ancestors`() { val items = diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt index 2172bae81666..6609b500ae5f 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt @@ -452,6 +452,34 @@ class ManageTagsViewModelTest : RobolectricTest() { } } + @Test + fun `search preserves collapsed matches and allows manual expansion`() = + runTest { + addTags("a::b::c::d", "other") + withViewModel { + filter("a") + assertEquals(listOf("a"), loadedState.visibleTagNames) + assertTrue(loadedState.visibleNodes.first().collapsed) + + toggleCollapsed("a").join() + assertEquals(listOf("a", "a::b"), loadedState.visibleTagNames) + assertTrue(loadedState.visibleNodes[1].collapsed) + + filter("c") + assertEquals(listOf("a", "a::b", "a::b::c"), loadedState.visibleTagNames) + + filter("") + assertEquals(listOf("a", "a::b", "other"), loadedState.visibleTagNames) + assertTrue(loadedState.visibleNodes[1].collapsed) + + filter("a") + toggleCollapsed("a").join() + assertEquals(listOf("a"), loadedState.visibleTagNames) + refreshTags().join() + assertEquals(listOf("a"), loadedState.visibleTagNames) + } + } + @Test fun `filter shows ancestors of matching nested tags`() = runTest { From 7a96e24cd2d379e66de9567d325c8ba5eb3ff138 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 25 Oct 2026 00:14:23 +0100 Subject: [PATCH 44/53] feat(tags): optimistic updates of 'collapsed' state * flip `collapsed` and update the visible rows before backend access * This means taps may be queued, use `previousSave` to enqueue backend updates * All other operations wait for the save The UI is not reverted on failure Part of 10397 Assisted-by: GPT-6-Astra xhigh --- .../ichi2/anki/tags/ManageTagsViewModel.kt | 67 +++++++++++++------ .../anki/tags/ManageTagsViewModelTest.kt | 57 ++++++++++++++++ 2 files changed, 102 insertions(+), 22 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt index a419530e8855..53af2e0e5b95 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt @@ -55,6 +55,7 @@ class ManageTagsViewModel : ViewModel() { private var tagList: List = emptyList() private var tagOperation: Job? = null + private var expansionSave: Job? = null init { refreshTags() @@ -87,44 +88,59 @@ class ManageTagsViewModel : ViewModel() { /** Checks the complete tag tree, including tags hidden by search or collapsed ancestors. */ fun tagExists(tag: TagName): Boolean = tagList.any { it.fullTag == tag } - /** Returns the visible node for [tag], or null with a warning if not found or not loaded */ - private fun findVisibleNode(tag: TagName): TagListItemState? { + /** Finds a tag in the cached hierarchy. */ + private fun findTagNode(tag: TagName): TagListItemState? { val loaded = state.value as? ManageTagsState.Content if (loaded == null) { - Timber.w("findVisibleNode called while not loaded") + Timber.w("findTagNode called while not loaded") return null } - return loaded.visibleNodes.firstOrNull { it.fullTag == tag }.also { + return tagList.firstOrNull { it.fullTag == tag }.also { if (it == null) { - Timber.w("Tag not found in visible nodes") + Timber.w("Tag not found in hierarchy") Timber.d("Tag: %s", tag) } } } /** - * Expands or collapses [tag] in the tree. Persists the state to the backend. + * Expands or collapses [tag] immediately, then saves the preference without a busy state. + * Saves run in tap order; a save failure leaves the visible hierarchy intact and reports the error. * * PERF: ~55ms to process 10k+ tags on my M1; ~350ms to process 10k+ tags in CI * * @see Tags.setCollapsed */ - fun toggleCollapsed(tag: TagName) = - launchTagOperation { - val node = - findVisibleNode(tag) ?: run { - messageQueue.enqueue(UserMessage.UnexpectedError) - return@launchTagOperation - } - val newCollapsed = !node.collapsed - withCol { tags.setCollapsed(node.fullTag, newCollapsed) } - // Update the in-memory list with the new collapsed state - tagList = - tagList.map { - if (it.fullTag == tag) it.copy(collapsed = newCollapsed) else it + fun toggleCollapsed(tag: TagName): Job { + tagOperation?.takeUnless { it.isCompleted }?.let { return it } + val previousSave = expansionSave + val operation = + viewModelScope.launch(start = CoroutineStart.LAZY) { + val node = + findTagNode(tag) ?: run { + messageQueue.enqueue(UserMessage.UnexpectedError) + return@launch + } + val newCollapsed = !node.collapsed + tagList = + tagList.map { + if (it.fullTag == tag) it.copy(collapsed = newCollapsed) else it + } + updateState { it.copy(visibleNodes = computeVisibleNodes(searchQuery.value), error = null) } + previousSave?.join() + try { + withCol { tags.setCollapsed(node.fullTag, newCollapsed) } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.w(e, "Saving tag expansion failed") + updateState { it.copy(error = e) } } - updateState { it.copy(visibleNodes = computeVisibleNodes(searchQuery.value)) } - } + } + expansionSave = operation + operation.start() + return operation + } /** * Deletes [tag] and its children from all notes. @@ -200,7 +216,14 @@ class ManageTagsViewModel : ViewModel() { } // set 'tagOperation' before executing - val operation = viewModelScope.launch(start = CoroutineStart.LAZY) { runTagOperation(block) } + val operation = + viewModelScope.launch(start = CoroutineStart.LAZY) { + runTagOperation { + // Finish preference saves before a reload or a mutation can rename/remove their tags. + expansionSave?.join() + block() + } + } tagOperation = operation // calls from observers will now find this job operation.start() diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt index 6609b500ae5f..b1a0b9db54b7 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt @@ -503,6 +503,63 @@ class ManageTagsViewModelTest : RobolectricTest() { } } + @Test + fun `expansion is immediate and rapid taps persist in order without blocking actions`() = + runTest { + addTags("science::biology", "history::ancient") + withViewModel { + withQueuedCollectionAccess { + val expand = toggleCollapsed("science") + assertFalse(expand.isCompleted) + assertFalse(loadedState.isWorking) + assertTrue("science::biology" in loadedState.visibleTagNames) + + val collapse = toggleCollapsed("science") + assertFalse(collapse.isCompleted) + assertFalse(loadedState.isWorking) + assertFalse("science::biology" in loadedState.visibleTagNames) + + toggleCollapsed("history") + assertTrue("history::ancient" in loadedState.visibleTagNames) + // Reloading must wait for every queued preference save. + refreshTags().join() + } + assertEquals(listOf("history", "history::ancient", "science"), loadedState.visibleTagNames) + assertFalse(loadedState.isWorking) + } + } + + @Test + fun `deleting a tag waits for expansion saves so they cannot recreate it`() = + runTest { + addTags("science::biology", "history") + withViewModel { + withQueuedCollectionAccess { + toggleCollapsed("science") + toggleCollapsed("science") + removeTag("science").join() + } + assertEquals(listOf("history"), loadedState.visibleTagNames) + assertEquals(listOf("history"), col.tags.all()) + } + } + + @Test + fun `failed expansion save retains the visible hierarchy and reports the error`() = + runTest { + addTags("science::biology") + withViewModel { + withLockedCollection { toggleCollapsed("science").join() } + assertEquals(listOf("science", "science::biology"), loadedState.visibleTagNames) + assertFalse(loadedState.isWorking) + assertIs(loadedState.error) + toggleCollapsed("science").join() + assertNull(loadedState.error) + refreshTags().join() + assertEquals(listOf("science"), loadedState.visibleTagNames) + } + } + @Test fun `toggleCollapsed expands a collapsed tag`() = runTest { From 5ced01ea037615118cf10c4ae254265f3164b8fc Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:30:18 +0000 Subject: [PATCH 45/53] fix(tags): better handle 'collapse/expand' during search When `Science::Biology::Cells` is collapsed. Searching for `Cells` automatically opens the tree When tapping `Science`: * Before, it used the saved state, rather than the visible state, so appeared as a no-op. * Now, it toggles the displayed state and remembers that the tag was explicitly collapsed. Part of 10397 Assisted-by: GPT-6-Astra xhigh --- .../ichi2/anki/tags/ManageTagsViewModel.kt | 17 +++++-- .../anki/tags/ManageTagsViewModelTest.kt | 48 +++++++++++++++++++ 2 files changed, 61 insertions(+), 4 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt index 53af2e0e5b95..8d5b29a34c40 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt @@ -54,6 +54,9 @@ class ManageTagsViewModel : ViewModel() { /** Cached flat list of all tags, rebuilt when the backend tree changes */ private var tagList: List = emptyList() + /** Explicit collapses override search expansion until the query changes. */ + private val searchCollapsedTags = mutableSetOf() + private var tagOperation: Job? = null private var expansionSave: Job? = null @@ -74,6 +77,7 @@ class ManageTagsViewModel : ViewModel() { * of having a filtered tag selected. */ fun filter(query: String) { + if (query != searchQuery.value) searchCollapsedTags.clear() searchQuery.value = query updateState { loaded -> loaded.copy( @@ -88,14 +92,14 @@ class ManageTagsViewModel : ViewModel() { /** Checks the complete tag tree, including tags hidden by search or collapsed ancestors. */ fun tagExists(tag: TagName): Boolean = tagList.any { it.fullTag == tag } - /** Finds a tag in the cached hierarchy. */ + /** Finds a tag using its displayed expansion state when visible. */ private fun findTagNode(tag: TagName): TagListItemState? { val loaded = state.value as? ManageTagsState.Content if (loaded == null) { Timber.w("findTagNode called while not loaded") return null } - return tagList.firstOrNull { it.fullTag == tag }.also { + return (loaded.visibleNodes.firstOrNull { it.fullTag == tag } ?: tagList.firstOrNull { it.fullTag == tag }).also { if (it == null) { Timber.w("Tag not found in hierarchy") Timber.d("Tag: %s", tag) @@ -122,6 +126,9 @@ class ManageTagsViewModel : ViewModel() { return@launch } val newCollapsed = !node.collapsed + if (searchQuery.value.isNotBlank()) { + if (newCollapsed) searchCollapsedTags.add(node.fullTag) else searchCollapsedTags.remove(node.fullTag) + } tagList = tagList.map { if (it.fullTag == tag) it.copy(collapsed = newCollapsed) else it @@ -264,7 +271,7 @@ class ManageTagsViewModel : ViewModel() { applyCollapsedVisibility(tagList) } else { // collapsed nodes can be re-expanded if searchQuery matches the leaf nodes - applySearchFilter(tagList, searchQuery) + applySearchFilter(tagList, searchQuery, searchCollapsedTags) } /** Applies [transform] only if the current state is [ManageTagsState.Content]; no-ops otherwise */ @@ -334,6 +341,7 @@ class ManageTagsViewModel : ViewModel() { * plus their ancestors to preserve hierarchy. * Expands ancestors only when a match reaches a child's own name. A match inherited * from a parent's name preserves the subtree's existing collapsed state. + * [manuallyCollapsedTags] prevents search from reopening tags collapsed by the user. * * O(n*d) where n = number of tags and d = maximum tag depth. */ @@ -341,6 +349,7 @@ class ManageTagsViewModel : ViewModel() { fun applySearchFilter( allNodes: List, searchQuery: String, + manuallyCollapsedTags: Set = emptySet(), ): List { val queryLowercase = searchQuery.lowercase() val visibleTags = mutableSetOf() @@ -359,7 +368,7 @@ class ManageTagsViewModel : ViewModel() { allNodes .filter { it.fullTag.value in visibleTags } .map { - if (it.collapsed && it.fullTag.value in ancestorsToExpand) { + if (it.collapsed && it.fullTag.value in ancestorsToExpand && it.fullTag !in manuallyCollapsedTags) { it.copy(collapsed = false) } else { it diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt index b1a0b9db54b7..f4948ce108cc 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt @@ -480,6 +480,54 @@ class ManageTagsViewModelTest : RobolectricTest() { } } + @Test + fun `search-expanded ancestors can collapse and expand regardless of saved state`() = + runTest { + addTags("parent::child") + for (initiallyCollapsed in listOf(true, false)) { + col.tags.setCollapsed(TagName.asValid("parent"), initiallyCollapsed) + withViewModel { + filter("child") + assertFalse(loadedState.visibleNodes.first().collapsed) + + toggleCollapsed("PARENT").join() + assertEquals(listOf("parent"), loadedState.visibleTagNames) + assertTrue(loadedState.visibleNodes.single().collapsed) + filter("child") + refreshTags().join() + assertEquals(listOf("parent"), loadedState.visibleTagNames) + assertTrue(ManageTagsViewModel.flattenTree(col.tags.tree()).first().collapsed) + + toggleCollapsed("parent").join() + assertEquals(listOf("parent", "parent::child"), loadedState.visibleTagNames) + assertFalse(loadedState.visibleNodes.first().collapsed) + assertFalse(ManageTagsViewModel.flattenTree(col.tags.tree()).first().collapsed) + } + } + } + + @Test + fun `changing search reveals matches beneath manually collapsed ancestors`() = + runTest { + addTags("parent::child::leaf") + withViewModel { + filter("leaf") + toggleCollapsed("parent::child").join() + assertEquals(listOf("parent", "parent::child"), loadedState.visibleTagNames) + assertTrue(loadedState.visibleNodes.last().collapsed) + + filter("lea") + assertEquals(listOf("parent", "parent::child", "parent::child::leaf"), loadedState.visibleTagNames) + assertFalse(loadedState.visibleNodes[1].collapsed) + + filter("") + assertEquals(listOf("parent"), loadedState.visibleTagNames) + toggleCollapsed("parent").join() + assertEquals(listOf("parent", "parent::child"), loadedState.visibleTagNames) + assertTrue(loadedState.visibleNodes.last().collapsed) + } + } + @Test fun `filter shows ancestors of matching nested tags`() = runTest { From 47010a84715cfb69c91c2aba2f3b491a0d8b745b Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:03:43 +0000 Subject: [PATCH 46/53] fix(browser): ANR on saved saved search previews Limit saved search names & queries to 500 characters, to prevent ANRs. Fixes 22820 Assisted-by: GPT-6.1-Sol xhigh --- .../browser/search/StandardSearchFragment.kt | 8 +++-- .../res/layout/view_saved_search_item.xml | 6 +++- .../StandardSearchFragmentScreenshotTest.kt | 30 +++++++++++++++++++ 3 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/browser/search/StandardSearchFragmentScreenshotTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/StandardSearchFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/StandardSearchFragment.kt index 1a131aa6ed40..0ed094a1d78c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/StandardSearchFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/StandardSearchFragment.kt @@ -23,6 +23,7 @@ import com.ichi2.anki.R import com.ichi2.anki.browser.SearchHistory.SearchHistoryEntry import com.ichi2.anki.browser.search.CardBrowserSearchViewModel.SearchHistoryItems import com.ichi2.anki.browser.toUserSpannable +import com.ichi2.anki.common.utils.ellipsize import com.ichi2.anki.databinding.FragmentStandardSearchBinding import com.ichi2.anki.databinding.ViewSavedSearchItemBinding import com.ichi2.anki.databinding.ViewSearchHistoryItemBinding @@ -263,8 +264,9 @@ class StandardSearchFragment : } val item = getItem(position)!! - binding.title.text = item.name - binding.content.text = item.query + // Bound the preview before layout to avoid ANRs on very large saved searches. + binding.title.text = item.name.ellipsize(MAX_SAVED_SEARCH_PREVIEW_LENGTH) + binding.content.text = item.query.ellipsize(MAX_SAVED_SEARCH_PREVIEW_LENGTH) binding.root.setOnClickListener { viewModel.submitSavedSearch(item) } binding.insertSavedSearch.setOnClickListener { viewModel.applySavedSearch(item) } @@ -302,6 +304,8 @@ class StandardSearchFragment : companion object { const val TAG = "STANDARD" + + private const val MAX_SAVED_SEARCH_PREVIEW_LENGTH = 500 } } diff --git a/AnkiDroid/src/main/res/layout/view_saved_search_item.xml b/AnkiDroid/src/main/res/layout/view_saved_search_item.xml index 0fa947b3920b..c7a1e0f5713c 100644 --- a/AnkiDroid/src/main/res/layout/view_saved_search_item.xml +++ b/AnkiDroid/src/main/res/layout/view_saved_search_item.xml @@ -30,6 +30,8 @@ android:orientation="vertical" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:maxLines="2" + android:ellipsize="end" android:gravity="start|center_vertical" android:textAlignment="viewStart" android:textSize="18sp" @@ -41,6 +43,8 @@ android:orientation="vertical" android:layout_width="wrap_content" android:layout_height="wrap_content" + android:maxLines="3" + android:ellipsize="end" android:gravity="start|center_vertical" android:textAlignment="viewStart" android:drawablePadding="10dp" @@ -64,4 +68,4 @@ - \ No newline at end of file + diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/browser/search/StandardSearchFragmentScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/browser/search/StandardSearchFragmentScreenshotTest.kt new file mode 100644 index 000000000000..5adfb474b4df --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/browser/search/StandardSearchFragmentScreenshotTest.kt @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.browser.search + +import com.ichi2.anki.ScreenshotTest +import com.ichi2.anki.browser.SearchHistory +import com.ichi2.anki.browser.withCardBrowserFragment +import org.junit.Test + +class StandardSearchFragmentScreenshotTest : ScreenshotTest() { + @Test + fun `large saved searches have bounded previews - Issue 22820`() { + SearchHistory().clear() + runTest { + SavedSearches.saveToConfig( + listOf( + SavedSearch("Long name ".repeat(10_000), "nid:" + (1..10_000).joinToString(",")), + SavedSearch("Short name", "tag:short"), + ), + ) + } + + withCardBrowserFragment(useSearchView = true) { + requireNotNull(searchView).show() + advanceRobolectricLooper() + + captureScreen("large_saved_search_previews") + } + } +} From d06fb5d32a0a09111158ecd06bdd6e216adf86aa Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:09:39 +0000 Subject: [PATCH 47/53] build: bump backend to 0.1.74-anki26.09.3 Assisted-by: GPT-6.1-Sol xhigh --- .../java/com/ichi2/anki/pages/PageWebViewClientAssetTest.kt | 2 +- gradle/libs.versions.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/pages/PageWebViewClientAssetTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/pages/PageWebViewClientAssetTest.kt index 85aeaf23fe67..8deba70cd909 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/pages/PageWebViewClientAssetTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/pages/PageWebViewClientAssetTest.kt @@ -25,7 +25,7 @@ class PageWebViewClientAssetTest : PageWebViewClientTestBase() { // Pin the entire declaration, including the bootstrap script hash, to require review on backend updates. // Backend updates are expected to change this hash and fail this test until the new declaration is reviewed. - val expectedPolicy = "script-src 'self' 'sha256-1eO5v3fJDmbaWxUNa8DWv729RR8kpfPqIe0VFTiVNds='" + val expectedPolicy = "script-src 'self' 'sha256-KgVk6DKxcC2FwNZfJ2E4h8ryzLcg38Fu9Id96eo1C5A='" assertEquals( listOf(expectedPolicy), policies, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 56839c84d0c3..d1c4cd31d9fb 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -73,7 +73,7 @@ androidxWebkit = "1.16.0" # https://developer.android.com/jetpack/androidx/releases/work androidxWork = "2.12.0" # https://github.com/ankidroid/Anki-Android-Backend/ -ankiBackend = '0.1.73-anki26.09.3' +ankiBackend = '0.1.74-anki26.09.3' autoService = "1.1.1" autoServiceAnnotations = "1.1.1" # https://github.com/skydoves/ColorPickerView/releases From 2a3381f8b45ff4e2fd89bb3e400e75bd602ccae3 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 24 Oct 2026 21:30:29 +0100 Subject: [PATCH 48/53] fix(tags): workaround: report tag moves which are no-ops Report `UnusedTagNotMoved` when the backend fails to move a tag subtree as it is unused. Part of 10397 Workaround for https://redirect.github.com/ankitects/anki/issues/5870 Assisted-by: GPT-6.1-Sol xhigh Assisted-by: GPT-6-Astra xhigh --- .../ichi2/anki/tags/ManageTagsViewModel.kt | 15 +++++++- .../main/java/com/ichi2/anki/tags/TagName.kt | 6 ++++ .../com/ichi2/anki/tags/TagTreeOperations.kt | 3 +- .../anki/tags/ManageTagsViewModelTest.kt | 36 ++++++++++++++++--- 4 files changed, 52 insertions(+), 8 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt index 8d5b29a34c40..9b2e41083791 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt @@ -193,7 +193,14 @@ class ManageTagsViewModel : ViewModel() { ) = launchTagOpAndRefresh { Timber.i("Moving tag") val result = undoableOp { moveTagTree(tag, newParent) } - messageQueue.enqueue(TagMoved(result.count)) + val currentParent = TagName.build(tag.value.substringBeforeLast("::", "")) + messageQueue.enqueue( + if (result.count == 0 && currentParent != newParent) { + UserMessage.UnusedTagNotMoved + } else { + TagMoved(result.count) + }, + ) } private suspend fun loadTags() { @@ -445,6 +452,12 @@ sealed interface UserMessage { val notesAffected: Int, ) : UserMessage + /** + * The backend leaves an entirely unused subtree in place when asked to move it. + * Remove this message and its handling when [Anki issue 5870](https://github.com/ankitects/anki/issues/5870) is fixed. + */ + data object UnusedTagNotMoved : UserMessage + /** @param count number of unused tags removed from the collection */ data class ClearedUnusedTags( val count: Int, diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/tags/TagName.kt b/AnkiDroid/src/main/java/com/ichi2/anki/tags/TagName.kt index 49c72c78bc03..920c487fb0a4 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/tags/TagName.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/tags/TagName.kt @@ -97,6 +97,12 @@ class TagName private constructor( } } +/** Returns this tag's path under [parent], or at the top level when the parent is null. */ +internal fun TagName.withParent(parent: TagName?): TagName { + val leaf = value.substringAfterLast("::") + return requireNotNull(TagName.build(parent?.let { "${it.value}::$leaf" } ?: leaf)) +} + // Extension methods serve two purposes: removing `.value` from the call site // and ensuring that collection operation lambdas return `OpChanges` // Having Timber.d as a final call means the Lambda returns `Unit`, which is converted to `Any` diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/tags/TagTreeOperations.kt b/AnkiDroid/src/main/java/com/ichi2/anki/tags/TagTreeOperations.kt index 51158409f324..bd24df20a8e8 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/tags/TagTreeOperations.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/tags/TagTreeOperations.kt @@ -40,8 +40,7 @@ internal fun Collection.moveTagTree( newParent: TagName?, ): OpChangesWithCount { require(newParent == null || !newParent.isSameOrDescendantOf(tag)) { "A tag cannot be moved under itself" } - val leaf = tag.value.substringAfterLast("::") - val destination = TagName.build(newParent?.let { "${it.value}::$leaf" } ?: leaf)!! + val destination = tag.withParent(newParent) if (destination == tag) return opChangesWithCount { } // Moving one subtree is a prefix rename. The backend reparent operation also rejects // valid sibling prefixes (e.g. parent::a beneath parent::ab). diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt index f4948ce108cc..786314fc25e7 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt @@ -996,6 +996,28 @@ class ManageTagsViewModelTest : RobolectricTest() { } } + /** + * Detects a fix for [Anki issue 5870](https://github.com/ankitects/anki/issues/5870) so + * [UserMessage.UnusedTagNotMoved] and its handling can be removed. Call native reparent directly + * because [moveTagTree] currently implements moves using rename. + */ + @Test + fun `backend reparent still requires UnusedTagNotMoved`() = + runTest { + val sourceTags = setOf("parent::child", "parent::child::leaf") + addUnusedTags(*sourceTags.toTypedArray()) + assertEquals(sourceTags, col.tags.all().toSet()) + + col.tags.reparent(listOf("parent::child"), "target") + + assertEquals( + sourceTags, + col.tags.all().toSet(), + "Anki issue 5870 changed: update moveTagTree to use the fixed backend operation " + + "and remove ${UserMessage.UnusedTagNotMoved}.", + ) + } + @Test fun `moving an unused subtree preserves tags and the previous undo action`() = runTest { @@ -1003,11 +1025,15 @@ class ManageTagsViewModelTest : RobolectricTest() { withViewModel { val before = col.tags.all().toSet() val undoBefore = col.undoStatus() - moveTag("parent::child", "target").join() - assertThat(col.tags.all().toSet(), equalTo(before)) - assertEquals(undoBefore.undo, col.undoStatus().undo) - assertEquals(undoBefore.redo, col.undoStatus().redo) - assertThat(pendingMessages.value.single().message, equalTo(UserMessage.TagMoved(0))) + for (destination in listOf("target", null)) { + moveTag("parent::child", destination).join() + assertThat(col.tags.all().toSet(), equalTo(before)) + assertEquals(undoBefore.undo, col.undoStatus().undo) + assertEquals(undoBefore.redo, col.undoStatus().redo) + val pending = pendingMessages.value.single() + assertThat(pending.message, equalTo(UserMessage.UnusedTagNotMoved)) + messageShown(pending.id) + } col.undo() assertEquals(before, col.getNote(note.id).tags.toSet()) col.redo() From 1cd42b5288aac2c06e77c7d992667985e1fe9366 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 27 Oct 2026 17:56:05 +0000 Subject: [PATCH 49/53] fix(browser): use simple line-break mode to fix ANR Fixes 22827 Assisted-by: GPT-6.1-Sol xhigh --- .../res/layout/view_browser_column_cell.xml | 3 ++- .../ichi2/anki/CardBrowserScreenshotTest.kt | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/AnkiDroid/src/main/res/layout/view_browser_column_cell.xml b/AnkiDroid/src/main/res/layout/view_browser_column_cell.xml index 05478ca1353b..00b66502f784 100644 --- a/AnkiDroid/src/main/res/layout/view_browser_column_cell.xml +++ b/AnkiDroid/src/main/res/layout/view_browser_column_cell.xml @@ -4,8 +4,9 @@ android:layout_width="0dp" android:layout_height="match_parent" android:layout_weight="1" + android:breakStrategy="simple" android:paddingStart="4dp" android:paddingEnd="4dp" android:paddingVertical="1dp" android:layout_gravity="top" -/> \ No newline at end of file +/> diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserScreenshotTest.kt index b093d5cf8e76..a47cdb1b716f 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserScreenshotTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserScreenshotTest.kt @@ -13,6 +13,8 @@ import androidx.core.view.WindowInsetsCompat.Type.displayCutout import androidx.core.view.WindowInsetsCompat.Type.navigationBars import androidx.core.view.WindowInsetsCompat.Type.statusBars import androidx.recyclerview.widget.RecyclerView +import com.ichi2.anki.browser.CardBrowserColumn.ANSWER +import com.ichi2.anki.browser.setColumn import com.ichi2.anki.common.utils.dp import com.ichi2.anki.ui.RecyclerFastScroller import com.ichi2.testutils.HIDDEN_GESTURE_BAR @@ -29,6 +31,23 @@ import org.robolectric.RuntimeEnvironment * `./gradlew :AnkiDroid:verifyRoborazziPlayDebug -Pscreenshot --tests "com.ichi2.anki.CardBrowserScreenshotTest"` */ class CardBrowserScreenshotTest : ScreenshotTest() { + @Test + fun `long browser cells wrap when collapsed and expanded - Issue 22827`() = + runTest { + val text = "A long paragraph of card text. ".repeat(2000) + addBasicNote(text, text) + val browser = getBrowserWithNotes(noteCount = 0) + browser.viewModel.setColumn(1, ANSWER) + advanceRobolectricLooper() + browser.waitForSearchResults() + + for ((truncated, state) in listOf(true to "collapsed", false to "expanded", true to "collapsed_again")) { + browser.viewModel.setTruncated(truncated).join() + advanceRobolectricLooper() + captureScreen("long_cells_$state") + } + } + @Test fun cardBrowserWith30Notes() = withCardBrowser(noteCount = 50) { browser -> From 9bf725b93d5dcd2904a33ba939696d6cfd42df6a Mon Sep 17 00:00:00 2001 From: Rizos-Vassili Date: Wed, 28 Oct 2026 18:06:02 +0100 Subject: [PATCH 50/53] test(tags): cover clearing the duplicate tag error Typing an existing tag in the add tag dialog shows an error. This adds a test that typing a new tag afterwards clears it and enables OK again. The dialog setup moves into a helper shared with the existing test. --- .../com/ichi2/anki/dialogs/tags/TagsDialog.kt | 1 - .../ichi2/anki/dialogs/tags/TagsDialogTest.kt | 67 ++++++++++++++----- 2 files changed, 50 insertions(+), 18 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 c989d471ae96..23133ea096a0 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 @@ -389,7 +389,6 @@ class TagsDialog : AnalyticsDialogFragment { * @param prefixTag: The tag to be prefilled into the EditText section. A trailing '::' will be appended. */ @NeedsTest("The prefixTag should be prefilled properly") - @NeedsTest("Entering a new valid tag should clear the error and enable the OK button") private fun createAddTagDialog(prefixTag: String?) { val addTagDialog = AlertDialog diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/tags/TagsDialogTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/tags/TagsDialogTest.kt index a94b1685112d..20ddb0121b6f 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/tags/TagsDialogTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/tags/TagsDialogTest.kt @@ -923,23 +923,7 @@ class TagsDialogTest : RobolectricTest() { fun `entering an existing tag shows an error and disables OK`() { val existingTag = "a" val validTag = "b" - runTagsDialogScenario( - args = - TagsDialog(ParametersUtils.whatever()) - .withTestArguments( - type = TagsDialog.DialogType.EDIT_TAGS, - checkedTags = arrayListOf(), - allTags = listOf(existingTag), - ).requireArguments(), - factory = - TagsDialogFactory( - listener = Mockito.mock(TagsDialogListener::class.java), - ), - ) { tagsDialog: TagsDialog -> - tagsDialog.binding.toolbar.root.menu - .performIdentifierAction(R.id.tags_dialog_action_add, 0) - advanceRobolectricLooper() - val addTagDialog = ShadowDialog.getLatestDialog() as AlertDialog + withAddTagDialog(allTags = listOf(existingTag)) { addTagDialog -> // OK starts disabled: Ensure its enabled for a valid input addTagDialog.getInputField().setText(validTag) advanceRobolectricLooperUntil( @@ -964,6 +948,55 @@ class TagsDialogTest : RobolectricTest() { } } + @Test + fun `entering a new valid tag clears the error and enables OK`() { + val existingTag = "a" + val validTag = "b" + withAddTagDialog(allTags = listOf(existingTag)) { addTagDialog -> + // Start from the error state + addTagDialog.getInputField().setText(existingTag) + advanceRobolectricLooperUntil( + lazyMessage = { "error was never shown for an existing tag" }, + ) { + addTagDialog.getInputTextLayout().error != null + } + addTagDialog.getInputField().setText(validTag) + advanceRobolectricLooperUntil( + lazyMessage = { "error was never cleared for a valid tag" }, + ) { + addTagDialog.getInputTextLayout().error == null + } + assertTrue( + actual = addTagDialog.positiveButton.isEnabled, + message = "OK should be enabled for a valid tag", + ) + } + } + + private fun withAddTagDialog( + allTags: List, + block: (AlertDialog) -> Unit, + ) { + runTagsDialogScenario( + args = + TagsDialog(ParametersUtils.whatever()) + .withTestArguments( + type = TagsDialog.DialogType.EDIT_TAGS, + checkedTags = arrayListOf(), + allTags = allTags, + ).requireArguments(), + factory = + TagsDialogFactory( + listener = Mockito.mock(TagsDialogListener::class.java), + ), + ) { tagsDialog: TagsDialog -> + tagsDialog.binding.toolbar.root.menu + .performIdentifierAction(R.id.tags_dialog_action_add, 0) + advanceRobolectricLooper() + block(ShadowDialog.getLatestDialog() as AlertDialog) + } + } + private fun runTagsDialogScenario( args: Bundle, factory: TagsDialogFactory? = null, From 897cc36cf49f2ec14e42f254cae404a9ccdeb7d4 Mon Sep 17 00:00:00 2001 From: hill Date: Thu, 29 Oct 2026 10:41:30 +0100 Subject: [PATCH 51/53] Take the theme styles from :common:android in tests Upstream moved Theme_Light and Theme_Dark into :common:android, so the fork's tests that wrap a context in them no longer compiled. --- .../java/com/ichi2/anki/ankiquest/AnkiquestDeckStatusTest.kt | 4 ++-- .../ichi2/anki/deckpicker/heatmap/ReviewHeatmapRenderTest.kt | 3 ++- .../ichi2/anki/deckpicker/heatmap/ReviewHeatmapViewTest.kt | 3 ++- .../src/test/java/com/ichi2/anki/widget/DeckAdapterTest.kt | 3 ++- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestDeckStatusTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestDeckStatusTest.kt index 570adc53487e..3f36625575b9 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestDeckStatusTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestDeckStatusTest.kt @@ -11,7 +11,6 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.test.ext.junit.runners.AndroidJUnit4 import com.ichi2.anki.AnkiDroidApp -import com.ichi2.anki.R import com.ichi2.anki.RobolectricTest import com.ichi2.anki.awaitInitialDeckHolder import com.ichi2.anki.common.time.TimeManager @@ -27,6 +26,7 @@ import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull import kotlin.test.assertTrue +import com.ichi2.anki.common.android.R as CommonR @RunWith(AndroidJUnit4::class) class AnkiquestDeckStatusTest : RobolectricTest() { @@ -93,7 +93,7 @@ class AnkiquestDeckStatusTest : RobolectricTest() { } private fun bind(states: Map): Map { - val context = ContextThemeWrapper(targetContext, R.style.Theme_Light) + val context = ContextThemeWrapper(targetContext, CommonR.style.Theme_Light) val adapter = DeckAdapter( context, diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapRenderTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapRenderTest.kt index 11831c67d11f..0f7342bbfe7f 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapRenderTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapRenderTest.kt @@ -26,6 +26,7 @@ import org.robolectric.annotation.GraphicsMode import java.io.File import java.time.LocalDate import kotlin.test.assertTrue +import com.ichi2.anki.common.android.R as CommonR /** Human-reviewed render artifacts, deliberately kept out of source-controlled golden images. */ @RunWith(RobolectricTestRunner::class) @@ -64,7 +65,7 @@ class ReviewHeatmapRenderTest { (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or if (dark) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO } - val context = ContextThemeWrapper(app.createConfigurationContext(config), if (dark) R.style.Theme_Dark else R.style.Theme_Light) + val context = ContextThemeWrapper(app.createConfigurationContext(config), if (dark) CommonR.style.Theme_Dark else CommonR.style.Theme_Light) val view = ReviewHeatmapView(context).apply(configure) view.measure( View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY), diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapViewTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapViewTest.kt index 89d7a2ac8f13..a643eb1f9d15 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapViewTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapViewTest.kt @@ -23,13 +23,14 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertTrue +import com.ichi2.anki.common.android.R as CommonR @RunWith(RobolectricTestRunner::class) @Config(application = EmptyApplication::class, qualifiers = "en-rUS-mdpi") @Category(EmptyApplicationCategory::class) class ReviewHeatmapViewTest { private val context: Context - get() = ContextThemeWrapper(ApplicationProvider.getApplicationContext(), R.style.Theme_Light) + get() = ContextThemeWrapper(ApplicationProvider.getApplicationContext(), CommonR.style.Theme_Light) @Test fun `selecting leap day previews details and only the full size button opens cards`() { diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/widget/DeckAdapterTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/widget/DeckAdapterTest.kt index 45c956dbe156..2c29d7b0ff65 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/widget/DeckAdapterTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/widget/DeckAdapterTest.kt @@ -32,6 +32,7 @@ import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertSame import kotlin.test.assertTrue +import com.ichi2.anki.common.android.R as CommonR @RunWith(AndroidJUnit4::class) class DeckAdapterTest : RobolectricTest() { @@ -184,7 +185,7 @@ class DeckAdapterTest : RobolectricTest() { } private inner class HeatmapDeckListFixture { - private val context = ContextThemeWrapper(targetContext, R.style.Theme_Light) + private val context = ContextThemeWrapper(targetContext, CommonR.style.Theme_Light) val deckAdapter = DeckAdapter( context, From ba2bf0954c83af6c25b37235247c7a2abc04b6d9 Mon Sep 17 00:00:00 2001 From: hill Date: Thu, 29 Oct 2026 10:41:30 +0100 Subject: [PATCH 52/53] Let a leftover poll read the profile in the private access test AnkiquestPollWorker from an earlier test can read the profile through this test's server, which made the exact request list fail on ankiquest. The test is about every read carrying the member token, so it now checks which endpoints were read rather than how often. --- .../java/com/ichi2/anki/ankiquest/AnkiquestPrivateAccessTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestPrivateAccessTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestPrivateAccessTest.kt index 939b840862ba..41d696eba1a2 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestPrivateAccessTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ankiquest/AnkiquestPrivateAccessTest.kt @@ -100,7 +100,7 @@ class AnkiquestPrivateAccessTest : RobolectricTest() { Ankiquest.leaderboard() Ankiquest.profile() - assertEquals(listOf("/api/leaderboard", "/api/profile/member%20name"), requests.map { it.path }) + assertEquals(setOf("/api/leaderboard", "/api/profile/member%20name"), requests.map { it.path }.toSet()) assertTrue(requests.all { it.authorization == "Bearer member-secret" }) assertTrue(requests.all { it.method == "GET" && it.body.isEmpty() }) } From 91ce3cc4419aa87987c80d8f3cbe4e18e51659a2 Mon Sep 17 00:00:00 2001 From: hill Date: Thu, 29 Oct 2026 11:38:41 +0100 Subject: [PATCH 53/53] Wrap the heatmap render test's themed context Naming the theme through CommonR took the line past ktlint's 140 columns. --- .../anki/deckpicker/heatmap/ReviewHeatmapRenderTest.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapRenderTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapRenderTest.kt index 0f7342bbfe7f..d888890b21c6 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapRenderTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/heatmap/ReviewHeatmapRenderTest.kt @@ -65,7 +65,11 @@ class ReviewHeatmapRenderTest { (uiMode and Configuration.UI_MODE_NIGHT_MASK.inv()) or if (dark) Configuration.UI_MODE_NIGHT_YES else Configuration.UI_MODE_NIGHT_NO } - val context = ContextThemeWrapper(app.createConfigurationContext(config), if (dark) CommonR.style.Theme_Dark else CommonR.style.Theme_Light) + val context = + ContextThemeWrapper( + app.createConfigurationContext(config), + if (dark) CommonR.style.Theme_Dark else CommonR.style.Theme_Light, + ) val view = ReviewHeatmapView(context).apply(configure) view.measure( View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),