From d3a4c67459a33145682498e2455e0c1ab7531c12 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:08:11 +0100 Subject: [PATCH 01/50] fix(server): read complete protobuf request bodies Reject truncated bodies instead of forwarding zero-padded protobuf messages. HTTPD handles body-read failures so it can close the connection. Assisted-by: GPT-6 --- .../java/com/ichi2/anki/pages/AnkiServer.kt | 5 +- .../com/ichi2/anki/pages/AnkiServerTest.kt | 88 +++++++++++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/pages/AnkiServerTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/pages/AnkiServer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/pages/AnkiServer.kt index 1bfb73014bec..b43da2fc34bf 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/pages/AnkiServer.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/pages/AnkiServer.kt @@ -7,6 +7,7 @@ import fi.iki.elonen.NanoHTTPD import kotlinx.coroutines.runBlocking import timber.log.Timber import java.io.ByteArrayInputStream +import java.io.DataInputStream open class AnkiServer( private val postHandler: PostRequestHandler, @@ -23,8 +24,8 @@ open class AnkiServer( Method.POST -> { val uri = session.uri Timber.d("POST: Requested %s", uri) + // NanoHTTPD closes the connection on body read failures by default val inputBytes = getSessionBytes(session) - try { val data = runBlocking { postHandler.handlePostRequest(PostRequestUri(uri), inputBytes) } buildResponse(data) @@ -65,7 +66,7 @@ open class AnkiServer( fun getSessionBytes(session: IHTTPSession): ByteArray { val contentLength = session.headers["content-length"]!!.toInt() val bytes = ByteArray(contentLength) - session.inputStream.read(bytes, 0, contentLength) + DataInputStream(session.inputStream).readFully(bytes) return bytes } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/pages/AnkiServerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/pages/AnkiServerTest.kt new file mode 100644 index 000000000000..44ee31d39b1b --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/pages/AnkiServerTest.kt @@ -0,0 +1,88 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.pages + +import fi.iki.elonen.NanoHTTPD.IHTTPSession +import fi.iki.elonen.NanoHTTPD.Method +import org.junit.Test +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.mockito.kotlin.verifyNoInteractions +import java.io.ByteArrayInputStream +import java.io.EOFException +import java.io.InputStream +import java.net.SocketTimeoutException +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertSame + +class AnkiServerTest { + @Test + fun `a request body can arrive in several reads`() { + val bytes = ByteArray(100) { it.toByte() } + val stream = + object : ByteArrayInputStream(bytes) { + override fun read( + buffer: ByteArray, + offset: Int, + length: Int, + ): Int = super.read(buffer, offset, minOf(length, 3)) + } + val session = postSession(stream, contentLength = bytes.size) + + assertContentEquals(bytes, AnkiServer.getSessionBytes(session)) + } + + @Test + fun `a truncated request propagates to NanoHTTPD without dispatching`() { + val session = postSession(ByteArrayInputStream(byteArrayOf(1, 2)), contentLength = 10) + val handler = mock() + val server = AnkiServer(handler) + + assertFailsWith { server.serve(session) } + verifyNoInteractions(handler) + } + + @Test + fun `a body read timeout propagates to NanoHTTPD without dispatching`() { + val timeout = SocketTimeoutException("Read timed out") + val stream = + object : InputStream() { + override fun read(): Int = throw timeout + } + val session = postSession(stream, contentLength = 1) + val handler = mock() + val server = AnkiServer(handler) + + assertSame(timeout, assertFailsWith { server.serve(session) }) + verifyNoInteractions(handler) + } + + @Test + fun `reading a body leaves bytes beyond content length unread`() { + val stream = ByteArrayInputStream(byteArrayOf(1, 2, 3, 4)) + val session = postSession(stream, contentLength = 3) + + assertContentEquals(byteArrayOf(1, 2, 3), AnkiServer.getSessionBytes(session)) + assertEquals(4, stream.read()) + } + + @Test + fun `an empty protobuf request is accepted`() { + val session = postSession(ByteArrayInputStream(byteArrayOf()), contentLength = 0) + + assertContentEquals(byteArrayOf(), AnkiServer.getSessionBytes(session)) + } + + private fun postSession( + stream: InputStream, + contentLength: Int, + ): IHTTPSession = + mock { + on { method } doReturn Method.POST + on { uri } doReturn "/_anki/test" + on { headers } doReturn mapOf("content-length" to contentLength.toString()) + on { inputStream } doReturn stream + } +} From 3a8f7d3fa022c1e36c5ee8a78f3609e0a725b1ef Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:14:52 +0100 Subject: [PATCH 02/50] fix(card-browser): add fast path to reduce chance of ANRs Rebuilding the fragment can cause an ANR This adds a fast path when the fragment does not need to be rebuilt. Part of 19737 Assisted-by: Claude Fable 5  Conflicts:  AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt --- .../main/java/com/ichi2/anki/CardBrowser.kt | 16 ++++++-- .../java/com/ichi2/anki/NoteEditorFragment.kt | 24 ++++++++++++ .../java/com/ichi2/anki/CardBrowserTest.kt | 38 +++++++++++++++++++ 3 files changed, 74 insertions(+), 4 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt index f4fc2877f0e3..f404ccae6cb7 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt @@ -500,10 +500,18 @@ open class CardBrowser : when (command) { is NoteEditorCommand.LoadInPane -> { binding.noteEditorFrame?.isVisible = true - if (fragment?.hasUnsavedChanges() == true) { - showSaveChangesDialog(command.destination) - } else { - loadNoteEditorFragment(command.destination) + val editor = fragment + when { + // Issue 19737: the pane already displays this selection. Refresh it in + // place: recreating the fragment is expensive enough to ANR when + // searches complete in quick succession. If there are unsaved + // changes, keep them: this is a redundant refresh, not a navigation + editor != null && editor.isEditingSameCards(command.destination) -> + if (!editor.hasUnsavedChanges()) { + editor.reloadNoteFromCollection() + } + editor?.hasUnsavedChanges() == true -> showSaveChangesDialog(command.destination) + else -> loadNoteEditorFragment(command.destination) } } is NoteEditorCommand.LaunchActivity -> { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt index 3d3dfebb113d..dac62ad8fee9 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt @@ -1204,6 +1204,30 @@ class NoteEditorFragment : private fun collectionHasLoaded(): Boolean = allNoteTypeIds != null + /** + * Whether this fragment is editing the cards which [destination] targets. + */ + fun isEditingSameCards(destination: NoteEditorDestination): Boolean { + if (destination !is NoteEditorDestination.EditSelection) return false + if (arguments?.getInt(EXTRA_CALLER) != NoteEditorCaller.EDIT.value) return false + return cardIdsFromArguments?.asList() == destination.cardIds + } + + /** + * Reloads the current note from the collection and rebuilds the fields, e.g. after the + * note was updated by another component (find & replace in the Card Browser). + * + * Unsaved edits are discarded: check [hasUnsavedChanges] before calling. + */ + fun reloadNoteFromCollection() { + Timber.i("reloadNoteFromCollection()") + val cardId = currentEditedCard?.id ?: return + currentEditedCard = getColUnsafe.getCard(cardId) + // reset so setNote() reloads the tags of the note + selectedTags = null + setNote(currentEditedCard!!.note(getColUnsafe), FieldChangeType.refresh(shouldReplaceNewlines())) + } + // ---------------------------------------------------------------------------- // SAVE NOTE METHODS // ---------------------------------------------------------------------------- diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt index 2c77fffdf5f2..f76650bb53ad 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt @@ -108,6 +108,7 @@ import org.hamcrest.CoreMatchers.allOf import org.hamcrest.CoreMatchers.containsString import org.hamcrest.CoreMatchers.instanceOf import org.hamcrest.CoreMatchers.`is` +import org.hamcrest.CoreMatchers.sameInstance import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.equalTo import org.hamcrest.Matchers.greaterThan @@ -1786,6 +1787,43 @@ class CardBrowserTest : RobolectricTest() { assertMenusEqual(expectedMenuItems, menu) } + @Test // Issue 19737 - recreating the editor is expensive enough to ANR when repeated + fun `pane editor is not recreated when a search returns the same row`() = + withBrowser(noteCount = 2, fragmented = true) { + val editorBeforeRefresh = assertNotNull(fragment, "note editor fragment") + + // every `opExecuted` calls refreshBrowserUI() -> a new search + // the pane row is unchanged, so the pane editor does not need to be recreated + searchCards() + advanceRobolectricLooper() + + assertThat("the pane editor is reused", fragment, sameInstance(editorBeforeRefresh)) + } + + @Test // Issue 19737 + fun `pane editor refreshes when the note changes outside the editor`() = + withBrowser(noteCount = 1, fragmented = true) { + val editor = assertNotNull(fragment, "note editor fragment") + assertThat(editor.getFieldForTest(0).fieldText, equalTo("0")) + + // simulate 'find & replace' in the browser: the note changes, + // then opExecuted calls refreshBrowserUI() -> a new search + val cardId = assertNotNull(viewModel.paneRow?.toCardId(viewModel.cardsOrNotes)) + withCol { + val note = getCard(cardId).note(this) + note.setField(0, "replaced") + updateNote(note) + } + searchCards() + advanceRobolectricLooper() + + assertThat( + "the pane editor shows the updated note", + fragment?.getFieldForTest(0)?.fieldText, + equalTo("replaced"), + ) + } + @Test fun `deck chip performs a search`() { // The deck chip uses `DeckSelectionListener`, which uses a different code path From b5d4b4fbb052d7e5c55374fb211bb2ecb23083fb Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 5 May 2026 18:43:42 +0100 Subject: [PATCH 03/50] feat(card-browser): implement 'Default search text' Fixes 18618 Assisted-by: Claude Opus 4.7 - all Assisted-by: GPT-6 - screenshot test --- .../anki/browser/BrowserOptionsRepository.kt | 17 +++++++- .../ichi2/anki/browser/CardBrowserFragment.kt | 5 +++ .../anki/browser/CardBrowserViewModel.kt | 14 +++++++ .../anki/dialogs/BrowserOptionsDialog.kt | 16 +++++++ .../java/com/ichi2/anki/utils/ext/Config.kt | 9 ++++ .../res/layout/dialog_browser_options.xml | 20 +++++++++ .../anki/browser/CardBrowserViewModelTest.kt | 42 +++++++++++++++++++ .../BrowserOptionsDialogScreenshotTest.kt | 33 +++++++++++++++ 8 files changed, 155 insertions(+), 1 deletion(-) create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/dialogs/BrowserOptionsDialogScreenshotTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserOptionsRepository.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserOptionsRepository.kt index c52594896e85..e90a59c6470a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserOptionsRepository.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserOptionsRepository.kt @@ -6,6 +6,7 @@ import android.content.SharedPreferences import androidx.core.content.edit import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.model.CardsOrNotes +import com.ichi2.anki.utils.ext.defaultBrowserSearch import com.ichi2.anki.utils.ext.ignoreAccentsInSearch import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -31,12 +32,18 @@ class BrowserOptionsRepository( val ignoreAccentsInSearch: StateFlow field = MutableStateFlow(false) + /** @see com.ichi2.anki.libanki.Config.defaultBrowserSearch */ + val defaultBrowserSearch: StateFlow + field = MutableStateFlow("") + /** Reads persisted values into the flows. Call once during ViewModel init. */ suspend fun load() = collectionOptionsMutex.withLock { - val (mode, ignoreAccents) = withCol { CardsOrNotes.fromCollection(this) to config.ignoreAccentsInSearch } + val (mode, ignoreAccents, defaultSearch) = + withCol { Triple(CardsOrNotes.fromCollection(this), config.ignoreAccentsInSearch, config.defaultBrowserSearch) } cardsOrNotes.value = mode ignoreAccentsInSearch.value = ignoreAccents + defaultBrowserSearch.value = defaultSearch } suspend fun setCardsOrNotes(value: CardsOrNotes) = @@ -62,6 +69,14 @@ class BrowserOptionsRepository( ignoreAccentsInSearch.value = value } + suspend fun setDefaultBrowserSearch(value: String) = + collectionOptionsMutex.withLock { + if (defaultBrowserSearch.value == value) return@withLock + Timber.d("setting default browser search to %s", value) + withCol { config.defaultBrowserSearch = value } + defaultBrowserSearch.value = value + } + companion object { private const val PREF_IS_TRUNCATED = "isTruncated" } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt index 424b963cec5a..20edde8125f2 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt @@ -1172,6 +1172,11 @@ class CardBrowserFragment : Timber.d("syncing searchview state from chip updates") val filters = search.filters + // Handle default search text + if (search.query.isNotEmpty() && searchBar?.text.isNullOrEmpty()) { + launchCatchingTask { searchBar?.setText(search.toUserSpannable()) } + } + legacyDeckName?.text = filters.decks.firstOrNull()?.name ?: TR.sentenceCase.allDecks decksChip?.text = filters.decks.firstOrNull()?.name ?: TR.sentenceCase.allDecks decksChip?.hasCheckedBackground = filters.decks.any() diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt index 600c7f8d2d5f..8d5c2b37d286 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt @@ -63,6 +63,7 @@ import com.ichi2.anki.model.SortType import com.ichi2.anki.observability.ChangeManager import com.ichi2.anki.observability.undoableOp import com.ichi2.anki.preferences.SharedPreferencesProvider +import com.ichi2.anki.settings.Prefs import com.ichi2.anki.utils.ext.getCardOrNull import com.ichi2.anki.utils.ext.setUserFlagForCards import kotlinx.coroutines.Deferred @@ -267,6 +268,8 @@ class CardBrowserViewModel( val shouldIgnoreAccents: Boolean get() = browserOptionsRepository.ignoreAccentsInSearch.value + val defaultBrowserSearch: String get() = browserOptionsRepository.defaultBrowserSearch.value + private val _selectedRows: MutableSet = Collections.synchronizedSet(LinkedHashSet()) // immutable accessor for _selectedRows @@ -579,6 +582,11 @@ class CardBrowserViewModel( viewModelScope.launch { browserOptionsRepository.load() + // Apply the default search (if available) + if (Prefs.devUsingCardBrowserSearchView) { + searchTerms = searchTerms.ifEmpty { defaultBrowserSearch } + } + val initialDeckId = if (selectAllDecks) SelectableDeck.AllDecks else getInitialDeck() // PERF: slightly inefficient if the source was lastDeckId setSelectedDeck(initialDeckId) @@ -803,6 +811,12 @@ class CardBrowserViewModel( fun setIgnoreAccents(value: Boolean) = viewModelScope.launch { browserOptionsRepository.setIgnoreAccentsInSearch(value) } + fun setDefaultSearchText(text: String) = + viewModelScope.launch { + if (!Prefs.devUsingCardBrowserSearchView) return@launch + browserOptionsRepository.setDefaultBrowserSearch(text) + } + fun selectAll(): Job? { if (!_selectedRows.addAll(cards)) return null Timber.d("selecting all: %d item(s)", cards.size) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/BrowserOptionsDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/BrowserOptionsDialog.kt index 19cc0a605ecf..5bc27f6ead84 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/BrowserOptionsDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/BrowserOptionsDialog.kt @@ -5,7 +5,9 @@ package com.ichi2.anki.dialogs import android.app.Dialog import android.os.Bundle +import android.text.Editable import androidx.appcompat.app.AppCompatDialogFragment +import androidx.core.view.isVisible import androidx.fragment.app.activityViewModels import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.ichi2.anki.CollectionManager.TR @@ -14,6 +16,7 @@ import com.ichi2.anki.browser.BrowserColumnSelectionFragment import com.ichi2.anki.browser.CardBrowserViewModel import com.ichi2.anki.databinding.DialogBrowserOptionsBinding import com.ichi2.anki.model.CardsOrNotes +import com.ichi2.anki.settings.Prefs import com.ichi2.anki.ui.internationalization.sentenceCase import com.ichi2.utils.create import com.ichi2.utils.negativeButton @@ -39,6 +42,7 @@ class BrowserOptionsDialog : AppCompatDialogFragment(R.layout.dialog_browser_opt viewModel.setCardsOrNotes(dialogCardsOrNotes) viewModel.setTruncated(binding.truncateCheckBox.isChecked) viewModel.setIgnoreAccents(binding.ignoreAccentsCheckBox.isChecked) + viewModel.setDefaultSearchText(binding.defaultSearchText.text.orEmpty()) } private val cardsOrNotes by lazy { @@ -89,6 +93,15 @@ class BrowserOptionsDialog : AppCompatDialogFragment(R.layout.dialog_browser_opt isChecked = viewModel.shouldIgnoreAccents } + if (Prefs.devUsingCardBrowserSearchView) { + binding.defaultSearchInputLayout.apply { + isVisible = true + hint = TR.preferencesDefaultSearchText() + placeholderText = TR.preferencesDefaultSearchTextExample() + } + binding.defaultSearchText.setText(viewModel.defaultBrowserSearch) + } + binding.browsingTextView.text = TR.preferencesBrowsing() return MaterialAlertDialogBuilder(requireContext()).create { @@ -124,3 +137,6 @@ class BrowserOptionsDialog : AppCompatDialogFragment(R.layout.dialog_browser_opt } } } + +/** Returns the text content as a [String], or `""` if the receiver is `null`. */ +private fun Editable?.orEmpty(): String = this?.toString().orEmpty() diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Config.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Config.kt index 5a7750992d31..2927134fb198 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Config.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Config.kt @@ -19,3 +19,12 @@ import com.ichi2.anki.libanki.Config var Config.ignoreAccentsInSearch get() = getBool(ConfigKey.Bool.IGNORE_ACCENTS_IN_SEARCH) set(value) = setBool(ConfigKey.Bool.IGNORE_ACCENTS_IN_SEARCH, value) + +/** + * Default search text for the card browser + * + * e.g. "deck:current" + */ +var Config.defaultBrowserSearch + get() = getString(ConfigKey.String.DEFAULT_SEARCH_TEXT) + set(value) = setString(ConfigKey.String.DEFAULT_SEARCH_TEXT, value) diff --git a/AnkiDroid/src/main/res/layout/dialog_browser_options.xml b/AnkiDroid/src/main/res/layout/dialog_browser_options.xml index 6f99851e8eaf..488fadba1c4b 100644 --- a/AnkiDroid/src/main/res/layout/dialog_browser_options.xml +++ b/AnkiDroid/src/main/res/layout/dialog_browser_options.xml @@ -93,6 +93,26 @@ android:layout_height="wrap_content" android:layout_marginHorizontal="16dp" tools:text="Ignore accents in search (slower)"/> + + + + Date: Tue, 5 May 2026 19:37:09 +0100 Subject: [PATCH 04/50] fix(accessibility): android:textColorHint android:textColorHint was #79747E. On #E4F5FD this led to a contrast of 4.07:1 #716C76 is 4.57:1 - acceptable for text Assisted-by: Claude Opus 4.7 - color --- AnkiDroid/src/main/res/values/theme_light.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/AnkiDroid/src/main/res/values/theme_light.xml b/AnkiDroid/src/main/res/values/theme_light.xml index b42e088ed263..bf1736b81336 100644 --- a/AnkiDroid/src/main/res/values/theme_light.xml +++ b/AnkiDroid/src/main/res/values/theme_light.xml @@ -7,6 +7,7 @@ @color/material_light_blue_700 @color/white @color/black + #716C76 @color/material_blue_grey_700 @color/white @color/white From bb105d4bb81bc21751e0b23884528e7235577ca6 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:37:22 +0100 Subject: [PATCH 05/50] fix(tests): tap was considered a long press .click() opened "Create subdeck" customizeReminderBeforeCreatingIt editReminderChangingDeckAndSettings Fixes 22040 Assisted-by: GPT-6 --- .../ichi2/anki/reviewreminders/ReviewRemindersTest.kt | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/reviewreminders/ReviewRemindersTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/reviewreminders/ReviewRemindersTest.kt index 691d64f1fc74..837f0ac8079e 100644 --- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/reviewreminders/ReviewRemindersTest.kt +++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/reviewreminders/ReviewRemindersTest.kt @@ -14,6 +14,8 @@ import androidx.test.espresso.action.ViewActions.replaceText import androidx.test.espresso.action.ViewActions.scrollTo import androidx.test.espresso.assertion.ViewAssertions.doesNotExist import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.contrib.RecyclerViewActions.actionOnItem +import androidx.test.espresso.matcher.ViewMatchers.hasDescendant import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom import androidx.test.espresso.matcher.ViewMatchers.isChecked import androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA @@ -25,6 +27,7 @@ import com.google.android.material.chip.Chip import com.ichi2.anki.CollectionManager import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.R +import com.ichi2.anki.TestUtils.clickChildViewWithId import com.ichi2.anki.common.time.TimeManager import com.ichi2.anki.settings.Prefs import com.ichi2.anki.tests.InstrumentedTest @@ -366,7 +369,13 @@ class ReviewRemindersTest : InstrumentedTest() { private fun selectDeck(deckName: String) { onView(withId(R.id.add_edit_reminder_deck_name)).perform(click()) onView(withText(deckName)).checkWithTimeout(matches(isDisplayed())) - onView(withText(deckName)).perform(click()) + // .click() can open "Create subdeck", so invoke the handler directly + onView(withId(R.id.decks)).perform( + actionOnItem( + hasDescendant(withText(deckName)), + clickChildViewWithId(R.id.DeckPickerHoriz), + ), + ) onView(withId(R.id.add_edit_reminder_deck_name)).checkWithTimeout(matches(withText(deckName))) } From d5c6370853d7d4e90595db4c5522ebe926085be8 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:42:01 +0100 Subject: [PATCH 06/50] fix(ci): exempt AndroidX from Dependabot cooldown Dependabot cannot obtain release dates for older AndroidX versions from Google Maven. Existing updates are then closed as `update_no_longer_possible` if recreated. See PRs 21219 21314 & 21552 https://redirect.github.com/dependabot/dependabot-core/issues/16055 Part of 22028 --- .github/dependabot.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 496db9c043f1..1e0c21720c21 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -21,6 +21,10 @@ updates: - "dependencies" cooldown: default-days: 3 + # Missing Google Maven release dates cause Dependabot to close older AndroidX updates. + # https://github.com/dependabot/dependabot-core/issues/16055 + exclude: + - "androidx.*" ignore: # Ignore all Rust backend updates, these are always done as manual pulls - dependency-name: ankiBackend From c097988725542330f0ebd4d77b76beaa7216ef66 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:35:34 +0100 Subject: [PATCH 07/50] fix(day-rollover): fix concurrency Concurrent events could flip the reviewer back to the question. This caused an issue in emulator testing. Fixes 22041 Assisted-by: GPT-6 --- .../java/com/ichi2/anki/DayRolloverHandler.kt | 50 ++++++++++-------- .../com/ichi2/anki/DayRolloverHandlerTest.kt | 51 +++++++++++++++++++ 2 files changed, 79 insertions(+), 22 deletions(-) create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/DayRolloverHandlerTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DayRolloverHandler.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DayRolloverHandler.kt index 8aa09138d9d4..3a17f2da4d5e 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DayRolloverHandler.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DayRolloverHandler.kt @@ -27,6 +27,7 @@ import android.content.Intent.ACTION_TIMEZONE_CHANGED import android.content.Intent.ACTION_TIME_CHANGED import android.content.Intent.ACTION_TIME_TICK import android.content.IntentFilter +import androidx.annotation.VisibleForTesting import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat.RECEIVER_EXPORTED import anki.collection.OpChanges @@ -43,6 +44,8 @@ import com.ichi2.anki.observability.ChangeManager import com.ichi2.widget.DayRolloverAlarm import com.ichi2.widget.WidgetStatus import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import timber.log.Timber /** @@ -59,6 +62,8 @@ import timber.log.Timber * precise). */ object DayRolloverHandler : AnkiBroadcastReceiver() { + private val timeChangeMutex = Mutex() + /** @see Scheduler.dayCutoff */ private var lastCutoff: EpochSeconds? = null @@ -78,8 +83,6 @@ object DayRolloverHandler : AnkiBroadcastReceiver() { context: Context, intent: Intent, ) { - // potential race condition if a timezone/tick change occur simultaneously - // the outcome would be two calls to notifySubscribers, which is acceptable Timber.v("received ${intent.action}") // launch coroutine as we need access to `col.sched` applicationScope.launchCatching(Dispatchers.IO, errorMessageHandler = { msg -> @@ -93,29 +96,32 @@ object DayRolloverHandler : AnkiBroadcastReceiver() { } } - private suspend fun handleTimeChange() { - val currentCutoff = withOpenColOrNull { sched.dayCutoff } - if (currentCutoff == null) { - // assumption: if the collection is not open, queue status will be updated - // by the act of opening the collection, missing an event is acceptable - Timber.w("could not check/update day rollover: collection not open") - return - } + @VisibleForTesting + internal suspend fun handleTimeChange() = + // Concurrent initial events must not mistake another event's initialization for a rollover. + timeChangeMutex.withLock { + val currentCutoff = withOpenColOrNull { sched.dayCutoff } + if (currentCutoff == null) { + // assumption: if the collection is not open, queue status will be updated + // by the act of opening the collection, missing an event is acceptable + Timber.w("could not check/update day rollover: collection not open") + return@withLock + } - // Anki Desktop: instead of comparing the current time to the cutoff, - // it detects if a change to the cutoff has occurred - // https://github.com/ankitects/anki/blob/edd38ca06730d7fc16804f52ce10f6bc54c3d145/qt/aqt/main.py#L508-L528 - if (lastCutoff == currentCutoff) return + // Anki Desktop: instead of comparing the current time to the cutoff, + // it detects if a change to the cutoff has occurred + // https://github.com/ankitects/anki/blob/edd38ca06730d7fc16804f52ce10f6bc54c3d145/qt/aqt/main.py#L508-L528 + if (lastCutoff == currentCutoff) return@withLock - Timber.i("day cutoff changed %d -> %d", lastCutoff, currentCutoff) - // Re-arm the wall-clock alarm whenever the cutoff changes - DayRolloverAlarm.scheduleNext(appContext) - // we do not want to send a "study queues changes" message initially - if (lastCutoff != null) { - handleDayRollover() + Timber.i("day cutoff changed %d -> %d", lastCutoff, currentCutoff) + // Re-arm the wall-clock alarm whenever the cutoff changes + DayRolloverAlarm.scheduleNext(appContext) + // we do not want to send a "study queues changes" message initially + if (lastCutoff != null) { + handleDayRollover() + } + this.lastCutoff = currentCutoff } - this.lastCutoff = currentCutoff - } private fun handleDayRollover() { Timber.i("day rollover occurred") diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DayRolloverHandlerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DayRolloverHandlerTest.kt new file mode 100644 index 000000000000..74bcff80e0dc --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/DayRolloverHandlerTest.kt @@ -0,0 +1,51 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.observability.ChangeManager +import com.ichi2.widget.DayRolloverAlarm +import com.ichi2.widget.WidgetStatus +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.runs +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DayRolloverHandlerTest : RobolectricTest() { + @After + fun clearMocks() { + unmockkAll() + } + + /** Issue 22041: overlapping initial time events must not flip the reviewer back to the question. */ + @Test + fun `overlapping initial time changes do not report a day rollover`() = + runTest { + val scheduler = col.sched + mockkObject(scheduler, DayRolloverAlarm, WidgetStatus) + every { scheduler.dayCutoff } returns 86400L + val subscriber = mockk(relaxed = true) + ChangeManager.subscribe(subscriber) + every { WidgetStatus.updateInBackground(any()) } just runs + every { DayRolloverAlarm.scheduleNext(any()) } answers { + // Let the second event check the same cutoff before the first event finishes. + runCurrent() + } + + repeat(2) { launch { DayRolloverHandler.handleTimeChange() } } + advanceUntilIdle() + + verify(exactly = 0) { subscriber.opExecuted(any(), any()) } + verify(exactly = 1) { DayRolloverAlarm.scheduleNext(any()) } + } +} From 4907a593d118390bd1538b6d8d676674b972b7bc Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:03:42 +0100 Subject: [PATCH 08/50] test: open JDK modules for Robolectric Allow unit test JVMs to access JDK internals. ---- See https://robolectric.org/getting-started/#running-with-java-17-and-higher > When running tests on Java 17 or higher, the JVM requires ``--add-opens` flags > so that Robolectric can access internal OpenJDK classes and APIs > (`java.lang`, `java.io`, `jdk.internal.access`, etc.). Add the following > `jvmArgs to your unit test configuration in `build.gradle` / `build.gradle.kts`: ```kt android { testOptions { unitTests.all { it.jvmArgs( "--add-opens=java.base/java.lang=ALL-UNNAMED", "--add-opens=java.base/java.util=ALL-UNNAMED", "--add-opens=java.base/java.io=ALL-UNNAMED", "--add-opens=java.base/java.net=ALL-UNNAMED", "--add-opens=java.base/java.security=ALL-UNNAMED", "--add-opens=java.base/java.text=ALL-UNNAMED", "--add-opens=java.base/jdk.internal.access=ALL-UNNAMED", "--add-opens=java.desktop/java.awt.font=ALL-UNNAMED", "--add-opens=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED", ) } } } ``` Assisted-by: GPT-6 --- build.gradle.kts | 3 +++ .../com/ichi2/anki/gradle/Robolectric.kt | 24 +++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 buildSrc/src/main/kotlin/com/ichi2/anki/gradle/Robolectric.kt diff --git a/build.gradle.kts b/build.gradle.kts index a9fdc9dc975f..9acffddbf7eb 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -3,6 +3,7 @@ import com.android.build.api.dsl.CommonExtension import com.android.build.api.extension.impl.AndroidComponentsExtensionImpl import com.ichi2.anki.gradle.GitHubActionsTestListener import com.ichi2.anki.gradle.TestSummaryService +import com.ichi2.anki.gradle.configureRobolectricJvm import com.slack.keeper.optInToKeeper import org.gradle.api.tasks.testing.logging.TestExceptionFormat import org.gradle.buildconfiguration.tasks.UpdateDaemonJvm @@ -80,6 +81,8 @@ subprojects { it.maxHeapSize = "${unitTestForkMaxHeapGb}g" it.minHeapSize = "1g" + it.configureRobolectricJvm() + it.useJUnitPlatform() it.testLogging { events("failed", "skipped") diff --git a/buildSrc/src/main/kotlin/com/ichi2/anki/gradle/Robolectric.kt b/buildSrc/src/main/kotlin/com/ichi2/anki/gradle/Robolectric.kt new file mode 100644 index 000000000000..87efbe38b9b8 --- /dev/null +++ b/buildSrc/src/main/kotlin/com/ichi2/anki/gradle/Robolectric.kt @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.gradle + +import org.gradle.api.tasks.testing.Test + +/** + * Opens the JDK internals required by Robolectric on Java 17 and higher. + * + * See https://robolectric.org/getting-started/#running-with-java-17-and-higher + */ +fun Test.configureRobolectricJvm() { + jvmArgs( + "--add-opens=java.base/java.lang=ALL-UNNAMED", + "--add-opens=java.base/java.util=ALL-UNNAMED", + "--add-opens=java.base/java.io=ALL-UNNAMED", + "--add-opens=java.base/java.net=ALL-UNNAMED", + "--add-opens=java.base/java.security=ALL-UNNAMED", + "--add-opens=java.base/java.text=ALL-UNNAMED", + "--add-opens=java.base/jdk.internal.access=ALL-UNNAMED", + "--add-opens=java.desktop/java.awt.font=ALL-UNNAMED", + "--add-opens=jdk.compiler/com.sun.tools.javac.api=ALL-UNNAMED", + ) +} From a6bd3fd5057d9702581b762539e10b05a79afe7e Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:36:03 +0100 Subject: [PATCH 09/50] refactor(ci): extract ADB shell retry helper Prep for 22042 Assisted-by: GPT-6 --- .github/adb_retry.sh | 23 +++++++++++++++++++++++ .github/disable_art_metrics.sh | 16 ++-------------- 2 files changed, 25 insertions(+), 14 deletions(-) create mode 100644 .github/adb_retry.sh diff --git a/.github/adb_retry.sh b/.github/adb_retry.sh new file mode 100644 index 000000000000..17ae85240320 --- /dev/null +++ b/.github/adb_retry.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# SPDX-License-Identifier: GPL-3.0-or-later + +set -euo pipefail + +# Issue 22033: ADB reported "device offline" after sys.boot_completed=1. +# The underlying cause is unknown; fixing it would likely remove the need for retries. +# Retries may help if the disconnect is transient; this is unverified in CI. +# Only use for commands safe to repeat: a failed ADB call may already have +# executed the command on the device. +retry_repeatable_adb_shell_cmd() { + local attempt output + for ((attempt = 1; attempt <= 5; attempt++)); do + if output=$(adb shell "$@"); then + printf '%s\n' "$output" + return 0 + fi + sleep 1 + done + return 1 +} + +retry_repeatable_adb_shell_cmd "$@" diff --git a/.github/disable_art_metrics.sh b/.github/disable_art_metrics.sh index caa12017231a..c87e7278a98d 100644 --- a/.github/disable_art_metrics.sh +++ b/.github/disable_art_metrics.sh @@ -3,21 +3,9 @@ set -euo pipefail -# Issue 22033: ADB reported "device offline" after sys.boot_completed=1. -# The underlying cause is unknown; fixing it would likely remove the need for retries. -# Retries may help if the disconnect is transient; this is unverified in CI. -# Only use for commands safe to repeat: a failed ADB call may already have -# executed the command on the device. +# Only use for shell commands safe to repeat, such as reading or setting a value. retry_repeatable_adb_shell_cmd() { - local attempt output - for ((attempt = 1; attempt <= 5; attempt++)); do - if output=$(adb shell "$@"); then - printf '%s\n' "$output" - return 0 - fi - sleep 1 - done - return 1 + bash "$(dirname "${BASH_SOURCE[0]}")/adb_retry.sh" "$@" } # Both CI images use BE2A.250530.026.F3. Revisit this workaround when that changes. From 256a61e22cb71933238a26535a02eff12714de94 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:36:48 +0100 Subject: [PATCH 10/50] fix(ci): retry ADB if it's offline Note: This fix is unverified, I was unable to produce the underlying error. Fixing the underlying error could cause this code to be removed. * Preserve stdout and command exit status * Only retry on offline connection errors Logcat is kept outside the helper so stderr remains usable Fixes 22042 Assisted-by: GPT-6 --- .github/adb_retry.sh | 42 +++++++++++++++++----------- .github/disable_art_metrics.sh | 2 +- .github/workflows/tests_emulator.yml | 10 +++---- 3 files changed, 31 insertions(+), 23 deletions(-) diff --git a/.github/adb_retry.sh b/.github/adb_retry.sh index 17ae85240320..b0bb58341a28 100644 --- a/.github/adb_retry.sh +++ b/.github/adb_retry.sh @@ -3,21 +3,29 @@ set -euo pipefail -# Issue 22033: ADB reported "device offline" after sys.boot_completed=1. -# The underlying cause is unknown; fixing it would likely remove the need for retries. -# Retries may help if the disconnect is transient; this is unverified in CI. -# Only use for commands safe to repeat: a failed ADB call may already have -# executed the command on the device. -retry_repeatable_adb_shell_cmd() { - local attempt output - for ((attempt = 1; attempt <= 5; attempt++)); do - if output=$(adb shell "$@"); then - printf '%s\n' "$output" - return 0 - fi - sleep 1 - done - return 1 -} +# ADB can report the device offline just after boot (#22033/#22042). +# Retry only connection failures reported before the command is dispatched. +# Only use for short-lived commands: stderr is captured until they exit. +stderr_file=$(mktemp) +trap 'rm -f "$stderr_file"' EXIT -retry_repeatable_adb_shell_cmd "$@" +for ((attempt = 1; attempt <= 5; attempt++)); do + # Keep retry messages out of stdout so getprop results remain usable. + if adb "$@" 2>"$stderr_file"; then + cat "$stderr_file" >&2 + exit 0 + else + adb_status=$? + fi + cat "$stderr_file" >&2 + + if ((attempt == 5)); then + exit "$adb_status" + fi + case "$(<"$stderr_file")" in + "adb: device offline"|"error: device offline") ;; + *) exit "$adb_status" ;; + esac + printf 'ADB device offline; retrying in 1 second (attempt %s/5).\n' "$((attempt + 1))" >&2 + sleep 1 +done diff --git a/.github/disable_art_metrics.sh b/.github/disable_art_metrics.sh index c87e7278a98d..c9430f9cfd12 100644 --- a/.github/disable_art_metrics.sh +++ b/.github/disable_art_metrics.sh @@ -5,7 +5,7 @@ set -euo pipefail # Only use for shell commands safe to repeat, such as reading or setting a value. retry_repeatable_adb_shell_cmd() { - bash "$(dirname "${BASH_SOURCE[0]}")/adb_retry.sh" "$@" + bash "$(dirname "${BASH_SOURCE[0]}")/adb_retry.sh" shell "$@" } # Both CI images use BE2A.250530.026.F3. Revisit this workaround when that changes. diff --git a/.github/workflows/tests_emulator.yml b/.github/workflows/tests_emulator.yml index d7e47c7dbb02..aa511978d29b 100644 --- a/.github/workflows/tests_emulator.yml +++ b/.github/workflows/tests_emulator.yml @@ -176,7 +176,7 @@ jobs: script: | touch adb-log.txt $ANDROID_HOME/platform-tools/adb logcat '*:D' >> adb-log.txt & - adb logcat --clear + bash .github/adb_retry.sh logcat --clear echo "Generated AVD snapshot for caching." # This step is separate so pure install time may be calculated as a step @@ -200,10 +200,10 @@ jobs: script: | touch adb-log.txt $ANDROID_HOME/platform-tools/adb logcat '*:D' >> adb-log.txt & - $ANDROID_HOME/platform-tools/adb shell su root "setprop ctl.restart zygote" + bash .github/adb_retry.sh shell su root "setprop ctl.restart zygote" sleep 10 sleep $FIRST_BOOT_DELAY - adb logcat --clear + bash .github/adb_retry.sh logcat --clear echo "First boot warmup completed." - name: Run Emulator Tests @@ -223,8 +223,8 @@ jobs: $ANDROID_HOME/platform-tools/adb logcat '*:D' >> adb-log.txt & bash .github/disable_art_metrics.sh # Fix "Speech Recognition and Synthesis from Google keeps stopping" causing RootViewWithoutFocusException - adb shell settings put global hide_error_dialogs 1 - adb emu screenrecord start --time-limit 1800 video.webm + bash .github/adb_retry.sh shell settings put global hide_error_dialogs 1 + bash .github/adb_retry.sh emu screenrecord start --time-limit 1800 video.webm sleep 5 ./gradlew uninstallAll jacocoAndroidTestReport --daemon From cac05734e79dea1699667df953361a318b90b9e2 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:59:07 +0100 Subject: [PATCH 11/50] fix(tests): await study options destruction This caused test failures trying to access a disposed backend Fixes DeckPickerTest.checkIfClickOnCountsLayoutOpensStudyOptionsOnMobile Fixes 22044 Assisted-by: GPT-6 --- .../java/com/ichi2/anki/DeckPickerTest.kt | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/DeckPickerTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/DeckPickerTest.kt index b68d670d32e9..8934159a6657 100644 --- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/DeckPickerTest.kt +++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/DeckPickerTest.kt @@ -20,11 +20,9 @@ import android.annotation.SuppressLint import androidx.test.espresso.Espresso.onView import androidx.test.espresso.assertion.ViewAssertions import androidx.test.espresso.assertion.ViewAssertions.matches -import androidx.test.espresso.matcher.ViewMatchers.assertThat import androidx.test.espresso.matcher.ViewMatchers.isDisplayed import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.ext.junit.rules.ActivityScenarioRule -import com.ichi2.anki.TestUtils.activityInstance import com.ichi2.anki.TestUtils.isTablet import com.ichi2.anki.tests.InstrumentedTest import com.ichi2.anki.testutil.GrantStoragePermission.storagePermission @@ -33,7 +31,7 @@ import com.ichi2.anki.testutil.discardPreliminaryViews import com.ichi2.anki.testutil.grantPermissions import com.ichi2.anki.testutil.notificationPermission import com.ichi2.anki.testutil.tapOnCountLayouts -import org.hamcrest.Matchers.instanceOf +import com.ichi2.anki.testutil.useResumedActivity import org.junit.Assume.assumeTrue import org.junit.Before import org.junit.Rule @@ -65,11 +63,10 @@ class DeckPickerTest : InstrumentedTest() { // Go to RecyclerView item having "Test Deck" and click on the counts layout tapOnCountLayouts("Default") - // Check if currently open Activity is StudyOptionsActivity - assertThat( - activityInstance, - instanceOf(StudyOptionsActivity::class.java), - ) + // ActivityScenarioRule only owns DeckPicker. Close Study Options before collection cleanup. + useResumedActivity { + onView(withId(R.id.studyoptions_frame)).check(matches(isDisplayed())) + } } @Test From 51f55b0e9d0426bbab092031bcff11faf0b01ed4 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 24 Sep 2026 13:39:14 +0100 Subject: [PATCH 12/50] test(reviewer): await zoom status Wait for WebView.canZoomIn before requesting a zoom. Using the DOM wasn't sufficient This fixes emulator tests: - buryingQuestionResetsZoomAndStartsNextQuestionAtTop - showingAnswerResetsZoomAndPreservesScrollPosition - answerAnchorTakesPrecedenceAfterResettingZoom - answerRenderingWaitsForZoomReset - nextQuestionResetsZoomAndStartsAtTop Fixes 22043 Assisted-by: GPT-6 --- .../com/ichi2/anki/ReviewerFragmentScrollTest.kt | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerFragmentScrollTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerFragmentScrollTest.kt index 5a1cb93361d0..38c832c4de07 100644 --- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerFragmentScrollTest.kt +++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/ReviewerFragmentScrollTest.kt @@ -2,6 +2,7 @@ package com.ichi2.anki +import android.webkit.WebView import androidx.core.content.edit import androidx.test.core.app.ActivityScenario import androidx.test.espresso.Espresso.onView @@ -290,7 +291,18 @@ class ReviewerFragmentScrollTest : InstrumentedTest() { private val SafeWebViewLayout.pageScale: Double get() = evaluateScript("window.visualViewport.scale").toDouble() + @Suppress("DEPRECATION") // Native zoom limits indicate whether WebView has applied the viewport. + private val SafeWebViewLayout.canZoomIn: Boolean + get() { + var canZoom = false + InstrumentationRegistry.getInstrumentation().runOnMainSync { + canZoom = (getChildAt(0) as WebView).canZoomIn() + } + return canZoom + } + private fun SafeWebViewLayout.zoomIn() { + waitUntil(message = { "WebView did not become ready to zoom" }) { canZoomIn } val originalScale = pageScale InstrumentationRegistry.getInstrumentation().runOnMainSync { zoomBy(2f) From b578fd8ee583f55325723c351b92d1860f2a502c Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 24 Sep 2026 15:22:22 +0100 Subject: [PATCH 13/50] fix(ci): avoid duplicate Dependabot CI runs Both `push` and `pull_request` triggered the same workflows. Drop `push` workflows for dependabot branches. Fixes 22063 Assisted-by: GPT-6 --- .github/workflows/lint.yml | 2 ++ .github/workflows/tests_emulator.yml | 2 ++ .github/workflows/tests_unit.yml | 2 ++ 3 files changed, 6 insertions(+) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f84b47fe80f7..1f6c1854e51c 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -7,6 +7,8 @@ on: # Ignore merge queue branches on push; avoids merge_group+push concurrency race since ref is same branches-ignore: - 'gh-readonly-queue/**' + # Dependabot branches are already checked by the pull_request trigger. + - 'dependabot/**' merge_group: permissions: diff --git a/.github/workflows/tests_emulator.yml b/.github/workflows/tests_emulator.yml index aa511978d29b..7d7757bfee03 100644 --- a/.github/workflows/tests_emulator.yml +++ b/.github/workflows/tests_emulator.yml @@ -13,6 +13,8 @@ on: # Ignore merge queue branches on push; avoids merge_group+push concurrency race since ref is same branches-ignore: - 'gh-readonly-queue/**' + # Dependabot branches are already checked by the pull_request trigger. + - 'dependabot/**' merge_group: permissions: diff --git a/.github/workflows/tests_unit.yml b/.github/workflows/tests_unit.yml index 12076f5cd7a3..989a1bc4d091 100644 --- a/.github/workflows/tests_unit.yml +++ b/.github/workflows/tests_unit.yml @@ -26,6 +26,8 @@ on: # Ignore merge queue branches on push; avoids merge_group+push concurrency race since ref is same branches-ignore: - 'gh-readonly-queue/**' + # Dependabot branches are already checked by the pull_request trigger. + - 'dependabot/**' merge_group: permissions: From c8e47738a900c7560809725ca8a0b313cea49ab8 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 22 Sep 2026 20:40:36 +0100 Subject: [PATCH 14/50] feat(deck-options): keep options open after optimizing presets Reload only after the optimization completes so the page picks up saved limits and optimized parameters. Normal saves still close the screen. This is prep for Anki 26.08/09 Part of 21926 See https://redirect.github.com/ankitects/anki/pull/4981 See https://redirect.github.com/ankitects/anki/issues/4978 Assisted-by: GPT-6 --- .../com/ichi2/anki/pages/DeckOptionsTest.kt | 48 +++++++++++++++ .../java/com/ichi2/anki/pages/DeckOptions.kt | 11 +++- .../com/ichi2/anki/pages/DeckOptionsTest.kt | 60 ++++++++++++++++++- 3 files changed, 117 insertions(+), 2 deletions(-) diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/pages/DeckOptionsTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/pages/DeckOptionsTest.kt index 27f6818edd55..c719fc408d5e 100644 --- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/pages/DeckOptionsTest.kt +++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/pages/DeckOptionsTest.kt @@ -6,6 +6,7 @@ import androidx.core.content.edit import androidx.core.view.isVisible import androidx.test.core.app.ActivityScenario import androidx.test.platform.app.InstrumentationRegistry +import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.R import com.ichi2.anki.SingleFragmentActivity import com.ichi2.anki.common.preferences.sharedPrefs @@ -15,6 +16,7 @@ import com.ichi2.anki.testutil.waitUntil import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout +import org.json.JSONObject import org.junit.After import org.junit.Before import org.junit.Test @@ -63,6 +65,52 @@ class DeckOptionsTest : InstrumentedTest() { } } + @Test + fun optimizingAllPresetsSavesAndReloadsOptions() { + withDeckOptions { + saveAndOptimize(newPerDay = 43) + + waitUntil(timeout = 30.seconds, message = { "optimization did not save the changed limit" }) { + newCardsPerDay == 43 + } + waitUntil(timeout = 30.seconds, message = { "options did not reload after optimization" }) { + evaluateJavascript( + "globalThis.beforeOptimization === undefined && Array.from(document.querySelectorAll('input[type=number]')).find(input => input.offsetParent !== null)?.value === '43'", + ) == + "true" + } + assertFalse(requireActivity().isFinishing) + } + } + + private val newCardsPerDay: Int + get() = + col.backend + .getDeckConfigsForUpdate(Consts.DEFAULT_DECK_ID) + .allConfigList + .first() + .config.config.newPerDay + + private fun DeckOptions.saveAndOptimize(newPerDay: Int) { + val script = + """ + globalThis.beforeOptimization = true; + window.confirm = () => true; + const input = Array.from(document.querySelectorAll('input[type="number"]')).find(input => input.offsetParent !== null); + input.focus(); + input.value = '$newPerDay'; + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); + Array.from(document.querySelectorAll('button')) + .find(button => button.textContent.trim() === ${JSONObject.quote(TR.deckConfigSaveAndOptimize())}).click(); + """.trimIndent() + // The action navigates away from its JavaScript context, which can discard the + // evaluation callback. Observe the persisted settings and the new page instead. + InstrumentationRegistry.getInstrumentation().runOnMainSync { + webViewLayout.evaluateJavascript(script) + } + } + private fun withDeckOptions(block: DeckOptions.() -> Unit) { val intent = DeckOptions.getIntent(testContext, Consts.DEFAULT_DECK_ID) ActivityScenario.launch(intent).use { scenario -> diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/pages/DeckOptions.kt b/AnkiDroid/src/main/java/com/ichi2/anki/pages/DeckOptions.kt index 8d984dc51175..45c8f8545ee3 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/pages/DeckOptions.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/pages/DeckOptions.kt @@ -14,6 +14,8 @@ import androidx.core.view.isVisible import androidx.fragment.app.FragmentActivity import anki.collection.ComputeParamsProgress import anki.collection.OpChanges +import anki.deck_config.UpdateDeckConfigsMode +import anki.deck_config.UpdateDeckConfigsRequest import com.google.android.material.appbar.MaterialToolbar import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CollectionManager.withCol @@ -343,7 +345,14 @@ suspend fun FragmentActivity.updateDeckConfigsRaw(input: ByteArray): ByteArray { } } undoableOp { OpChanges.parseFrom(output) } - withContext(Dispatchers.Main) { finish() } + withContext(Dispatchers.Main) { + if (UpdateDeckConfigsRequest.parseFrom(input).mode == UpdateDeckConfigsMode.UPDATE_DECK_CONFIGS_MODE_COMPUTE_ALL_PARAMS) { + // Reload so the page uses the newly optimized parameters. + requireDeckOptionsFragment().webViewLayout.reload() + } else { + finish() + } + } return output } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/pages/DeckOptionsTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/pages/DeckOptionsTest.kt index 666120880bc4..ccd3915a4339 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/pages/DeckOptionsTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/pages/DeckOptionsTest.kt @@ -5,6 +5,8 @@ package com.ichi2.anki.pages import android.webkit.WebView import androidx.core.view.children import androidx.test.ext.junit.runners.AndroidJUnit4 +import anki.deck_config.UpdateDeckConfigsMode +import anki.deck_config.UpdateDeckConfigsRequest import com.ichi2.anki.R import com.ichi2.anki.RobolectricTest import com.ichi2.anki.SingleFragmentActivity @@ -17,6 +19,9 @@ import org.junit.After import org.junit.Test import org.junit.runner.RunWith import org.robolectric.Shadows.shadowOf +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue /** Test for [DeckOptions] */ @RunWith(AndroidJUnit4::class) @@ -39,7 +44,60 @@ class DeckOptionsTest : RobolectricTest() { } } - private fun withDeckOptions(block: DeckOptions.() -> Unit) { + @Test + fun `optimizing all presets saves without closing options`() = + runTest { + withDeckOptions { + updateDeckConfigs(UpdateDeckConfigsMode.UPDATE_DECK_CONFIGS_MODE_COMPUTE_ALL_PARAMS, newPerDay = 42) + + assertFalse(requireActivity().isFinishing) + assertEquals(42, newCardsPerDay) + } + } + + @Test + fun `normal save closes options`() = + runTest { + withDeckOptions { + updateDeckConfigs(UpdateDeckConfigsMode.UPDATE_DECK_CONFIGS_MODE_NORMAL, newPerDay = 42) + + assertTrue(requireActivity().isFinishing) + assertEquals(42, newCardsPerDay) + } + } + + private val newCardsPerDay: Int + get() = + col.backend + .getDeckConfigsForUpdate(Consts.DEFAULT_DECK_ID) + .allConfigList + .single() + .config.config.newPerDay + + private suspend fun DeckOptions.updateDeckConfigs( + mode: UpdateDeckConfigsMode, + newPerDay: Int, + ) { + val data = col.backend.getDeckConfigsForUpdate(Consts.DEFAULT_DECK_ID) + val config = + data.allConfigList + .single() + .config + .toBuilder() + config.setConfig(config.config.toBuilder().setNewPerDay(newPerDay)) + val request = + UpdateDeckConfigsRequest + .newBuilder() + .setTargetDeckId(Consts.DEFAULT_DECK_ID) + .setMode(mode) + .setFsrs(true) + .addConfigs(config) + .build() + .toByteArray() + requireActivity().updateDeckConfigsRaw(request) + } + + private inline fun withDeckOptions(block: DeckOptions.() -> Unit) { val activity = startActivityNormallyOpenCollectionWithIntent( SingleFragmentActivity::class.java, From 2973612f7cbbb1295292762674e8655d685f9f5e Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 24 Sep 2026 14:41:52 +0100 Subject: [PATCH 15/50] refactor(test): extract :anki-common helpers to fixtures Assisted-by: GPT-6 --- AnkiDroid/build.gradle | 1 + .../java/com/ichi2/anki/pages/DeckOptionsTest.kt | 11 ++--------- .../java/com/ichi2/anki/pages/DeckOptionsTest.kt | 13 +++---------- anki-common/build.gradle.kts | 1 + .../kotlin/com/ichi2/testutils/ext/DeckConfig.kt | 15 +++++++++++++++ 5 files changed, 22 insertions(+), 19 deletions(-) create mode 100644 anki-common/src/testFixtures/kotlin/com/ichi2/testutils/ext/DeckConfig.kt diff --git a/AnkiDroid/build.gradle b/AnkiDroid/build.gradle index 62ec003b5091..25c53a7438a2 100644 --- a/AnkiDroid/build.gradle +++ b/AnkiDroid/build.gradle @@ -767,6 +767,7 @@ dependencies { testImplementation testFixtures(project(":common:android")) testImplementation testFixtures(project(":compat")) testImplementation testFixtures(project(":anki-common")) + androidTestImplementation testFixtures(project(":anki-common")) // A path for a testing library which provide Parameterized Test testImplementation libs.junit.jupiter diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/pages/DeckOptionsTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/pages/DeckOptionsTest.kt index c719fc408d5e..2f9cd900bf09 100644 --- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/pages/DeckOptionsTest.kt +++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/pages/DeckOptionsTest.kt @@ -13,6 +13,7 @@ import com.ichi2.anki.common.preferences.sharedPrefs import com.ichi2.anki.libanki.Consts import com.ichi2.anki.tests.InstrumentedTest import com.ichi2.anki.testutil.waitUntil +import com.ichi2.testutils.ext.defaultDeckNewCardsPerDay import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout @@ -71,7 +72,7 @@ class DeckOptionsTest : InstrumentedTest() { saveAndOptimize(newPerDay = 43) waitUntil(timeout = 30.seconds, message = { "optimization did not save the changed limit" }) { - newCardsPerDay == 43 + col.defaultDeckNewCardsPerDay == 43 } waitUntil(timeout = 30.seconds, message = { "options did not reload after optimization" }) { evaluateJavascript( @@ -83,14 +84,6 @@ class DeckOptionsTest : InstrumentedTest() { } } - private val newCardsPerDay: Int - get() = - col.backend - .getDeckConfigsForUpdate(Consts.DEFAULT_DECK_ID) - .allConfigList - .first() - .config.config.newPerDay - private fun DeckOptions.saveAndOptimize(newPerDay: Int) { val script = """ diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/pages/DeckOptionsTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/pages/DeckOptionsTest.kt index ccd3915a4339..1b828d6a38b4 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/pages/DeckOptionsTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/pages/DeckOptionsTest.kt @@ -13,6 +13,7 @@ import com.ichi2.anki.SingleFragmentActivity import com.ichi2.anki.libanki.Consts import com.ichi2.anki.settings.Prefs import com.ichi2.testutils.ext.clear +import com.ichi2.testutils.ext.defaultDeckNewCardsPerDay import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.containsString import org.junit.After @@ -51,7 +52,7 @@ class DeckOptionsTest : RobolectricTest() { updateDeckConfigs(UpdateDeckConfigsMode.UPDATE_DECK_CONFIGS_MODE_COMPUTE_ALL_PARAMS, newPerDay = 42) assertFalse(requireActivity().isFinishing) - assertEquals(42, newCardsPerDay) + assertEquals(42, col.defaultDeckNewCardsPerDay) } } @@ -62,18 +63,10 @@ class DeckOptionsTest : RobolectricTest() { updateDeckConfigs(UpdateDeckConfigsMode.UPDATE_DECK_CONFIGS_MODE_NORMAL, newPerDay = 42) assertTrue(requireActivity().isFinishing) - assertEquals(42, newCardsPerDay) + assertEquals(42, col.defaultDeckNewCardsPerDay) } } - private val newCardsPerDay: Int - get() = - col.backend - .getDeckConfigsForUpdate(Consts.DEFAULT_DECK_ID) - .allConfigList - .single() - .config.config.newPerDay - private suspend fun DeckOptions.updateDeckConfigs( mode: UpdateDeckConfigsMode, newPerDay: Int, diff --git a/anki-common/build.gradle.kts b/anki-common/build.gradle.kts index 55f596f3cd0d..99ae9e4695c0 100644 --- a/anki-common/build.gradle.kts +++ b/anki-common/build.gradle.kts @@ -38,6 +38,7 @@ dependencies { implementation(libs.kotlinx.coroutines.core) testFixturesImplementation(project(":common:android")) + testFixturesImplementation(project(":libanki")) testFixturesImplementation(libs.androidx.core.ktx) testFixturesImplementation(libs.androidx.test.core) // The Kotlin Compose Compiler plugin attaches to every Kotlin compilation in the diff --git a/anki-common/src/testFixtures/kotlin/com/ichi2/testutils/ext/DeckConfig.kt b/anki-common/src/testFixtures/kotlin/com/ichi2/testutils/ext/DeckConfig.kt new file mode 100644 index 000000000000..a4ef5d62207d --- /dev/null +++ b/anki-common/src/testFixtures/kotlin/com/ichi2/testutils/ext/DeckConfig.kt @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.testutils.ext + +import com.ichi2.anki.libanki.Collection +import com.ichi2.anki.libanki.Consts + +/** The preset's new-card limit for the default deck. */ +val Collection.defaultDeckNewCardsPerDay: Int + get() { + val data = backend.getDeckConfigsForUpdate(Consts.DEFAULT_DECK_ID) + return data.allConfigList + .single { it.config.id == data.currentDeck.configId } + .config.config.newPerDay + } From 4d19dbf9408e16cd1a3e2a49a2c47232e7e527e6 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 24 Sep 2026 16:59:36 +0100 Subject: [PATCH 16/50] fix: replace deprecated bundleOf calls Required for androidxCoreKtx:1.19.1 (androidx.core) Assisted-by: GPT-6 --- .../anki/browser/search/SortOrderBottomSheetFragment.kt | 3 +-- .../browser/search/SortOrderBottomSheetFragmentTest.kt | 8 ++++---- .../ichi2/anki/multimedia/MultimediaImageFragmentTest.kt | 4 ++-- .../com/ichi2/anki/workarounds/SafeWebViewLayoutTest.kt | 4 ++-- 4 files changed, 9 insertions(+), 10 deletions(-) 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 9c356dd51e5a..7d892e75309a 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 @@ -11,7 +11,6 @@ import androidx.annotation.CheckResult import androidx.annotation.StringRes import androidx.annotation.VisibleForTesting import androidx.core.content.ContextCompat -import androidx.core.os.bundleOf import androidx.core.view.isVisible import androidx.fragment.app.FragmentManager import androidx.fragment.app.activityViewModels @@ -447,7 +446,7 @@ class SortOrderBottomSheetFragment( Timber.i("creating SortOrderBottomSheetFragment with %s", sortData) - arguments = bundleOf(ARG_CURRENT_SORT_TYPE to sortData) + arguments = Bundle().apply { putParcelable(ARG_CURRENT_SORT_TYPE, sortData) } } } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/browser/search/SortOrderBottomSheetFragmentTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/browser/search/SortOrderBottomSheetFragmentTest.kt index 9741e14cd7ea..d397e31b9c0b 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/browser/search/SortOrderBottomSheetFragmentTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/browser/search/SortOrderBottomSheetFragmentTest.kt @@ -2,7 +2,7 @@ package com.ichi2.anki.browser.search -import androidx.core.os.bundleOf +import android.os.Bundle import androidx.lifecycle.Lifecycle import androidx.test.ext.junit.runners.AndroidJUnit4 import com.ichi2.anki.RobolectricTest @@ -48,9 +48,9 @@ class SortOrderBottomSheetFragmentTest : RobolectricTest() { val viewModelFactory = SingleViewModelFactory.create(createCardBrowserViewModel()) val fragmentArgs = - bundleOf( - ARG_CURRENT_SORT_TYPE to currentSortType, - ) + Bundle().apply { + putParcelable(ARG_CURRENT_SORT_TYPE, currentSortType) + } launchFragment(fragmentArgs) { SortOrderBottomSheetFragment(viewModelFactory) }.use { scenario -> diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/multimedia/MultimediaImageFragmentTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/multimedia/MultimediaImageFragmentTest.kt index e35b7c56e49c..fb9705dffe20 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/multimedia/MultimediaImageFragmentTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/multimedia/MultimediaImageFragmentTest.kt @@ -6,9 +6,9 @@ package com.ichi2.anki.multimedia import android.annotation.SuppressLint import android.content.Intent import android.net.Uri +import android.os.Bundle import android.webkit.WebView import androidx.core.net.toUri -import androidx.core.os.bundleOf import androidx.fragment.app.commitNow import androidx.test.ext.junit.runners.AndroidJUnit4 import com.ichi2.anki.RobolectricTest @@ -91,6 +91,6 @@ class MultimediaImageFragmentTest : RobolectricTest() { private fun withImageFragment(block: MultimediaImageFragment.() -> Unit) = launchFragmentInContainer( - bundleOf(EXTRA_MEDIA_OPTIONS to MultimediaImageFragment.ImageOptions.GALLERY), + Bundle().apply { putSerializable(EXTRA_MEDIA_OPTIONS, MultimediaImageFragment.ImageOptions.GALLERY) }, ).use { it.withFragment(block) } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/workarounds/SafeWebViewLayoutTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/workarounds/SafeWebViewLayoutTest.kt index 311326f40203..56b4ab5355a6 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/workarounds/SafeWebViewLayoutTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/workarounds/SafeWebViewLayoutTest.kt @@ -2,10 +2,10 @@ package com.ichi2.anki.workarounds +import android.os.Bundle import android.view.ViewGroup import android.webkit.WebView import android.widget.FrameLayout -import androidx.core.os.bundleOf import androidx.test.ext.junit.runners.AndroidJUnit4 import com.ichi2.anki.RobolectricTest import com.ichi2.anki.multimedia.MultimediaActivity.Companion.EXTRA_MEDIA_OPTIONS @@ -229,7 +229,7 @@ class SafeWebViewLayoutTest : RobolectricTest() { private fun withMultimediaWebView(block: (SafeWebViewLayout, MultimediaImageFragment) -> Unit) { launchFragmentInContainer( - bundleOf(EXTRA_MEDIA_OPTIONS to MultimediaImageFragment.ImageOptions.GALLERY), + Bundle().apply { putSerializable(EXTRA_MEDIA_OPTIONS, MultimediaImageFragment.ImageOptions.GALLERY) }, ).use { scenario -> scenario.onFragment { fragment -> block(fragment.binding.multimediaWebView, fragment) From 7c78fb0fa8ce2f14f69f984e3529580c9aca2849 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:59:18 +0100 Subject: [PATCH 17/50] refactor(browser): remove duplicate icon load Already assigned above Assisted-by: GPT-6 --- .../java/com/ichi2/anki/browser/search/StandardSearchFragment.kt | 1 - 1 file changed, 1 deletion(-) 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 afd814588b87..60f840b9c80e 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 @@ -140,7 +140,6 @@ class StandardSearchFragment : ) } } - binding.cardStateChip.chipIcon = ContextCompat.getDrawable(requireContext(), it.cardStates.firstOrNull().iconRes) binding.flagsChip.text = formatChipDescription( From 88e75e2438364c9734ec7a1548af1be8245b4881 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:59:38 +0100 Subject: [PATCH 18/50] fix(browser): fix search icon tinting Call `.mutate()` so the state of other icons is unchanged. Fixes 22067 Assisted-by: GPT-6 --- .../java/com/ichi2/anki/browser/CardBrowserFragment.kt | 10 ++++++++-- .../anki/browser/search/StandardSearchFragment.kt | 4 ++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt index 20edde8125f2..f319ab82c71d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt @@ -1188,7 +1188,10 @@ class CardBrowserFragment : cardStateChip?.chipIcon = ContextCompat.getDrawable(requireContext(), filters.cardStates.firstOrNull().iconRes)?.also { if (filters.cardStates.isEmpty()) { - DrawableCompat.setTint(it, MaterialColors.getColor(requireContext(), androidx.appcompat.R.attr.colorPrimary, 0)) + DrawableCompat.setTint( + it.mutate(), + MaterialColors.getColor(requireContext(), androidx.appcompat.R.attr.colorPrimary, 0), + ) } } cardStateChip?.hasCheckedBackground = filters.cardStates.any() @@ -1203,7 +1206,10 @@ class CardBrowserFragment : flagsChip?.chipIcon = ContextCompat.getDrawable(requireContext(), filters.flags.firstOrNull().iconRes)?.also { if (filters.flags.isEmpty()) { - DrawableCompat.setTint(it, MaterialColors.getColor(requireContext(), androidx.appcompat.R.attr.colorPrimary, 0)) + DrawableCompat.setTint( + it.mutate(), + MaterialColors.getColor(requireContext(), androidx.appcompat.R.attr.colorPrimary, 0), + ) } } flagsChip?.hasCheckedBackground = filters.flags.any() 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 60f840b9c80e..6c7ac4ddf977 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 @@ -135,7 +135,7 @@ class StandardSearchFragment : ContextCompat.getDrawable(requireContext(), it.cardStates.firstOrNull().iconRes)?.also { drawable -> if (it.cardStates.isEmpty()) { DrawableCompat.setTint( - drawable, + drawable.mutate(), MaterialColors.getColor(requireContext(), androidx.appcompat.R.attr.colorPrimary, 0), ) } @@ -151,7 +151,7 @@ class StandardSearchFragment : ContextCompat.getDrawable(requireContext(), it.flags.firstOrNull().iconRes)?.also { drawable -> if (it.flags.isEmpty()) { DrawableCompat.setTint( - drawable, + drawable.mutate(), MaterialColors.getColor(requireContext(), androidx.appcompat.R.attr.colorPrimary, 0), ) } From e875fea956400198d6019e816868eb404c1be48d Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 24 Sep 2026 11:53:01 +0100 Subject: [PATCH 19/50] feat(ci): report emulator test failures As a step, rather than needing to download the artifacts * Tests with assumption failures appear, but last, and hidden * Same with ignored tests Assisted-by: GPT-6 --- .github/report_emulator_test_failures.py | 97 ++++++++++++++++++++++++ .github/workflows/tests_emulator.yml | 6 +- 2 files changed, 102 insertions(+), 1 deletion(-) create mode 100644 .github/report_emulator_test_failures.py diff --git a/.github/report_emulator_test_failures.py b/.github/report_emulator_test_failures.py new file mode 100644 index 000000000000..c686509c268a --- /dev/null +++ b/.github/report_emulator_test_failures.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Report failures and skipped tests from Android's JUnit XML using the standard library.""" + +import html +import os +from pathlib import Path +import re +import sys +import xml.etree.ElementTree as ET + + +def read_ignored_tests(log_file): + ignored = set() + failed = set() + if log_file is None or not log_file.is_file(): + return ignored + with log_file.open(encoding="utf-8", errors="replace") as log: + for line in log: + match = re.search(r"\bTestRunner: (ignored|failed): (.+)\(([^()]+)\)\s*$", line) + if match: + status, method, classname = match.groups() + (ignored if status == "ignored" else failed).add(f"{classname}.{method}") + return ignored - failed + + +def main(results_dir, log_file=None): + failures = [] + skipped = [] + ignored = read_ignored_tests(log_file) + for report in sorted(results_dir.rglob("TEST-*.xml")): + try: + root = ET.parse(report).getroot() + except (ET.ParseError, OSError) as error: + failures.append(f"Could not read {report}: {error}") + continue + + for test in root.iter("testcase"): + for result in test: + if result.tag not in ("failure", "error", "skipped"): + continue + name = f"{test.get('classname', '')}.{test.get('name', '')}" + details = "".join(result.itertext()).strip() + message = result.get("message", "") + # Android can report assumption violations as failures or errors. + # Only match the top-level exception, not mentions in a real failure. + exception_type = result.get("type") or (details or message).partition("\n")[0].partition(":")[0] + # The Android test engine also writes @Ignore as an empty failure. + # Require the runner's ignore event before treating an empty failure as skipped. + is_ignored = name in ignored and not (details or message or result.get("type")) + if message and message not in details: + details = f"{message}\n{details}".strip() + if result.tag == "skipped" or is_ignored or exception_type in ( + "org.junit.AssumptionViolatedException", + "org.junit.internal.AssumptionViolatedException", + ): + skipped.append(f"{name}\n{details or result.get('type', 'Test skipped')}") + else: + failures.append(f"{name}\n{details or result.get('type', 'Test failed')}") + + for failure in failures: + if os.environ.get("GITHUB_ACTIONS") == "true": + # GitHub renders the escaped message as a multiline error in the log. + message = failure.replace("%", "%25").replace("\r", "%0D").replace("\n", "%0A") + print(f"::error::{message}") + else: + print(failure, end="\n\n") + + if skipped: + title = f"Skipped tests ({len(skipped)})" + if os.environ.get("GITHUB_ACTIONS") == "true": + print(f"::group::{title}") + else: + print(title, end="\n\n") + for skip in skipped: + print(skip, end="\n\n") + if os.environ.get("GITHUB_ACTIONS") == "true": + print("::endgroup::") + + if (failures or skipped) and os.environ.get("GITHUB_STEP_SUMMARY"): + with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as summary: + if failures: + summary.write("### Emulator test failures\n\n") + for failure in failures: + summary.write(f"
{html.escape(failure)}
\n\n") + if skipped: + summary.write(f"
{title}\n\n") + for skip in skipped: + summary.write(f"
{html.escape(skip)}
\n\n") + summary.write("
\n\n") + + return 1 if failures else 0 + + +if __name__ == "__main__": + sys.exit(main(Path(sys.argv[1]), Path(sys.argv[2]) if len(sys.argv) > 2 else None)) diff --git a/.github/workflows/tests_emulator.yml b/.github/workflows/tests_emulator.yml index 7d7757bfee03..7efb3ac6c300 100644 --- a/.github/workflows/tests_emulator.yml +++ b/.github/workflows/tests_emulator.yml @@ -230,6 +230,10 @@ jobs: sleep 5 ./gradlew uninstallAll jacocoAndroidTestReport --daemon + - name: Report Emulator Test Results + if: always() && hashFiles('AnkiDroid/build/outputs/androidTest-results/connected/**/TEST-*.xml') != '' + run: python3 .github/report_emulator_test_failures.py AnkiDroid/build/outputs/androidTest-results/connected adb-log.txt + - name: Configure NDK tools PATH for alignment checking run: | echo "Using pre-installed NDK tools for alignment checking" @@ -302,4 +306,4 @@ jobs: - uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 with: verbose: true - fail_ci_if_error: ${{ github.repository == 'ankidroid/Anki-Android' }} # optional (default = false) \ No newline at end of file + fail_ci_if_error: ${{ github.repository == 'ankidroid/Anki-Android' }} # optional (default = false) From 55b9b6caeb59c991fe4dca2508244aad93c89f0f Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 24 Sep 2026 17:42:30 +0100 Subject: [PATCH 20/50] fix(card-viewer): fix unnecessary onCleared call Prep for `EmptySuperCall` lint in androidx.lifecycle:2.11.0 https://developer.android.com/jetpack/androidx/releases/lifecycle#version_211_2 Prep for PR 21314 --- .../main/java/com/ichi2/anki/previewer/CardViewerViewModel.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerViewModel.kt index ff94d5f977ed..26af26daee03 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerViewModel.kt @@ -57,7 +57,6 @@ abstract class CardViewerViewModel( @CallSuper override fun onCleared() { server.stop() - super.onCleared() } /* ********************************************************************************************* From 59c5994995b27987de6178d82f3571c828d7c534 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 24 Sep 2026 18:37:00 +0100 Subject: [PATCH 21/50] fix(tests): download Robolectric for Android 7.1 This is used by `PermissionsTest`, so it caused a runtime fetch. Related to 22069 Assisted-by: GPT-6 --- AnkiDroid/robolectricDownloader.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AnkiDroid/robolectricDownloader.gradle b/AnkiDroid/robolectricDownloader.gradle index bfbd1c8e73d4..83756ce8ed29 100644 --- a/AnkiDroid/robolectricDownloader.gradle +++ b/AnkiDroid/robolectricDownloader.gradle @@ -21,7 +21,7 @@ def robolectricAndroidSdkVersions = [ // [androidVersion: "6.0.1_r3", frameworkSdkBuildVersion: "r1"], // [androidVersion: "7.0.0_r1", frameworkSdkBuildVersion: "r1"], -// [androidVersion: "7.1.0_r7", frameworkSdkBuildVersion: "r1"], + [androidVersion: "7.1.0_r7", frameworkSdkBuildVersion: "r1"], // [androidVersion: "8.0.0_r4", frameworkSdkBuildVersion: "r1"], [androidVersion: "8.1.0", frameworkSdkBuildVersion: "4611349"], [androidVersion: "9", frameworkSdkBuildVersion: "4913185-2"], From fc207d2f18cb4c42ca28373626956c8c6f06aeee Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 16 Aug 2026 02:37:05 +0100 Subject: [PATCH 22/50] refactor(navigation): move deck options methods to :anki-common 097c7cdc6c` moved `CollectionManager` down, so these methods can now be moved to `:anki-common`. More work for the for `:widgets` multimodule migration Part of 20737 Assisted-by: Claude Fable 5 --- .../java/com/ichi2/anki/CoroutineHelpers.kt | 2 +- .../anki/pages/DeckOptionsDestination.kt | 19 ------------------- .../widget/cardanalysis/CardAnalysisWidget.kt | 2 +- .../widget/deckpicker/DeckPickerWidget.kt | 2 +- .../destinations/DeckOptionsDestination.kt | 18 ++++++++++++++++++ 5 files changed, 21 insertions(+), 22 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt index b9de1b56aeb4..54c009621b37 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt @@ -31,12 +31,12 @@ import com.ichi2.anki.common.annotations.UseContextParameter import com.ichi2.anki.common.coroutines.applicationScope import com.ichi2.anki.common.crashreporting.CrashReportService import com.ichi2.anki.common.destinations.DeckOptionsDestination +import com.ichi2.anki.common.destinations.fromCurrentDeck import com.ichi2.anki.dialogs.DatabaseErrorDialog import com.ichi2.anki.dialogs.DatabaseErrorDialog.DatabaseErrorDialogType import com.ichi2.anki.exception.StorageAccessException import com.ichi2.anki.exception.StorageNotConfiguredException import com.ichi2.anki.libanki.exception.InvalidSearchException -import com.ichi2.anki.pages.fromCurrentDeck import com.ichi2.anki.pages.toIntent import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.startup.redirectToMainEntryPoint diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/pages/DeckOptionsDestination.kt b/AnkiDroid/src/main/java/com/ichi2/anki/pages/DeckOptionsDestination.kt index 25dc721294fa..71260b786b57 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/pages/DeckOptionsDestination.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/pages/DeckOptionsDestination.kt @@ -4,11 +4,8 @@ package com.ichi2.anki.pages import android.content.Context import android.content.Intent -import androidx.annotation.CheckResult -import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.common.destinations.DeckOptionsDestination import com.ichi2.anki.filtered.FilteredDeckOptionsFragment -import com.ichi2.anki.libanki.DeckId /** Builds the [Intent] that opens the deck options screen for this destination. */ fun DeckOptionsDestination.toIntent(context: Context): Intent = @@ -17,19 +14,3 @@ fun DeckOptionsDestination.toIntent(context: Context): Intent = } else { DeckOptions.getIntent(context, deckId) } - -suspend fun DeckOptionsDestination.Companion.fromDeckId(deckId: DeckId): DeckOptionsDestination = - DeckOptionsDestination( - deckId = deckId, - isFiltered = withCol { decks.isFiltered(deckId) }, - ) - -@CheckResult -suspend fun DeckOptionsDestination.Companion.fromCurrentDeck(): DeckOptionsDestination = - withCol { - val deckId = decks.getCurrentId() - DeckOptionsDestination( - deckId = deckId, - isFiltered = decks.isFiltered(deckId), - ) - } diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidget.kt b/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidget.kt index 27da73b38062..858305152dcc 100644 --- a/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidget.kt +++ b/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidget.kt @@ -17,11 +17,11 @@ import com.ichi2.anki.common.crashreporting.CrashReportService import com.ichi2.anki.common.destinations.DeckOptionsDestination import com.ichi2.anki.common.destinations.DeferredNavigation import com.ichi2.anki.common.destinations.ReviewDeckDestination +import com.ichi2.anki.common.destinations.fromDeckId import com.ichi2.anki.common.destinations.toIntent import com.ichi2.anki.isCollectionEmpty import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.libanki.Decks.Companion.NOT_FOUND_DECK_ID -import com.ichi2.anki.pages.fromDeckId import com.ichi2.widget.ACTION_UPDATE_WIDGET import com.ichi2.widget.AnalyticsWidgetProvider import com.ichi2.widget.AppWidgetId diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/deckpicker/DeckPickerWidget.kt b/AnkiDroid/src/main/java/com/ichi2/widget/deckpicker/DeckPickerWidget.kt index a3d8bb8709cd..ae4c1056b124 100644 --- a/AnkiDroid/src/main/java/com/ichi2/widget/deckpicker/DeckPickerWidget.kt +++ b/AnkiDroid/src/main/java/com/ichi2/widget/deckpicker/DeckPickerWidget.kt @@ -18,10 +18,10 @@ import com.ichi2.anki.common.crashreporting.CrashReportService import com.ichi2.anki.common.destinations.DeckOptionsDestination import com.ichi2.anki.common.destinations.DeferredNavigation import com.ichi2.anki.common.destinations.ReviewDeckDestination +import com.ichi2.anki.common.destinations.fromDeckId import com.ichi2.anki.common.destinations.toIntent import com.ichi2.anki.isCollectionEmpty import com.ichi2.anki.libanki.DeckId -import com.ichi2.anki.pages.fromDeckId import com.ichi2.widget.ACTION_UPDATE_WIDGET import com.ichi2.widget.AnalyticsWidgetProvider import com.ichi2.widget.AppWidgetId diff --git a/anki-common/src/main/kotlin/com/ichi2/anki/common/destinations/DeckOptionsDestination.kt b/anki-common/src/main/kotlin/com/ichi2/anki/common/destinations/DeckOptionsDestination.kt index bc0327a8a858..89bdc9fb28fc 100644 --- a/anki-common/src/main/kotlin/com/ichi2/anki/common/destinations/DeckOptionsDestination.kt +++ b/anki-common/src/main/kotlin/com/ichi2/anki/common/destinations/DeckOptionsDestination.kt @@ -2,6 +2,8 @@ package com.ichi2.anki.common.destinations +import androidx.annotation.CheckResult +import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.libanki.DeckId /** @@ -26,3 +28,19 @@ data class DeckOptionsEntry( val name: String?, val isFiltered: Boolean, ) + +suspend fun DeckOptionsDestination.Companion.fromDeckId(deckId: DeckId): DeckOptionsDestination = + DeckOptionsDestination( + deckId = deckId, + isFiltered = withCol { decks.isFiltered(deckId) }, + ) + +@CheckResult +suspend fun DeckOptionsDestination.Companion.fromCurrentDeck(): DeckOptionsDestination = + withCol { + val deckId = decks.getCurrentId() + DeckOptionsDestination( + deckId = deckId, + isFiltered = decks.isFiltered(deckId), + ) + } From ed39db19d796dfee2b1c1e6fbfd2fa7943a7ff27 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 16 Aug 2026 02:37:50 +0100 Subject: [PATCH 23/50] refactor(navigation): inline extension methods Part of 20737 Assisted-by: Claude Fable 5 --- .../java/com/ichi2/anki/CoroutineHelpers.kt | 1 - .../widget/cardanalysis/CardAnalysisWidget.kt | 1 - .../widget/deckpicker/DeckPickerWidget.kt | 1 - .../destinations/DeckOptionsDestination.kt | 34 +++++++++---------- 4 files changed, 17 insertions(+), 20 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt index 54c009621b37..2d727f075457 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt @@ -31,7 +31,6 @@ import com.ichi2.anki.common.annotations.UseContextParameter import com.ichi2.anki.common.coroutines.applicationScope import com.ichi2.anki.common.crashreporting.CrashReportService import com.ichi2.anki.common.destinations.DeckOptionsDestination -import com.ichi2.anki.common.destinations.fromCurrentDeck import com.ichi2.anki.dialogs.DatabaseErrorDialog import com.ichi2.anki.dialogs.DatabaseErrorDialog.DatabaseErrorDialogType import com.ichi2.anki.exception.StorageAccessException diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidget.kt b/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidget.kt index 858305152dcc..3997e058f87f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidget.kt +++ b/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidget.kt @@ -17,7 +17,6 @@ import com.ichi2.anki.common.crashreporting.CrashReportService import com.ichi2.anki.common.destinations.DeckOptionsDestination import com.ichi2.anki.common.destinations.DeferredNavigation import com.ichi2.anki.common.destinations.ReviewDeckDestination -import com.ichi2.anki.common.destinations.fromDeckId import com.ichi2.anki.common.destinations.toIntent import com.ichi2.anki.isCollectionEmpty import com.ichi2.anki.libanki.DeckId diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/deckpicker/DeckPickerWidget.kt b/AnkiDroid/src/main/java/com/ichi2/widget/deckpicker/DeckPickerWidget.kt index ae4c1056b124..ece3b92dcfd7 100644 --- a/AnkiDroid/src/main/java/com/ichi2/widget/deckpicker/DeckPickerWidget.kt +++ b/AnkiDroid/src/main/java/com/ichi2/widget/deckpicker/DeckPickerWidget.kt @@ -18,7 +18,6 @@ import com.ichi2.anki.common.crashreporting.CrashReportService import com.ichi2.anki.common.destinations.DeckOptionsDestination import com.ichi2.anki.common.destinations.DeferredNavigation import com.ichi2.anki.common.destinations.ReviewDeckDestination -import com.ichi2.anki.common.destinations.fromDeckId import com.ichi2.anki.common.destinations.toIntent import com.ichi2.anki.isCollectionEmpty import com.ichi2.anki.libanki.DeckId diff --git a/anki-common/src/main/kotlin/com/ichi2/anki/common/destinations/DeckOptionsDestination.kt b/anki-common/src/main/kotlin/com/ichi2/anki/common/destinations/DeckOptionsDestination.kt index 89bdc9fb28fc..8fad0b3873da 100644 --- a/anki-common/src/main/kotlin/com/ichi2/anki/common/destinations/DeckOptionsDestination.kt +++ b/anki-common/src/main/kotlin/com/ichi2/anki/common/destinations/DeckOptionsDestination.kt @@ -16,7 +16,23 @@ data class DeckOptionsDestination( val isFiltered: Boolean, val options: List = emptyList(), ) : Destination() { - companion object + companion object { + suspend fun fromDeckId(deckId: DeckId): DeckOptionsDestination = + DeckOptionsDestination( + deckId = deckId, + isFiltered = withCol { decks.isFiltered(deckId) }, + ) + + @CheckResult + suspend fun fromCurrentDeck(): DeckOptionsDestination = + withCol { + val deckId = decks.getCurrentId() + DeckOptionsDestination( + deckId = deckId, + isFiltered = decks.isFiltered(deckId), + ) + } + } } /** @@ -28,19 +44,3 @@ data class DeckOptionsEntry( val name: String?, val isFiltered: Boolean, ) - -suspend fun DeckOptionsDestination.Companion.fromDeckId(deckId: DeckId): DeckOptionsDestination = - DeckOptionsDestination( - deckId = deckId, - isFiltered = withCol { decks.isFiltered(deckId) }, - ) - -@CheckResult -suspend fun DeckOptionsDestination.Companion.fromCurrentDeck(): DeckOptionsDestination = - withCol { - val deckId = decks.getCurrentId() - DeckOptionsDestination( - deckId = deckId, - isFiltered = decks.isFiltered(deckId), - ) - } From 1cfc7e63b79012cf0ebe2ae44569480817fb3da8 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 16 Aug 2026 10:55:15 +0100 Subject: [PATCH 24/50] build(anki-common): add unit test & testFixtures support Part of 20737 Assisted-by: Claude Fable 5 --- anki-common/build.gradle.kts | 10 ++++++++++ build.gradle.kts | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/anki-common/build.gradle.kts b/anki-common/build.gradle.kts index 99ae9e4695c0..eaaeb1b72c4f 100644 --- a/anki-common/build.gradle.kts +++ b/anki-common/build.gradle.kts @@ -37,10 +37,20 @@ dependencies { implementation(libs.jakewharton.timber) implementation(libs.kotlinx.coroutines.core) + testImplementation(testFixtures(project(":anki-common"))) + testImplementation(libs.hamcrest) + testImplementation(libs.junit.jupiter) + testImplementation(libs.junit.platform.launcher) + testImplementation(libs.junit.vintage.engine) + testImplementation(libs.kotlin.reflect) + testImplementation(libs.kotlinx.coroutines.test) + testFixturesImplementation(project(":common:android")) testFixturesImplementation(project(":libanki")) testFixturesImplementation(libs.androidx.core.ktx) testFixturesImplementation(libs.androidx.test.core) + testFixturesImplementation(libs.jakewharton.timber) + testFixturesImplementation(libs.kotlin.test) // The Kotlin Compose Compiler plugin attaches to every Kotlin compilation in the // module including testFixtures, which has no @Composable code and refuses to // run unless the Compose Runtime is on the classpath. compileOnly satisfies the diff --git a/build.gradle.kts b/build.gradle.kts index 9acffddbf7eb..e69928ced7d6 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -141,7 +141,7 @@ subprojects { compilerArgs += "-XXLanguage:+ExplicitBackingFields" } - if (project.path !in listOf(":anki-common", ":api", ":common", ":common:android")) { + if (project.path !in listOf(":api", ":common", ":common:android")) { compilerArgs += "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi" } if (project.path != ":api") { From b2e93fc5537b0b33f0577739415827c98bb20164 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 16 Aug 2026 13:25:04 +0100 Subject: [PATCH 25/50] test: move ChangeManager tests to :anki-common TestChangeSubscriber moves to testFixtures, and ChangeManagerTest uses its ChangeCounter directly Part of 20737 Assisted-by: Claude Fable 5 --- .../ichi2/anki/StudyOptionsViewModelTest.kt | 2 +- .../anki/browser/CardBrowserViewModelTest.kt | 6 +- .../deckpicker/DeckPickerViewModelTest.kt | 2 +- .../anki/tags/ManageTagsViewModelTest.kt | 2 +- .../anki/observability/ChangeManagerTest.kt | 94 ++++++++++++++++--- .../observability}/TestChangeSubscriber.kt | 22 ++--- 6 files changed, 96 insertions(+), 32 deletions(-) rename {AnkiDroid/src/test/java => anki-common/src/test/kotlin}/com/ichi2/anki/observability/ChangeManagerTest.kt (64%) rename {AnkiDroid/src/test/java/com/ichi2/testutils => anki-common/src/testFixtures/kotlin/com/ichi2/anki/observability}/TestChangeSubscriber.kt (80%) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/StudyOptionsViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/StudyOptionsViewModelTest.kt index 1c697ee7facb..85f4377a6b8b 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/StudyOptionsViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/StudyOptionsViewModelTest.kt @@ -6,7 +6,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import anki.scheduler.CardAnswer.Rating import app.cash.turbine.test import com.ichi2.anki.CollectionManager.withCol -import com.ichi2.testutils.ensureOpsExecuted +import com.ichi2.anki.observability.ensureOpsExecuted import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch import org.hamcrest.MatcherAssert.assertThat diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserViewModelTest.kt index bb6777e953c9..23fca6425617 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserViewModelTest.kt @@ -67,6 +67,9 @@ import com.ichi2.anki.model.SelectableDeck import com.ichi2.anki.model.SortType import com.ichi2.anki.model.cardBrowserNoSorting import com.ichi2.anki.noteeditor.toIntent +import com.ichi2.anki.observability.ensureNoOpsExecuted +import com.ichi2.anki.observability.ensureOpWithHandler +import com.ichi2.anki.observability.ensureOpsExecuted import com.ichi2.anki.servicelayer.NoteService import com.ichi2.anki.setFlagFilterSync import com.ichi2.anki.settings.Prefs @@ -75,9 +78,6 @@ import com.ichi2.anki.utils.ext.ignoreAccentsInSearch import com.ichi2.testutils.IntentAssert import com.ichi2.testutils.JvmTest import com.ichi2.testutils.createTransientDirectory -import com.ichi2.testutils.ensureNoOpsExecuted -import com.ichi2.testutils.ensureOpWithHandler -import com.ichi2.testutils.ensureOpsExecuted import com.ichi2.testutils.ext.reopenWithLanguage import com.ichi2.testutils.mockIt import kotlinx.coroutines.CancellationException diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/DeckPickerViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/DeckPickerViewModelTest.kt index f218e15f292e..b61a14b6f3a8 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/DeckPickerViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/DeckPickerViewModelTest.kt @@ -14,7 +14,7 @@ import com.ichi2.anki.libanki.Consts import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.libanki.Note import com.ichi2.anki.libanki.emptyCids -import com.ichi2.testutils.ensureOpsExecuted +import com.ichi2.anki.observability.ensureOpsExecuted import org.hamcrest.CoreMatchers.not import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.equalTo 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 018ab1f713cd..b3f1b947ddb5 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt @@ -6,7 +6,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import com.ichi2.anki.RobolectricTest import com.ichi2.anki.dialogs.utils.AnKingTags -import com.ichi2.testutils.ensureOpsExecuted +import com.ichi2.anki.observability.ensureOpsExecuted import kotlinx.coroutines.flow.first import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.containsInAnyOrder diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/observability/ChangeManagerTest.kt b/anki-common/src/test/kotlin/com/ichi2/anki/observability/ChangeManagerTest.kt similarity index 64% rename from AnkiDroid/src/test/java/com/ichi2/anki/observability/ChangeManagerTest.kt rename to anki-common/src/test/kotlin/com/ichi2/anki/observability/ChangeManagerTest.kt index 5e402a50eb1e..9dc7499cd623 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/observability/ChangeManagerTest.kt +++ b/anki-common/src/test/kotlin/com/ichi2/anki/observability/ChangeManagerTest.kt @@ -2,30 +2,38 @@ package com.ichi2.anki.observability -import androidx.test.ext.junit.runners.AndroidJUnit4 +import android.app.Activity +import android.content.Context import anki.collection.OpChanges -import com.ichi2.testutils.EmptyApplication -import com.ichi2.testutils.JvmTest -import com.ichi2.testutils.subscriberChangeCounter +import com.ichi2.anki.common.crashreporting.CrashReportService +import com.ichi2.anki.common.crashreporting.CrashReporter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.equalTo import org.hamcrest.Matchers.greaterThan +import org.junit.After +import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.Parameterized -import org.robolectric.annotation.Config import kotlin.reflect.KProperty1 import kotlin.reflect.full.memberProperties import kotlin.reflect.javaType import kotlin.reflect.jvm.isAccessible -// this cannot yet use `EmptyApplication` - `CrashReportService` dependency -@RunWith(AndroidJUnit4::class) -class ChangeManagerExceptionHandlingTest : JvmTest() { +class ChangeManagerExceptionHandlingTest { + @Before + fun setUp() { + ChangeManager.resetForTesting() + // an exception in a subscriber is sent to the crash reporting service + CrashReportService.setReporter(NoOpCrashReporter) + } + @Test fun `all subscribers notified even if one throws exception`() { var firstCalled = false @@ -96,9 +104,17 @@ class ChangeManagerTest { } } -@RunWith(AndroidJUnit4::class) -@Config(application = EmptyApplication::class) -class ChangeManagerPublishTest : JvmTest() { +class ChangeManagerPublishTest { + @Before + fun setUp() { + ChangeManager.resetForTesting() + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + @Test fun `publish notifies multiple subscribers asynchronously`() = runTest { @@ -118,3 +134,59 @@ class ChangeManagerPublishTest : JvmTest() { assertThat("Second subscriber should be notified", counter2.hasChanges, equalTo(true)) } } + +/** + * Produces a [ChangeCounter] which is subscribed to [ChangeManager]. + * + * Query the result via [.hasChanges][ChangeCounter.hasChanges] + */ +private fun subscriberChangeCounter(): ChangeCounter { + val counter = ChangeCounter() + ChangeManager.subscribe(counter) + return counter +} + +/** A [CrashReporter] which ignores all reports */ +private object NoOpCrashReporter : CrashReporter { + override fun sendExceptionReport( + message: String?, + origin: String?, + ) { + } + + override fun sendExceptionReport( + e: Throwable, + origin: String?, + additionalInfo: String?, + onlyIfSilent: Boolean, + ) { + } + + override fun sendExceptionReport( + e: Throwable, + origin: String?, + additionalInfo: String?, + onlyIfSilent: Boolean, + context: Context, + ) { + } + + override fun onPreferenceChanged( + ctx: Context, + newValue: String, + ) { + } + + override fun deleteLimiterData(context: Context) { + } + + override fun setReportingMode(value: String) { + } + + override fun isEnabled( + context: Context, + defaultValue: Boolean, + ): Boolean = false + + override fun sendReport(activity: Activity): Boolean = false +} diff --git a/AnkiDroid/src/test/java/com/ichi2/testutils/TestChangeSubscriber.kt b/anki-common/src/testFixtures/kotlin/com/ichi2/anki/observability/TestChangeSubscriber.kt similarity index 80% rename from AnkiDroid/src/test/java/com/ichi2/testutils/TestChangeSubscriber.kt rename to anki-common/src/testFixtures/kotlin/com/ichi2/anki/observability/TestChangeSubscriber.kt index 48b8cb0c1c44..ae1fa9b41020 100644 --- a/AnkiDroid/src/test/java/com/ichi2/testutils/TestChangeSubscriber.kt +++ b/anki-common/src/testFixtures/kotlin/com/ichi2/anki/observability/TestChangeSubscriber.kt @@ -1,9 +1,8 @@ // SPDX-License-Identifier: GPL-3.0-or-later -package com.ichi2.testutils +package com.ichi2.anki.observability import anki.collection.OpChanges -import com.ichi2.anki.observability.ChangeManager import timber.log.Timber import kotlin.test.fail @@ -59,8 +58,12 @@ suspend fun ensureOpWithHandler( fail("ChangeManager: expected handler to be $handler, but was ${handlerAccessor.handler}") } -// used to ensure a strong reference to the subscription is held -internal class ChangeCounter : ChangeManager.Subscriber { +/** + * Counts calls to [ChangeManager.notifySubscribers] + * + * Used to ensure a strong reference to the subscription is held + */ +class ChangeCounter : ChangeManager.Subscriber { private var changes = 0 val changeCount get() = changes val hasChanges get() = changes > 0 @@ -85,14 +88,3 @@ private class ExtractOpHandler : ChangeManager.Subscriber { this.handler = handler } } - -/** - * Produces a [ChangeCounter] which is subscribed to [ChangeManager]. - * - * Query the result via [.changeCount][ChangeCounter.changeCount] or [.hasChanges][ChangeCounter.hasChanges] - */ -internal fun subscriberChangeCounter(): ChangeCounter { - val counter = ChangeCounter() - ChangeManager.subscribe(counter) - return counter -} From 5c0663085b72b6dd0bbad17bc138f80b3cfef4d1 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 16 Aug 2026 13:01:21 +0100 Subject: [PATCH 26/50] test: use the TestChangeSubscriber fixture in ContentProviderTest Part of 20737 Assisted-by: Claude Fable 5 --- AnkiDroid/build.gradle | 2 + .../ichi2/anki/tests/ContentProviderTest.kt | 40 +++++++------------ 2 files changed, 16 insertions(+), 26 deletions(-) diff --git a/AnkiDroid/build.gradle b/AnkiDroid/build.gradle index 25c53a7438a2..8ac6af4f8f68 100644 --- a/AnkiDroid/build.gradle +++ b/AnkiDroid/build.gradle @@ -805,6 +805,8 @@ dependencies { // for testing flows testImplementation libs.cashapp.turbine + androidTestImplementation testFixtures(project(":anki-common")) + // May need a resolution strategy for support libs to our versions androidTestImplementation libs.androidx.espresso.core androidTestImplementation(libs.androidx.espresso.contrib) { 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 5b414f92a46b..ec4627c4b966 100644 --- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/ContentProviderTest.kt +++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/ContentProviderTest.kt @@ -12,7 +12,6 @@ import android.database.Cursor import android.database.CursorWindow import android.net.Uri import anki.cards.FsrsMemoryState -import anki.collection.OpChanges import anki.notetypes.StockNotetype import com.ichi2.anki.CollectionManager import com.ichi2.anki.Flag @@ -37,6 +36,7 @@ import com.ichi2.anki.libanki.backend.BackendUtils import com.ichi2.anki.libanki.exception.ConfirmModSchemaException import com.ichi2.anki.libanki.getStockNotetype import com.ichi2.anki.libanki.sched.Scheduler +import com.ichi2.anki.observability.ChangeCounter import com.ichi2.anki.observability.ChangeManager import com.ichi2.anki.provider.pureAnswer import com.ichi2.anki.testutil.DatabaseUtils.cursorFillWindow @@ -2475,7 +2475,7 @@ class ContentProviderTest : InstrumentedTest() { @Test fun testInsertNotifiesUI() { - val counter = TestSubscriber() + val counter = ChangeCounter() ChangeManager.subscribe(counter) try { val mid = noteTypeId @@ -2504,7 +2504,7 @@ class ContentProviderTest : InstrumentedTest() { ContentValues().apply { put(FlashCardsContract.Note.TAGS, "new_tag") } - val counter = TestSubscriber() + val counter = ChangeCounter() ChangeManager.subscribe(counter) try { contentResolver.update(uri, values, null, null) @@ -2516,7 +2516,7 @@ class ContentProviderTest : InstrumentedTest() { @Test fun testUpdateNonExistentNoteDoesNotNotifyUI() { - val counter = TestSubscriber() + val counter = ChangeCounter() ChangeManager.awaitPendingOpChanges() ChangeManager.subscribe(counter) try { @@ -2531,7 +2531,7 @@ class ContentProviderTest : InstrumentedTest() { } ChangeManager.awaitPendingOpChanges() - assertEquals("UI should not be notified if update is failed", 0, counter.count) + assertEquals("UI should not be notified if update is failed", 0, counter.changeCount) } finally { ChangeManager.unsubscribe(counter) } @@ -2541,7 +2541,7 @@ class ContentProviderTest : InstrumentedTest() { fun testDeleteNotifiesUI() { val noteId = createdNotes.first().lastPathSegment!!.toLong() val uri = Uri.withAppendedPath(FlashCardsContract.Note.CONTENT_URI, noteId.toString()) - val counter = TestSubscriber() + val counter = ChangeCounter() ChangeManager.subscribe(counter) try { contentResolver.delete(uri, null, null) @@ -2553,7 +2553,7 @@ class ContentProviderTest : InstrumentedTest() { @Test fun testDeleteNonExistentNoteDoesNotNotifyUI() { - val counter = TestSubscriber() + val counter = ChangeCounter() ChangeManager.awaitPendingOpChanges() ChangeManager.subscribe(counter) try { @@ -2562,7 +2562,7 @@ class ContentProviderTest : InstrumentedTest() { assertEquals("It should return 0 for non-existent note", 0, deletedCount) ChangeManager.awaitPendingOpChanges() - assertEquals("UI should not be notify if nothing was deleted", 0, counter.count) + assertEquals("UI should not be notify if nothing was deleted", 0, counter.changeCount) } finally { ChangeManager.unsubscribe(counter) } @@ -2570,7 +2570,7 @@ class ContentProviderTest : InstrumentedTest() { @Test fun testBulkInsertNotifiesUI() { - val counter = TestSubscriber() + val counter = ChangeCounter() ChangeManager.subscribe(counter) try { val mid = noteTypeId @@ -2595,14 +2595,14 @@ class ContentProviderTest : InstrumentedTest() { @Test fun testBulkInsertEmptyListDoesNotNotifyUI() { - val counter = TestSubscriber() + val counter = ChangeCounter() ChangeManager.awaitPendingOpChanges() ChangeManager.subscribe(counter) try { contentResolver.bulkInsert(FlashCardsContract.Note.CONTENT_URI, emptyArray()) ChangeManager.awaitPendingOpChanges() - assertEquals("UI should not be notified for empty bulk insert", 0, counter.count) + assertEquals("UI should not be notified for empty bulk insert", 0, counter.changeCount) } finally { ChangeManager.unsubscribe(counter) } @@ -2692,26 +2692,14 @@ class ContentProviderTest : InstrumentedTest() { } } - // TODO: PERF: use TestChangeSubscriber once we've moved to testFixtures - private class TestSubscriber : ChangeManager.Subscriber { - var count = 0 - - override fun opExecuted( - changes: OpChanges, - handler: Any?, - ) { - count++ - } - } - - private fun assertNotificationReceived(subscriber: TestSubscriber) { + private fun assertNotificationReceived(subscriber: ChangeCounter) { val timeout = 5000L val startTime = TimeManager.time.intTimeMS() - while (subscriber.count == 0 && TimeManager.time.intTimeMS() - startTime < timeout) { + while (!subscriber.hasChanges && TimeManager.time.intTimeMS() - startTime < timeout) { Thread.sleep(100) } - assertTrue("UI should be notified of the change", subscriber.count > 0) + assertTrue("UI should be notified of the change", subscriber.hasChanges) } private val contentResolver: ContentResolver From cd35f8fa4f8142aadaaac2b6286904704fbb37cd Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 16 Aug 2026 02:43:06 +0100 Subject: [PATCH 27/50] refactor: move launchCatching to :anki-common For the widget feature module extraction Part of 20737 Assisted-by: Claude Opus 4.8 (cherry picked from commit 28885ffe7d677a28397b5ffddc32561d29ce4cca) --- .../java/com/ichi2/anki/CoroutineHelpers.kt | 35 -------------- .../kotlin/com/ichi2/anki/LaunchCatching.kt | 46 +++++++++++++++++++ .../com/ichi2/anki/LaunchCatchingTest.kt | 2 +- 3 files changed, 47 insertions(+), 36 deletions(-) create mode 100644 anki-common/src/main/kotlin/com/ichi2/anki/LaunchCatching.kt rename AnkiDroid/src/test/java/com/ichi2/anki/CoroutineHelpersTest.kt => anki-common/src/test/kotlin/com/ichi2/anki/LaunchCatchingTest.kt (97%) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt index 2d727f075457..83a33d1016c1 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt @@ -35,7 +35,6 @@ import com.ichi2.anki.dialogs.DatabaseErrorDialog import com.ichi2.anki.dialogs.DatabaseErrorDialog.DatabaseErrorDialogType import com.ichi2.anki.exception.StorageAccessException import com.ichi2.anki.exception.StorageNotConfiguredException -import com.ichi2.anki.libanki.exception.InvalidSearchException import com.ichi2.anki.pages.toIntent import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.startup.redirectToMainEntryPoint @@ -72,8 +71,6 @@ import net.ankiweb.rsdroid.exceptions.BackendNetworkException import net.ankiweb.rsdroid.exceptions.BackendSyncException import org.jetbrains.annotations.VisibleForTesting import timber.log.Timber -import kotlin.coroutines.CoroutineContext -import kotlin.coroutines.EmptyCoroutineContext import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds @@ -86,38 +83,6 @@ var ioDispatcher: CoroutineDispatcher = Dispatchers.IO @VisibleForTesting var throwOnShowError = false -/** - * Runs a suspend function that catches any uncaught errors and reports them to the user. - * Errors from the backend contain localized text that is often suitable to show to the user as-is. - * Other errors should ideally be handled in the block. - * - * @param context Coroutine context passed to [launch] - * @param errorMessageHandler Called after an exception is caught and logged, input is either - * `Exception.localizedMessage` or `Exception.toString()` - * @param block code to execute inside [launch] - */ -fun CoroutineScope.launchCatching( - context: CoroutineContext = EmptyCoroutineContext, - errorMessageHandler: suspend (String) -> Unit, - block: suspend CoroutineScope.() -> Unit, -): Job = - launch(context) { - try { - block() - } catch (cancellationException: CancellationException) { - // CancellationException should be re-thrown to propagate it to the parent coroutine - throw cancellationException - } catch (exception: Exception) { - Timber.w(exception) - val message = - when (exception) { - is BackendException, is InvalidSearchException -> exception.localizedMessage - else -> null - } ?: exception.toString() - errorMessageHandler.invoke(message) - } - } - interface OnErrorListener { val onError: MutableSharedFlow } diff --git a/anki-common/src/main/kotlin/com/ichi2/anki/LaunchCatching.kt b/anki-common/src/main/kotlin/com/ichi2/anki/LaunchCatching.kt new file mode 100644 index 000000000000..1d67b86a0539 --- /dev/null +++ b/anki-common/src/main/kotlin/com/ichi2/anki/LaunchCatching.kt @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: Copyright (c) 2023 Brayan Oliveira <69634269+brayandso@users.noreply.github.com> + +package com.ichi2.anki + +import com.ichi2.anki.libanki.exception.InvalidSearchException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import net.ankiweb.rsdroid.BackendException +import timber.log.Timber +import kotlin.coroutines.CoroutineContext +import kotlin.coroutines.EmptyCoroutineContext + +/** + * Runs a suspend function that catches any uncaught errors and reports them to the user. + * Errors from the backend contain localized text that is often suitable to show to the user as-is. + * Other errors should ideally be handled in the block. + * + * @param context Coroutine context passed to [launch] + * @param errorMessageHandler Called after an exception is caught and logged, input is either + * `Exception.localizedMessage` or `Exception.toString()` + * @param block code to execute inside [launch] + */ +fun CoroutineScope.launchCatching( + context: CoroutineContext = EmptyCoroutineContext, + errorMessageHandler: suspend (String) -> Unit, + block: suspend CoroutineScope.() -> Unit, +): Job = + launch(context) { + try { + block() + } catch (cancellationException: CancellationException) { + // CancellationException should be re-thrown to propagate it to the parent coroutine + throw cancellationException + } catch (exception: Exception) { + Timber.w(exception) + val message = + when (exception) { + is BackendException, is InvalidSearchException -> exception.localizedMessage + else -> null + } ?: exception.toString() + errorMessageHandler.invoke(message) + } + } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CoroutineHelpersTest.kt b/anki-common/src/test/kotlin/com/ichi2/anki/LaunchCatchingTest.kt similarity index 97% rename from AnkiDroid/src/test/java/com/ichi2/anki/CoroutineHelpersTest.kt rename to anki-common/src/test/kotlin/com/ichi2/anki/LaunchCatchingTest.kt index a80654105e2d..4a4c0a0dc0d5 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CoroutineHelpersTest.kt +++ b/anki-common/src/test/kotlin/com/ichi2/anki/LaunchCatchingTest.kt @@ -9,7 +9,7 @@ import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.equalTo import org.junit.Test -class CoroutineHelpersTest { +class LaunchCatchingTest { @Test fun `launchCatching does not include class names for InvalidSearchException`() = runTest { From 4858cb61c0cfa013e4dc08ee8d90664855c3cece Mon Sep 17 00:00:00 2001 From: AdityaDarke97 Date: Wed, 23 Sep 2026 23:54:18 +0530 Subject: [PATCH 28/50] ported nextId from Python to Kotlin and added tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update capitalizePy() to solve theoretical issue ß --- .../java/com/ichi2/anki/libanki/Collection.kt | 12 +++++++++--- .../ichi2/anki/libanki/utils/PythonExtensions.kt | 2 ++ .../com/ichi2/anki/libanki/CollectionTest.kt | 16 ++++++++++++++++ 3 files changed, 27 insertions(+), 3 deletions(-) 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 bc47011ecc66..259603833a05 100644 --- a/libanki/src/main/java/com/ichi2/anki/libanki/Collection.kt +++ b/libanki/src/main/java/com/ichi2/anki/libanki/Collection.kt @@ -76,6 +76,7 @@ import com.ichi2.anki.libanki.sched.DummyScheduler import com.ichi2.anki.libanki.sched.Scheduler 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.BackendFatalError import net.ankiweb.rsdroid.BackendException.BackendSearchException @@ -653,12 +654,17 @@ class Collection( @CheckResult @LibAnkiAlias("nextID") - @RustCleanup("Python returns 'Any' - may fail for Double?") - @Deprecated("not implemented", level = DeprecationLevel.HIDDEN) fun nextId( type: String, inc: Boolean = true, - ): Long = TODO() + ): Long { + val cType = "next" + type.capitalizePy() + val id = config.get(cType) ?: 1L + if (inc) { + config.set(cType, id + 1L) + } + return id + } /* * Notes diff --git a/libanki/src/main/java/com/ichi2/anki/libanki/utils/PythonExtensions.kt b/libanki/src/main/java/com/ichi2/anki/libanki/utils/PythonExtensions.kt index 99cfc60ba4a9..df7b0a93f9a0 100644 --- a/libanki/src/main/java/com/ichi2/anki/libanki/utils/PythonExtensions.kt +++ b/libanki/src/main/java/com/ichi2/anki/libanki/utils/PythonExtensions.kt @@ -136,3 +136,5 @@ fun len(templates: JSONContainer) = templates.jsonArra // Changed signature from `copy.deepcopy(clone_from)` fun DeckConfig.deepClone() = this.copy(jsonObject = this.jsonObject.deepClone()) + +internal fun String.capitalizePy(): String = if (isEmpty()) this else first().titlecase() + drop(1).lowercase() diff --git a/libanki/src/test/java/com/ichi2/anki/libanki/CollectionTest.kt b/libanki/src/test/java/com/ichi2/anki/libanki/CollectionTest.kt index d238a0fc2772..57a57d2a2838 100644 --- a/libanki/src/test/java/com/ichi2/anki/libanki/CollectionTest.kt +++ b/libanki/src/test/java/com/ichi2/anki/libanki/CollectionTest.kt @@ -361,4 +361,20 @@ class CollectionTest : InMemoryAnkiTest() { fun `get all columns`() { assertThat(col.allBrowserColumns(), not(hasSize(0))) } + + @Test + fun `nextId new type`() { + assertEquals(1L, col.nextId(type = "someType")) + } + + @Test + fun `nextId when inc is false`() { + assertEquals(1L, col.nextId(type = "someType", inc = false)) + } + + @Test + fun `nextId increments by default`() { + assertEquals(1L, col.nextId(type = "someType")) + assertEquals(2L, col.nextId(type = "someType")) + } } From 6c5a36124bafe11262342f44faee7bc6c96166a4 Mon Sep 17 00:00:00 2001 From: lukstbit <52494258+lukstbit@users.noreply.github.com> Date: Fri, 25 Sep 2026 15:38:04 +0300 Subject: [PATCH 29/50] Set scroll lift color to colorSurface for Statistics toolbar This overrides the default behavior of AppBarLayout which uses colorSurfaceContainer when scrolling the content. With colorSurface the colors remains constant when scrolling as well as in relation to the status bar color. See https://github.com/material-components/material-components-android/blob/master/docs/components/TopAppBar.md#scrolling-behavior-attributes --- AnkiDroid/src/main/res/layout/page_statistics.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/AnkiDroid/src/main/res/layout/page_statistics.xml b/AnkiDroid/src/main/res/layout/page_statistics.xml index 032c61748f89..cb6fda90a776 100644 --- a/AnkiDroid/src/main/res/layout/page_statistics.xml +++ b/AnkiDroid/src/main/res/layout/page_statistics.xml @@ -10,6 +10,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:background="?attr/colorSurface" + app:liftOnScrollColor="?attr/colorSurface" app:statusBarForeground="?attr/appBarColor"> Date: Fri, 25 Sep 2026 13:05:41 +0530 Subject: [PATCH 30/50] fix(note-editor): hide the camera when no app can take pictures Camera option in IO and multimedia are now hidden when nothing can handle IMAGE_CAPTURE. The query is declared in our own manifest, since until now it only reached the merged manifest through the image cropper library. --- AnkiDroid/src/main/AndroidManifest.xml | 3 ++ .../java/com/ichi2/anki/NoteEditorFragment.kt | 2 + .../anki/multimedia/MultimediaBottomSheet.kt | 3 ++ .../ichi2/anki/multimedia/MultimediaUtils.kt | 4 ++ .../java/com/ichi2/anki/NoteEditorTest.kt | 41 +++++++++++++++++++ 5 files changed, 53 insertions(+) diff --git a/AnkiDroid/src/main/AndroidManifest.xml b/AnkiDroid/src/main/AndroidManifest.xml index ba73f828ee54..97ed6a7ac06e 100644 --- a/AnkiDroid/src/main/AndroidManifest.xml +++ b/AnkiDroid/src/main/AndroidManifest.xml @@ -78,6 +78,9 @@ + + + + Add reminder Add review reminder Edit review reminder You have no review reminders + + + %d card due + %d cards due + + Select tags 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 4f5535d792dc..9f5b180cbc8d 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/services/NotificationServiceTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/services/NotificationServiceTest.kt @@ -130,6 +130,38 @@ class NotificationServiceTest : RobolectricTest() { reviewReminder.verifyLatestNotifTime(previousTime = creationTime, shouldHaveUpdated = true) } + @Test + fun `notification description is singular when one card is due`() = + runTest { + val did = addDeck("Deck", setAsSelected = true).withNotes(count = 1) + val reviewReminder = createTestReminder(deckId = did) + ReviewRemindersDatabase.insertReminder(reviewReminder) + + val notif = slot() + + TimeManager.resetWith(today) + attemptNotif(reviewReminder) + + verifyNotifSent(reviewReminder, slot = notif) + assertThat(notif.captured.extras.getString(Notification.EXTRA_TEXT), equalTo("1 card due")) + } + + @Test + fun `notification description is plural when several cards are due`() = + runTest { + val did = addDeck("Deck", setAsSelected = true).withNotes(count = 2) + val reviewReminder = createTestReminder(deckId = did) + ReviewRemindersDatabase.insertReminder(reviewReminder) + + val notif = slot() + + TimeManager.resetWith(today) + attemptNotif(reviewReminder) + + verifyNotifSent(reviewReminder, slot = notif) + assertThat(notif.captured.extras.getString(Notification.EXTRA_TEXT), equalTo("2 cards due")) + } + @Test fun `triggering with non-existent deck should not fire notification but schedule next`() = runTest { From 879a0006463cf530d99a171b3dff78383082c295 Mon Sep 17 00:00:00 2001 From: Song Eric Yu Li Date: Sat, 19 Sep 2026 22:43:04 -0700 Subject: [PATCH 41/50] refactor(reminders): move reminder strings out of 12-dont-translate These are now translated, so the _do_not_translate suffix is dropped. The notification channel ID is unchanged; only its display name is, which is now sentence case. Assisted-by: Claude Opus 5 --- .../src/main/java/com/ichi2/anki/NotificationChannel.kt | 2 +- .../java/com/ichi2/anki/dialogs/DeckPickerContextMenu.kt | 2 +- AnkiDroid/src/main/res/menu/study_options_fragment.xml | 2 +- AnkiDroid/src/main/res/values/10-preferences.xml | 8 +++++++- AnkiDroid/src/main/res/values/12-dont-translate.xml | 4 ---- 5 files changed, 10 insertions(+), 8 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NotificationChannel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NotificationChannel.kt index a94f313800cf..30a05d7c52f1 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/NotificationChannel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/NotificationChannel.kt @@ -69,7 +69,7 @@ enum class NotificationChannel( ) { GENERAL("General Notifications", R.string.app_name, NotificationManagerCompat.IMPORTANCE_DEFAULT), SYNC("Synchronization", R.string.sync_title, NotificationManagerCompat.IMPORTANCE_LOW), - REVIEW_REMINDERS("Review Reminders", R.string.review_reminders_do_not_translate, NotificationManagerCompat.IMPORTANCE_DEFAULT), + REVIEW_REMINDERS("Review Reminders", R.string.review_reminders_title, NotificationManagerCompat.IMPORTANCE_DEFAULT), ; fun getName(res: Resources) = res.getString(nameId) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckPickerContextMenu.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckPickerContextMenu.kt index e5f44cd6308a..fe50daa91872 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckPickerContextMenu.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckPickerContextMenu.kt @@ -96,7 +96,7 @@ class DeckPickerContextMenu : AnalyticsDialogFragment() { BROWSE_CARDS -> getString(R.string.browse_cards) EDIT_DESCRIPTION -> getString(R.string.edit_deck_description) ADD_CARD -> TR.actionsAdd() - SCHEDULE_REMINDERS -> getString(R.string.schedule_reminders_do_not_translate) + SCHEDULE_REMINDERS -> getString(R.string.schedule_reminders_title) } } } diff --git a/AnkiDroid/src/main/res/menu/study_options_fragment.xml b/AnkiDroid/src/main/res/menu/study_options_fragment.xml index 66154daea5e4..907c836edb99 100644 --- a/AnkiDroid/src/main/res/menu/study_options_fragment.xml +++ b/AnkiDroid/src/main/res/menu/study_options_fragment.xml @@ -6,7 +6,7 @@ xmlns:tools="http://schemas.android.com/tools"> diff --git a/AnkiDroid/src/main/res/values/10-preferences.xml b/AnkiDroid/src/main/res/values/10-preferences.xml index 84f46f2630b4..20b386fb54d4 100644 --- a/AnkiDroid/src/main/res/values/10-preferences.xml +++ b/AnkiDroid/src/main/res/values/10-preferences.xml @@ -219,9 +219,15 @@ - Add reminder Add review reminder Edit review reminder + + + Review reminders + + + Schedule reminders + Add reminder You have no review reminders diff --git a/AnkiDroid/src/main/res/values/12-dont-translate.xml b/AnkiDroid/src/main/res/values/12-dont-translate.xml index 44edfbfc7dbc..d9752ab15525 100644 --- a/AnkiDroid/src/main/res/values/12-dont-translate.xml +++ b/AnkiDroid/src/main/res/values/12-dont-translate.xml @@ -28,10 +28,6 @@ If you want to use a string in your code that can't be translated, please use: This is a string with a non breakable-space using unicode. This is a string with a non breakable-space using entity. - - - Schedule reminders - Review Reminders More Browse From a486c011276a05ba4a66a25acc802a767b65d08b Mon Sep 17 00:00:00 2001 From: Song Eric Yu Li Date: Sat, 19 Sep 2026 22:46:48 -0700 Subject: [PATCH 42/50] refactor(reminders): extract settings entry point strings Assisted-by: Claude Opus 5 --- .../main/java/com/ichi2/anki/preferences/HeaderFragment.kt | 4 ++-- AnkiDroid/src/main/res/values/10-preferences.xml | 2 ++ AnkiDroid/src/main/res/values/constants.xml | 4 ++-- AnkiDroid/src/main/res/xml/preference_headers.xml | 2 +- 4 files changed, 7 insertions(+), 5 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/HeaderFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/HeaderFragment.kt index db7e73828b80..6afca21c6f42 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/HeaderFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/HeaderFragment.kt @@ -103,9 +103,9 @@ class HeaderFragment : SettingsFragment() { if (Prefs.newReviewRemindersEnabled) { indexItem() .withKey(activity.getString(R.string.pref_review_reminders_screen_key)) - .withTitle("Review reminders") + .withTitle(activity.getString(R.string.review_reminders_title)) .withResId(R.xml.preferences_review_reminders) - .withSummary("Notifications") + .withSummary(activity.getString(R.string.notification_pref)) } else { index(R.xml.preferences_notifications) } diff --git a/AnkiDroid/src/main/res/values/10-preferences.xml b/AnkiDroid/src/main/res/values/10-preferences.xml index 20b386fb54d4..ea843fa374d8 100644 --- a/AnkiDroid/src/main/res/values/10-preferences.xml +++ b/AnkiDroid/src/main/res/values/10-preferences.xml @@ -224,6 +224,8 @@ Review reminders + App-wide + Deck-specific Schedule reminders diff --git a/AnkiDroid/src/main/res/values/constants.xml b/AnkiDroid/src/main/res/values/constants.xml index 8df7dc8f6ecc..9408f7e78338 100644 --- a/AnkiDroid/src/main/res/values/constants.xml +++ b/AnkiDroid/src/main/res/values/constants.xml @@ -300,8 +300,8 @@ - App-wide - Deck-specific + @string/review_reminders_summary_app_wide + @string/review_reminders_summary_deck_specific diff --git a/AnkiDroid/src/main/res/xml/preference_headers.xml b/AnkiDroid/src/main/res/xml/preference_headers.xml index bffa1c5bfcce..c87fa8769e49 100644 --- a/AnkiDroid/src/main/res/xml/preference_headers.xml +++ b/AnkiDroid/src/main/res/xml/preference_headers.xml @@ -76,7 +76,7 @@ From 3d3630ae96aa9e3404685a0c139706b2d272699d Mon Sep 17 00:00:00 2001 From: Song Eric Yu Li Date: Sat, 19 Sep 2026 22:51:38 -0700 Subject: [PATCH 43/50] refactor(reminders): extract schedule screen strings Assisted-by: Claude Opus 5 --- .../reviewreminders/ScheduleRemindersAdapter.kt | 3 ++- .../reviewreminders/ScheduleRemindersFragment.kt | 14 +++++++------- .../res/layout/fragment_schedule_reminders.xml | 5 ++--- AnkiDroid/src/main/res/menu/schedule_reminders.xml | 13 +++++-------- AnkiDroid/src/main/res/values/10-preferences.xml | 9 +++++++++ 5 files changed, 25 insertions(+), 19 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ScheduleRemindersAdapter.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ScheduleRemindersAdapter.kt index 60551f5b1233..faa4ab4867a1 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ScheduleRemindersAdapter.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ScheduleRemindersAdapter.kt @@ -15,6 +15,7 @@ import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import com.google.android.material.materialswitch.MaterialSwitch import com.ichi2.anki.CollectionManager.TR +import com.ichi2.anki.R import com.ichi2.anki.databinding.ItemScheduleRemindersBinding import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.ui.internationalization.sentenceCase @@ -95,7 +96,7 @@ class ScheduleRemindersAdapter( setTextViewStrikethrough(holder.timeTextView, false) setViewHolderColors(holder, activeTextColor, activeTrackColor) } else { - holder.deckTextView.text = "Deck not found" + holder.deckTextView.text = holder.context.getString(R.string.schedule_reminders_deck_not_found) setTextViewStrikethrough(holder.timeTextView, true) setViewHolderColors(holder, inactiveTextColor, inactiveTrackColor) } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ScheduleRemindersFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ScheduleRemindersFragment.kt index 88472b9673cb..2bd9a84fc0c3 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ScheduleRemindersFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ScheduleRemindersFragment.kt @@ -276,8 +276,8 @@ class ScheduleRemindersFragment : } if (troubleshootingSnackbar?.isShown == true) return troubleshootingSnackbar = - showSnackbar(text = "Reminders are unavailable", duration = Snackbar.LENGTH_INDEFINITE) { - setAction("Fix") { openTroubleshootingScreen() } + showSnackbar(text = getString(R.string.schedule_reminders_unavailable), duration = Snackbar.LENGTH_INDEFINITE) { + setAction(R.string.schedule_reminders_fix) { openTroubleshootingScreen() } } } @@ -290,7 +290,7 @@ class ScheduleRemindersFragment : Lifecycle.State.RESUMED, ) retrieveSubtitle { subtitle -> - setToolbarText(title = "Review reminders", subtitle = subtitle) + setToolbarText(title = getString(R.string.review_reminders_title), subtitle = subtitle) } invalidateMenu() } @@ -316,7 +316,7 @@ class ScheduleRemindersFragment : Lifecycle.State.RESUMED, ) - title = "Review reminders" + title = getString(R.string.review_reminders_title) retrieveSubtitle { subtitle = it } setNavigationOnClickListener { @@ -461,11 +461,11 @@ class ScheduleRemindersFragment : if (flowOfShowTroubleshooting.value) return // Don't override the troubleshooting snackbar, which is more important showSnackbar( when (modeOfFinishedDialog) { - is AddEditReminderDialog.DialogMode.Add -> "Successfully added new review reminder" + is AddEditReminderDialog.DialogMode.Add -> getString(R.string.schedule_reminders_added) is AddEditReminderDialog.DialogMode.Edit -> { when (newOrModifiedReminder) { - null -> "Successfully deleted review reminder" - else -> "Successfully edited review reminder" + null -> getString(R.string.schedule_reminders_deleted) + else -> getString(R.string.schedule_reminders_edited) } } }, diff --git a/AnkiDroid/src/main/res/layout/fragment_schedule_reminders.xml b/AnkiDroid/src/main/res/layout/fragment_schedule_reminders.xml index 769e95919abc..485f1b131793 100644 --- a/AnkiDroid/src/main/res/layout/fragment_schedule_reminders.xml +++ b/AnkiDroid/src/main/res/layout/fragment_schedule_reminders.xml @@ -57,10 +57,9 @@ android:drawSelectorOnTop="true" android:paddingBottom="84dp" android:scrollbars="vertical" - android:contentDescription="List of scheduled review reminders" + android:contentDescription="@string/schedule_reminders_list_content_description" app:layout_behavior="@string/appbar_scrolling_view_behavior" - tools:listitem="@layout/item_schedule_reminders" - tools:ignore="HardcodedText" /> + tools:listitem="@layout/item_schedule_reminders" /> - + android:title="@string/schedule_reminders_troubleshoot_menu" + app:showAsAction="always" /> + android:title="@string/schedule_reminders_notification_settings_menu" + app:showAsAction="always" /> diff --git a/AnkiDroid/src/main/res/values/10-preferences.xml b/AnkiDroid/src/main/res/values/10-preferences.xml index ea843fa374d8..0f6a9f1c6852 100644 --- a/AnkiDroid/src/main/res/values/10-preferences.xml +++ b/AnkiDroid/src/main/res/values/10-preferences.xml @@ -231,6 +231,15 @@ Schedule reminders Add reminder You have no review reminders + Troubleshoot + Notification settings + List of scheduled review reminders + Deck not found + Reminders are unavailable + Fix + Successfully added new review reminder + Successfully deleted review reminder + Successfully edited review reminder From 1cce864d7390db85a633578d2c80747469294309 Mon Sep 17 00:00:00 2001 From: Song Eric Yu Li Date: Sat, 19 Sep 2026 22:56:11 -0700 Subject: [PATCH 44/50] refactor(reminders): extract add/edit reminder dialog strings The toolbar title and the time button's text are both set at runtime, so their placeholders become tools: attributes rather than resources. Assisted-by: Claude Opus 5 --- .../reviewreminders/AddEditReminderDialog.kt | 8 +++--- .../res/layout/dialog_add_edit_reminder.xml | 27 +++++++------------ AnkiDroid/src/main/res/values/02-strings.xml | 2 +- .../src/main/res/values/10-preferences.xml | 16 +++++++++-- .../java/com/ichi2/anki/TranslationTest.kt | 3 +++ 5 files changed, 32 insertions(+), 24 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt index fb29db46f37c..4f6fd234cf1d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt @@ -236,8 +236,8 @@ class AddEditReminderDialog : DialogFragment() { val value: Int? = text.toString().toIntOrNull() binding.addEditReminderCardThresholdInputWrapper.error = when { - (value == null) -> "Please enter a whole number of cards" - (value < 0) -> "The threshold must be at least 0" + (value == null) -> getString(R.string.add_edit_reminder_card_threshold_error_not_a_number) + (value < 0) -> getString(R.string.add_edit_reminder_card_threshold_error_negative) else -> null } viewModel.setCardTriggerThreshold(value ?: 0) @@ -318,8 +318,8 @@ class AddEditReminderDialog : DialogFragment() { val confirmationDialog = ConfirmationDialog() confirmationDialog.setArgs( - "Delete this reminder?", - "This action cannot be undone.", + getString(R.string.add_edit_reminder_delete_confirmation_title), + getString(R.string.add_edit_reminder_delete_confirmation_message), ) confirmationDialog.setConfirm { setFragmentResult( diff --git a/AnkiDroid/src/main/res/layout/dialog_add_edit_reminder.xml b/AnkiDroid/src/main/res/layout/dialog_add_edit_reminder.xml index 10570e67e347..c2f9e0fddb11 100644 --- a/AnkiDroid/src/main/res/layout/dialog_add_edit_reminder.xml +++ b/AnkiDroid/src/main/res/layout/dialog_add_edit_reminder.xml @@ -20,7 +20,7 @@ android:minHeight="?attr/actionBarSize" android:theme="@style/ActionBarStyle" android:background="?attr/appBarColor" - app:title="Add / edit review reminder" /> + tools:title="Add / edit review reminder" /> + android:text="@string/add_edit_reminder_time_label" + android:textSize="18sp" /> + tools:text="12:00" /> @@ -77,9 +75,8 @@ android:layout_height="match_parent" android:layout_weight="1" android:gravity="center_vertical" - android:text="Deck:" - android:textSize="18sp" - tools:ignore="HardcodedText" /> + android:text="@string/add_edit_reminder_deck_label" + android:textSize="18sp" /> + android:text="@string/add_edit_reminder_advanced_label" />
@@ -158,16 +154,14 @@ android:layout_height="wrap_content" android:layout_gravity="center_vertical" android:textSize="16sp" - android:text="Card threshold:" - tools:ignore="HardcodedText" /> + android:text="@string/add_edit_reminder_card_threshold_label" /> + android:text="@string/add_edit_reminder_card_threshold_description" /> @@ -205,8 +199,7 @@ android:layout_width="0dp" android:layout_height="wrap_content" android:layout_weight="1" - android:text="Only notify me if no reviews have been done today" - tools:ignore="HardcodedText" /> + android:text="@string/add_edit_reminder_only_notify_if_no_reviews_label" /> diff --git a/AnkiDroid/src/main/res/values/02-strings.xml b/AnkiDroid/src/main/res/values/02-strings.xml index e67c70bc9d96..90f9c52bbcfc 100644 --- a/AnkiDroid/src/main/res/values/02-strings.xml +++ b/AnkiDroid/src/main/res/values/02-strings.xml @@ -17,7 +17,7 @@ Card deck: - Deck: + Deck: Type: Tags: %1$s Cards: %1$s diff --git a/AnkiDroid/src/main/res/values/10-preferences.xml b/AnkiDroid/src/main/res/values/10-preferences.xml index 0f6a9f1c6852..31f16f80f07d 100644 --- a/AnkiDroid/src/main/res/values/10-preferences.xml +++ b/AnkiDroid/src/main/res/values/10-preferences.xml @@ -219,8 +219,6 @@ - Add review reminder - Edit review reminder Review reminders @@ -241,6 +239,20 @@ Successfully deleted review reminder Successfully edited review reminder + + Add review reminder + Edit review reminder + Time: + Deck: + Advanced settings + Card threshold: + Do not send me notifications if there are less than this many cards due + Only notify me if no reviews have been done today + Please enter a whole number of cards + The threshold must be at least 0 + Delete this reminder? + This action cannot be undone. + %d card due diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/TranslationTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/TranslationTest.kt index acb605d8e13f..df391b547b68 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/TranslationTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/TranslationTest.kt @@ -368,6 +368,9 @@ class TranslationTest : RobolectricTest() { private val IGNORED_BACKEND_TRANSLATIONS = setOf( "launcherOff", // "Off" - unrelated to R.string.full_screen_off + // "Advanced Settings" - this upstream string is Title Case; R.string.add_edit_reminder_advanced_label + // is both unrelated and sentence case (per AnkiDroid convention) + "deckConfigAdvancedSettings", ) } } From 916e58384226c9fa34d8a76a2241925f46b31550 Mon Sep 17 00:00:00 2001 From: Song Eric Yu Li Date: Sat, 19 Sep 2026 23:00:25 -0700 Subject: [PATCH 45/50] refactor(reminders): extract troubleshooting screen strings Assisted-by: Claude Opus 5 --- .../ReminderTroubleshootingFragment.kt | 11 ++++++----- .../res/layout/fragment_reminder_troubleshooting.xml | 7 +++---- AnkiDroid/src/main/res/values/10-preferences.xml | 6 ++++++ 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReminderTroubleshootingFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReminderTroubleshootingFragment.kt index efdcea9469c1..2ff66806b2ff 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReminderTroubleshootingFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReminderTroubleshootingFragment.kt @@ -31,6 +31,7 @@ import androidx.lifecycle.viewmodel.viewModelFactory import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView +import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.NotificationChannel import com.ichi2.anki.R import com.ichi2.anki.common.utils.android.getColorFromAttr @@ -141,13 +142,13 @@ class ReminderTroubleshootingFragment : Fragment(R.layout.fragment_reminder_trou private fun setupExternalActivityToolbar() { binding.troubleshootingToolbar.isVisible = false requireAnkiActivity().apply { - // TODO: Move to string resources - setToolbarText(title = "Troubleshooting") + setToolbarText(title = TR.errorsTroubleshootingButton()) invalidateMenu() } } private fun setupInternalFragmentToolbar() { + binding.troubleshootingToolbar.title = TR.errorsTroubleshootingButton() binding.troubleshootingToolbar.setNavigationOnClickListener { parentFragmentManager.popBackStack() } @@ -162,21 +163,21 @@ class ReminderTroubleshootingFragment : Fragment(R.layout.fragment_reminder_trou Triple( R.drawable.ic_cancel_24, context.getColor(android.R.color.holo_red_dark), - "Reminders are unavailable.", + context.getString(R.string.reminder_troubleshooting_summary_error), ) SummaryStatus.Warning -> Triple( R.drawable.ic_warning_24, getColorFromAttr(context, R.attr.reminderTroubleshootingWarning), - "Reminders may not work correctly.", + context.getString(R.string.reminder_troubleshooting_summary_warning), ) SummaryStatus.Ok -> Triple( R.drawable.ic_check_circle_24, getColorFromAttr(context, R.attr.reminderTroubleshootingOk), - "Your reminders should work as expected.", + context.getString(R.string.reminder_troubleshooting_summary_ok), ) } binding.summaryIcon.setImageResource(iconRes) diff --git a/AnkiDroid/src/main/res/layout/fragment_reminder_troubleshooting.xml b/AnkiDroid/src/main/res/layout/fragment_reminder_troubleshooting.xml index 6fb839a7a1ca..0ffdca6b7943 100644 --- a/AnkiDroid/src/main/res/layout/fragment_reminder_troubleshooting.xml +++ b/AnkiDroid/src/main/res/layout/fragment_reminder_troubleshooting.xml @@ -14,7 +14,7 @@ android:layout_height="wrap_content" app:navigationIcon="?attr/homeAsUpIndicator" app:navigationContentDescription="@string/abc_action_bar_up_description" - app:title="Troubleshooting" /> + tools:title="Troubleshooting" /> + android:textColor="?android:attr/textColorSecondary" /> Delete this reminder?
This action cannot be undone. + + Reminders are unavailable. + Reminders may not work correctly. + Your reminders should work as expected. + Different devices have different notification restriction settings. Check your system settings or search online if issues persist. + %d card due From a375e6284354120f2db7d8f725dd151fbb873c95 Mon Sep 17 00:00:00 2001 From: Song Eric Yu Li Date: Sat, 19 Sep 2026 23:08:45 -0700 Subject: [PATCH 46/50] refactor(reminders): resolve troubleshooting check text via resources Enabled/Disabled reuse the generic strings. A translator comment alongside full_screen_off is added to keep DuplicateCrowdInStrings quiet. Assisted-by: Claude Opus 5 --- .../ReminderTroubleshootingFragment.kt | 122 +++++++++++------- .../src/main/res/values/10-preferences.xml | 27 +++- .../java/com/ichi2/anki/TranslationTest.kt | 1 + ...eminderTroubleshootingResolveActionTest.kt | 9 +- 4 files changed, 104 insertions(+), 55 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReminderTroubleshootingFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReminderTroubleshootingFragment.kt index 2ff66806b2ff..4d163f79ee71 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReminderTroubleshootingFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReminderTroubleshootingFragment.kt @@ -284,10 +284,11 @@ private class TroubleshootingChecksAdapter( val tintColor = check.result.tintColor(context) val lastIndex = itemCount - 1 - holder.binding.title.text = check.title() - holder.binding.statusName.text = check.statusName() + val titleText = with(context) { check.title() } + holder.binding.title.text = titleText + holder.binding.statusName.text = with(context) { check.statusName() } - val explanationText = check.explanation() + val explanationText = with(context) { check.explanation() } holder.binding.explanation.text = explanationText holder.binding.explanation.isVisible = explanationText != null @@ -315,7 +316,7 @@ private class TroubleshootingChecksAdapter( holder.binding.actionLink.text = resolveAction.label val clickListener = View.OnClickListener { - Timber.i("Launching fix for '%s': %s", check.title(), resolveAction.logDescription) + Timber.i("Launching fix for '%s': %s", titleText, resolveAction.logDescription) resolveAction.action() } holder.binding.itemContainer.setOnClickListener(clickListener) @@ -346,54 +347,75 @@ private class TroubleshootingChecksAdapter( } } -// TODO: move to string resources +context(context: Context) private fun TroubleshootingCheck.title(): String = - when (this) { - is TroubleshootingCheck.NotificationPermission -> "Notification permission" - is TroubleshootingCheck.NotificationChannelEnabled -> "Notification channel" - is TroubleshootingCheck.DoNotDisturbOff -> "Do not disturb" - is TroubleshootingCheck.UnrestrictedOptimizationEnabled -> "Battery optimization" - is TroubleshootingCheck.PowerSavingModeOff -> "Power saving mode" - is TroubleshootingCheck.ExactAlarmPermission -> "Alarms & reminders permission" - } - -// TODO: move to string resources -private fun TroubleshootingCheck.statusName(): String? = - when (this) { - is TroubleshootingCheck.NotificationPermission -> if (result == CheckResult.Passed) "Granted" else "Denied" - is TroubleshootingCheck.NotificationChannelEnabled -> if (result == CheckResult.Passed) "Enabled" else "Disabled" - is TroubleshootingCheck.DoNotDisturbOff -> if (result == CheckResult.Passed) "Off" else "On" - is TroubleshootingCheck.UnrestrictedOptimizationEnabled -> - when (result) { - is CheckResult.Passed -> "Unrestricted" - is CheckResult.Warning -> "Optimized" - is CheckResult.Failed -> "Restricted" - else -> null - } - is TroubleshootingCheck.PowerSavingModeOff -> if (result == CheckResult.Passed) "Off" else "On" - is TroubleshootingCheck.ExactAlarmPermission -> if (result == CheckResult.Passed) "Granted" else "Denied" - } + context.getString( + when (this) { + is TroubleshootingCheck.NotificationPermission -> R.string.reminder_troubleshooting_check_notification_permission + is TroubleshootingCheck.NotificationChannelEnabled -> R.string.reminder_troubleshooting_check_notification_channel + is TroubleshootingCheck.DoNotDisturbOff -> R.string.reminder_troubleshooting_check_do_not_disturb + is TroubleshootingCheck.UnrestrictedOptimizationEnabled -> R.string.reminder_troubleshooting_check_battery_optimization + is TroubleshootingCheck.PowerSavingModeOff -> R.string.reminder_troubleshooting_check_power_saving_mode + is TroubleshootingCheck.ExactAlarmPermission -> R.string.reminder_troubleshooting_check_exact_alarm_permission + }, + ) + +context(context: Context) +private fun TroubleshootingCheck.statusName(): String? { + val passed = result == CheckResult.Passed + val statusRes = + when (this) { + is TroubleshootingCheck.NotificationPermission, + is TroubleshootingCheck.ExactAlarmPermission, + -> + if (passed) { + R.string.reminder_troubleshooting_status_granted + } else { + R.string.reminder_troubleshooting_status_denied + } + is TroubleshootingCheck.NotificationChannelEnabled -> if (passed) R.string.enabled else R.string.disabled + is TroubleshootingCheck.DoNotDisturbOff, + is TroubleshootingCheck.PowerSavingModeOff, + -> + if (passed) { + R.string.reminder_troubleshooting_status_off + } else { + R.string.reminder_troubleshooting_status_on + } + is TroubleshootingCheck.UnrestrictedOptimizationEnabled -> + when (result) { + is CheckResult.Passed -> R.string.reminder_troubleshooting_status_unrestricted + is CheckResult.Warning -> R.string.reminder_troubleshooting_status_optimized + is CheckResult.Failed -> R.string.reminder_troubleshooting_status_restricted + else -> null + } + } + return statusRes?.let { context.getString(it) } +} -// TODO: move to string resources -private fun TroubleshootingCheck.explanation(): String? = - when (this) { - // no need for an explanation: the 'grant permission' action should be sufficient - is TroubleshootingCheck.NotificationPermission -> null - is TroubleshootingCheck.NotificationChannelEnabled -> - if (result.hasIssue) "The review reminder notification channel must be enabled" else null - is TroubleshootingCheck.DoNotDisturbOff -> - if (result.hasIssue) "Do Not Disturb may mute reminder notifications" else null - is TroubleshootingCheck.UnrestrictedOptimizationEnabled -> - when (result) { - is CheckResult.Warning -> "Battery optimization may delay reminders" - is CheckResult.Failed -> "Background usage is disabled. Reminders may not be delivered" - else -> null - } - is TroubleshootingCheck.PowerSavingModeOff -> - if (result.hasIssue) "Power saving mode may prevent timely delivery of reminders" else null - is TroubleshootingCheck.ExactAlarmPermission -> - if (result.hasIssue) "Required to schedule reminders at exact times" else null - } +context(context: Context) +private fun TroubleshootingCheck.explanation(): String? { + val explanationRes = + when (this) { + // no need for an explanation: the 'grant permission' action should be sufficient + is TroubleshootingCheck.NotificationPermission -> null + is TroubleshootingCheck.NotificationChannelEnabled -> + if (result.hasIssue) R.string.reminder_troubleshooting_explanation_notification_channel else null + is TroubleshootingCheck.DoNotDisturbOff -> + if (result.hasIssue) R.string.reminder_troubleshooting_explanation_do_not_disturb else null + is TroubleshootingCheck.UnrestrictedOptimizationEnabled -> + when (result) { + is CheckResult.Warning -> R.string.reminder_troubleshooting_explanation_battery_optimized + is CheckResult.Failed -> R.string.reminder_troubleshooting_explanation_battery_restricted + else -> null + } + is TroubleshootingCheck.PowerSavingModeOff -> + if (result.hasIssue) R.string.reminder_troubleshooting_explanation_power_saving_mode else null + is TroubleshootingCheck.ExactAlarmPermission -> + if (result.hasIssue) R.string.reminder_troubleshooting_explanation_exact_alarm else null + } + return explanationRes?.let { context.getString(it) } +} private fun CheckResult.iconRes(): Int = when (this) { diff --git a/AnkiDroid/src/main/res/values/10-preferences.xml b/AnkiDroid/src/main/res/values/10-preferences.xml index f8ed29a1724d..5082494b2ccc 100644 --- a/AnkiDroid/src/main/res/values/10-preferences.xml +++ b/AnkiDroid/src/main/res/values/10-preferences.xml @@ -10,7 +10,8 @@ - Disabled + Enabled + Disabled %d min %d mins @@ -62,7 +63,7 @@ Display audio filenames in the card browser question/answer fields AnkiDroid directory Fullscreen mode - Off + Off Hide the system bars Hide the system bars and answer buttons Answer buttons position @@ -258,6 +259,28 @@ Reminders may not work correctly. Your reminders should work as expected. Different devices have different notification restriction settings. Check your system settings or search online if issues persist. + + Notification permission + Notification channel + Do not disturb + Battery optimization + Power saving mode + Alarms & reminders permission + + Granted + Denied + On + Off + Unrestricted + Optimized + Restricted + + The review reminder notification channel must be enabled + Do Not Disturb may mute reminder notifications + Battery optimization may delay reminders + Background usage is disabled. Reminders may not be delivered + Power saving mode may prevent timely delivery of reminders + Required to schedule reminders at exact times diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/TranslationTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/TranslationTest.kt index df391b547b68..ce4787b0e91d 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/TranslationTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/TranslationTest.kt @@ -368,6 +368,7 @@ class TranslationTest : RobolectricTest() { private val IGNORED_BACKEND_TRANSLATIONS = setOf( "launcherOff", // "Off" - unrelated to R.string.full_screen_off + "launcherOn", // "On" - unrelated to R.string.reminder_troubleshooting_status_on // "Advanced Settings" - this upstream string is Title Case; R.string.add_edit_reminder_advanced_label // is both unrelated and sentence case (per AnkiDroid convention) "deckConfigAdvancedSettings", diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/reviewreminders/ReminderTroubleshootingResolveActionTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/reviewreminders/ReminderTroubleshootingResolveActionTest.kt index cbeef781e797..aebd4eb4f114 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/reviewreminders/ReminderTroubleshootingResolveActionTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/reviewreminders/ReminderTroubleshootingResolveActionTest.kt @@ -11,6 +11,7 @@ import androidx.core.net.toUri import androidx.core.view.children import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.R import com.ichi2.anki.RobolectricTest import com.ichi2.anki.databinding.ItemTroubleshootingCheckBinding import com.ichi2.testutils.withRequestIgnoreBatteryOptimizationsInManifest @@ -88,7 +89,9 @@ class ReminderTroubleshootingResolveActionTest : RobolectricTest() { /** The displayed 'Battery optimization' check */ private val ReminderTroubleshootingFragment.batteryOptimizationCheck: ItemTroubleshootingCheckBinding - get() = - binding.checksList.children + get() { + val title = requireContext().getString(R.string.reminder_troubleshooting_check_battery_optimization) + return binding.checksList.children .map { ItemTroubleshootingCheckBinding.bind(it) } - .single { it.title.text.toString() == "Battery optimization" } + .single { it.title.text.toString() == title } + } From b64c465c49222a80ef5bafe899fb51e94c40ef8c Mon Sep 17 00:00:00 2001 From: Song Eric Yu Li Date: Sat, 19 Sep 2026 23:13:52 -0700 Subject: [PATCH 47/50] refactor(reminders): resolve troubleshooting action labels via resources Assisted-by: Claude Opus 5 --- .../ReminderTroubleshootingFragment.kt | 22 +++++++++++++------ .../src/main/res/values/10-preferences.xml | 5 +++++ 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReminderTroubleshootingFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReminderTroubleshootingFragment.kt index 4d163f79ee71..94873628523e 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReminderTroubleshootingFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReminderTroubleshootingFragment.kt @@ -444,10 +444,9 @@ context(fragment: ReminderTroubleshootingFragment) private fun TroubleshootingCheck.resolveAction(): ResolveCheckAction? { val context = fragment.requireContext() - // TODO: move labels to string resources fun requestNotificationPermission(): ResolveCheckAction? = ResolveCheckAction( - label = "Grant permission", + label = context.getString(R.string.reminder_troubleshooting_action_grant_permission), logDescription = "requesting POST_NOTIFICATIONS via system dialog or app settings", ) { fragment.attemptToEnableNotifications(fragment.notificationPermissionLauncher) @@ -456,7 +455,7 @@ private fun TroubleshootingCheck.resolveAction(): ResolveCheckAction? { fun requestReminderNotifChannelPermission(): ResolveCheckAction? { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return null return ResolveCheckAction( - label = "Enable notification channel", + label = context.getString(R.string.reminder_troubleshooting_action_enable_notification_channel), logDescription = "opening app notification settings screen", ) { fragment.openAppNotificationsSettingsScreen(highlightedChannel = NotificationChannel.REVIEW_REMINDERS) @@ -468,7 +467,7 @@ private fun TroubleshootingCheck.resolveAction(): ResolveCheckAction? { return if (Permissions.canRequestIgnoreBatteryOptimizations(context)) { ResolveCheckAction( - label = "Disable battery optimization", + label = context.getString(R.string.reminder_troubleshooting_action_disable_battery_optimization), logDescription = "opening ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS", ) { try { @@ -484,20 +483,29 @@ private fun TroubleshootingCheck.resolveAction(): ResolveCheckAction? { } } } else { - ResolveCheckAction(label = "Open battery settings", logDescription = "opening ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS") { + ResolveCheckAction( + label = context.getString(R.string.reminder_troubleshooting_action_open_battery_settings), + logDescription = "opening ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS", + ) { openBatteryOptimizationList() } } } fun openBatterySaverSettings() = - ResolveCheckAction(label = "Open battery settings", logDescription = "opening ACTION_BATTERY_SAVER_SETTINGS") { + ResolveCheckAction( + label = context.getString(R.string.reminder_troubleshooting_action_open_battery_settings), + logDescription = "opening ACTION_BATTERY_SAVER_SETTINGS", + ) { context.startActivity(Intent(Settings.ACTION_BATTERY_SAVER_SETTINGS)) } fun openExactAlarmSettings(): ResolveCheckAction? { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return null - return ResolveCheckAction(label = "Grant permission", logDescription = "opening ACTION_REQUEST_SCHEDULE_EXACT_ALARM") { + return ResolveCheckAction( + label = context.getString(R.string.reminder_troubleshooting_action_grant_permission), + logDescription = "opening ACTION_REQUEST_SCHEDULE_EXACT_ALARM", + ) { context.startActivity( Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).apply { data = Uri.fromParts("package", context.packageName, null) diff --git a/AnkiDroid/src/main/res/values/10-preferences.xml b/AnkiDroid/src/main/res/values/10-preferences.xml index 5082494b2ccc..bb0884c2228e 100644 --- a/AnkiDroid/src/main/res/values/10-preferences.xml +++ b/AnkiDroid/src/main/res/values/10-preferences.xml @@ -281,6 +281,11 @@ Background usage is disabled. Reminders may not be delivered Power saving mode may prevent timely delivery of reminders Required to schedule reminders at exact times + + Grant permission + Enable notification channel + Disable battery optimization + Open battery settings From a81242124fbe61c1a544a01ec17bc45189a6446d Mon Sep 17 00:00:00 2001 From: Song Eric Yu Li Date: Sat, 19 Sep 2026 23:16:32 -0700 Subject: [PATCH 48/50] refactor(reminders): extract review reminder notification strings Assisted-by: Claude Opus 5 --- .../com/ichi2/anki/services/NotificationService.kt | 10 ++++++---- AnkiDroid/src/main/res/values/10-preferences.xml | 3 +++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/services/NotificationService.kt b/AnkiDroid/src/main/java/com/ichi2/anki/services/NotificationService.kt index ab1bb61d3ed9..411d46fb7e65 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/services/NotificationService.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/services/NotificationService.kt @@ -217,12 +217,12 @@ class NotificationService : AnkiBroadcastReceiver() { val title = when (reviewReminder.scope) { - is ReviewReminderScope.Global -> "It's time to study your cards" + is ReviewReminderScope.Global -> context.getString(R.string.review_reminder_notification_title_all_decks) is ReviewReminderScope.DeckSpecific -> { val fullDeckName = reviewReminder.scope.getDeckName() val deckName = Decks.basename(fullDeckName) // don't show the full path with "::" included - "It's time to study \"$deckName\"" // quotation marks to separate user-defined deck names from official app text + context.getString(R.string.review_reminder_notification_title_deck, deckName) } } val description = @@ -272,8 +272,10 @@ class NotificationService : AnkiBroadcastReceiver() { .setContentIntent(pendingIntent) .setAutoCancel(true) // Dismiss on click .setTicker(title) // Accessibility - .addAction("Snooze 5m", fiveMinuteSnooze) - .addAction("Snooze 1h", oneHourSnooze) + .addAction( + context.getString(R.string.review_reminder_notification_snooze_button, 5.minutes.toString()), + fiveMinuteSnooze, + ).addAction(context.getString(R.string.review_reminder_notification_snooze_button, 1.hours.toString()), oneHourSnooze) // Vibration and priority are set here for backwards compatibility; they are set via channel for API 33+ .setVibrate(longArrayOf(0, 500)) .setPriority(NotificationCompat.PRIORITY_DEFAULT) diff --git a/AnkiDroid/src/main/res/values/10-preferences.xml b/AnkiDroid/src/main/res/values/10-preferences.xml index bb0884c2228e..e6ed734bbdaa 100644 --- a/AnkiDroid/src/main/res/values/10-preferences.xml +++ b/AnkiDroid/src/main/res/values/10-preferences.xml @@ -288,6 +288,9 @@ Open battery settings + It\'s time to study your cards + It\'s time to study \"%s\" + Snooze %s %d card due %d cards due From 226411e72e3cf9cb559cf181c680d674ad3c320a Mon Sep 17 00:00:00 2001 From: Song Eric Yu Li Date: Sat, 19 Sep 2026 23:22:39 -0700 Subject: [PATCH 49/50] refactor(reminders): extract reminder load error message Assisted-by: Claude Opus 5 --- .../ichi2/anki/reviewreminders/ReviewRemindersDatabase.kt | 5 ++--- AnkiDroid/src/main/res/values/10-preferences.xml | 1 + 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReviewRemindersDatabase.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReviewRemindersDatabase.kt index d6e73460fc02..a411421d1b7c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReviewRemindersDatabase.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ReviewRemindersDatabase.kt @@ -7,6 +7,7 @@ import android.content.Context import android.content.SharedPreferences import androidx.annotation.VisibleForTesting import androidx.core.content.edit +import com.ichi2.anki.R import com.ichi2.anki.common.android.appContext import com.ichi2.anki.common.crashreporting.CrashReportService import com.ichi2.anki.common.time.TimeManager @@ -447,9 +448,7 @@ object ReviewRemindersDatabase { if (errorString.isEmpty()) return context.showError( - message = - "An error occurred while loading your review reminders, corrupted reminders have been deleted. " + - "Details:\n\n${errorString.ellipsize(1000)}", + message = context.getString(R.string.review_reminders_load_error) + "\n\n" + errorString.ellipsize(1000), crashReportData = null, // Crash reports are sent when the error is first encountered ) Prefs.reviewReminderDeserializationErrors = "" diff --git a/AnkiDroid/src/main/res/values/10-preferences.xml b/AnkiDroid/src/main/res/values/10-preferences.xml index e6ed734bbdaa..4a052bc521d8 100644 --- a/AnkiDroid/src/main/res/values/10-preferences.xml +++ b/AnkiDroid/src/main/res/values/10-preferences.xml @@ -225,6 +225,7 @@ Review reminders App-wide Deck-specific + An error occurred while loading your review reminders, corrupted reminders have been deleted. Details: Schedule reminders From 52b61f88e9b5f3f4283b418e4e1e062aa7d2e238 Mon Sep 17 00:00:00 2001 From: Song Eric Yu Li Date: Thu, 24 Sep 2026 00:48:02 -0700 Subject: [PATCH 50/50] fix(reminders): handle RTL in notification title As pointed out by David, a creative deck name like `French\u202B\u200E, you silly person` would result in an outputted notification title of `It's time to study "French", you silly person`, breaching quotation mark containment. This is essentially the inverse of `Lang.kt`'s `withoutUnicodeIsolation`, but could not be put into `Lang.kt` due to that being in `libanki`. Assisted-by: Claude Opus 5.5 --- .../anki/services/NotificationService.kt | 6 ++++- .../main/java/com/ichi2/utils/LanguageUtil.kt | 6 +++++ .../anki/services/NotificationServiceTest.kt | 22 +++++++++++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/services/NotificationService.kt b/AnkiDroid/src/main/java/com/ichi2/anki/services/NotificationService.kt index 411d46fb7e65..4ee4f0e84df5 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/services/NotificationService.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/services/NotificationService.kt @@ -34,6 +34,7 @@ import com.ichi2.anki.reviewreminders.reminderLogPrefix import com.ichi2.anki.runGloballyWithTimeout import com.ichi2.anki.settings.Prefs import com.ichi2.anki.utils.ext.getParcelableCompat +import com.ichi2.utils.LanguageUtil.withUnicodeIsolation import com.ichi2.widget.WidgetStatus import net.ankiweb.rsdroid.BackendException import timber.log.Timber @@ -222,7 +223,10 @@ class NotificationService : AnkiBroadcastReceiver() { val fullDeckName = reviewReminder.scope.getDeckName() val deckName = Decks.basename(fullDeckName) // don't show the full path with "::" included - context.getString(R.string.review_reminder_notification_title_deck, deckName) + context.getString( + R.string.review_reminder_notification_title_deck, + withUnicodeIsolation(deckName), + ) } } val description = diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/LanguageUtil.kt b/AnkiDroid/src/main/java/com/ichi2/utils/LanguageUtil.kt index cf928bae5326..0ebf5bfc83c8 100644 --- a/AnkiDroid/src/main/java/com/ichi2/utils/LanguageUtil.kt +++ b/AnkiDroid/src/main/java/com/ichi2/utils/LanguageUtil.kt @@ -362,4 +362,10 @@ object LanguageUtil { */ fun getListSeparator(context: Context): String = CompatHelper.compat.getListSeparator(context, context.getString(R.string.list_separator)) + + /** + * Wraps the provided string in Unicode isolation markers (FSI and PDI) to ensure bidirectional markers + * within the string are isolated. + */ + fun withUnicodeIsolation(s: String): String = "\u2068$s\u2069" } 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 9f5b180cbc8d..abadc97cc3cb 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/services/NotificationServiceTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/services/NotificationServiceTest.kt @@ -3,6 +3,7 @@ package com.ichi2.anki.services +import android.annotation.SuppressLint import android.app.Notification import android.app.NotificationManager import android.content.Context @@ -162,6 +163,27 @@ class NotificationServiceTest : RobolectricTest() { assertThat(notif.captured.extras.getString(Notification.EXTRA_TEXT), equalTo("2 cards due")) } + @Test + @SuppressLint("BidiSpoofing") + fun `notification title isolates bidi control characters in deck name`() = + runTest { + // an unclosed RTL embedding would otherwise move the closing quote: 'It's time to study "French", you silly person' + val did = addDeck("French‫‎, you silly person", setAsSelected = true).withNotes(count = 1) + val reviewReminder = createTestReminder(deckId = did) + ReviewRemindersDatabase.insertReminder(reviewReminder) + + val notif = slot() + + TimeManager.resetWith(today) + attemptNotif(reviewReminder) + + verifyNotifSent(reviewReminder, slot = notif) + assertThat( + notif.captured.extras.getString(Notification.EXTRA_TITLE), + equalTo("It's time to study \"⁨French‫‎, you silly person⁩\""), + ) + } + @Test fun `triggering with non-existent deck should not fire notification but schedule next`() = runTest {