From 38f9c237b7adfc2ebd2b3dabaccb5dabdb6a53ae Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 18 Apr 2026 08:28:03 +0100 Subject: [PATCH 01/87] build(ci): fix CodeQL OOM Since the configuration cache was enabled in 6a87b7dd1c8947ab20df470868ac54bac43e193c, the CodeQL job has regularly OOMed. CodeQL appears to run its own JVM, stopping the Gradle daemon should free ~3 GB for CodeQL with no downside. Fixes 20768 Assisted-by: Claude Opus 4.7 (investigation) --- .github/workflows/codeql.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 293c5b1586c9..6b9c06bea6a6 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -102,6 +102,8 @@ jobs: shell: bash run: | ./gradlew assemblePlayRelease -x lintVitalPlayRelease + # Free the Gradle daemon's heap before CodeQL analysis runs, or we OOM the runner (#20768) + ./gradlew --stop - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v4 From e8c2f43ad5dbfac9587a9e1dd2f5a6581752a6fe Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Sat, 18 Apr 2026 05:33:05 +0530 Subject: [PATCH 02/87] fix: error shown when snackbar not anchored --- .../main/java/com/ichi2/anki/DeckPicker.kt | 2 +- .../java/com/ichi2/anki/DeckPickerTest.kt | 35 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt index aa5e15ad050d..05d582464241 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt @@ -287,7 +287,7 @@ open class DeckPicker : } } override val baseSnackbarBuilder: SnackbarBuilder = { - anchorView = floatingActionButtonBinding.fabMain + anchorView = floatingActionButtonBinding.fabMain.takeIf { it.isVisible } addCallback(activeSnackbarCallback) } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt index e40dfa2a2efc..5946f15c19e7 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt @@ -9,6 +9,7 @@ import android.content.pm.PackageManager import android.database.sqlite.SQLiteDatabaseCorruptException import android.os.Bundle import android.view.Menu +import android.view.View import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.core.content.IntentCompat @@ -32,6 +33,7 @@ import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.observability.ChangeManager import com.ichi2.anki.preferences.sharedPrefs import com.ichi2.anki.settings.Prefs +import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.ui.windows.permissions.PermissionsActivity import com.ichi2.anki.ui.windows.permissions.PermissionsActivity.Companion.PERMISSIONS_SET_EXTRA import com.ichi2.anki.utils.Destination @@ -643,6 +645,39 @@ class DeckPickerTest : RobolectricTest() { assertThat(getUndoTitle(), containsString("Add Note")) } + @Test + fun `baseSnackbarBuilder has no anchor when FAB is hidden`() = + deckPicker { + val fab = findViewById(R.id.fab_main) + fab.visibility = View.GONE + + val snackbar = showSnackbar("test") + + snackbar?.let { baseSnackbarBuilder.invoke(it) } + + assertThat( + "anchorView must be null when FAB is not visible", + snackbar?.anchorView, + nullValue(), + ) + } + + @Test + fun `baseSnackbarBuilder anchors to FAB when visible`() = + deckPicker { + val fab = findViewById(R.id.fab_main) + fab.visibility = View.VISIBLE + + val snackbar = showSnackbar("test") + snackbar?.let { baseSnackbarBuilder.invoke(it) } + + assertThat( + "anchorView is the FAB when visible", + snackbar?.anchorView, + equalTo(fab), + ) + } + @Test fun `On a new startup, the App Intro is displayed`() = deckPicker(skipIntroduction = false) { From df9dfa9ffdc9c842599a6e5217b66e13ee5506c8 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:13:58 +0100 Subject: [PATCH 03/87] refactor(deck-picker): combine Context Menu result Both the 'Right Click Context Menu' and the 'Long press context menu' shared the same output shape and type, so combine them A data class is used to consolidate the result bundle processing Assisted-by: Claude Opus 4.6 --- .../main/java/com/ichi2/anki/DeckPicker.kt | 17 +----- .../DeckPickerMenuContentProvider.kt | 15 ++--- .../anki/dialogs/DeckPickerContextMenu.kt | 59 ++++++++++++++++--- .../java/com/ichi2/anki/DeckPickerTest.kt | 40 ++++++------- .../com/ichi2/anki/compat/CompatHelper.kt | 5 ++ 5 files changed, 80 insertions(+), 56 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt index 05d582464241..fed0f32a4d22 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt @@ -136,6 +136,7 @@ import com.ichi2.anki.dialogs.SyncErrorDialog.SyncErrorDialogListener import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog.CustomStudyAction import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog.CustomStudyAction.Companion.REQUEST_KEY +import com.ichi2.anki.dialogs.setDeckPickerContextMenuResultListener import com.ichi2.anki.export.ExportDialogFragment import com.ichi2.anki.filtered.FilteredDeckOptionsFragment import com.ichi2.anki.introduction.CollectionPermissionScreenLauncher @@ -602,20 +603,8 @@ open class DeckPicker : } } - setFragmentResultListener(DeckPickerContextMenu.REQUEST_KEY_CONTEXT_MENU) { _, bundle -> - handleContextMenuSelection( - bundle.getSerializableCompat(DeckPickerContextMenu.CONTEXT_MENU_DECK_OPTION) - ?: error("Unable to retrieve selected context menu option"), - bundle.getLong(DeckPickerContextMenu.CONTEXT_MENU_DECK_ID, -1), - ) - } - - setFragmentResultListener(DeckPickerMenuContentProvider.REQUEST_KEY_CONTEXT_MENU) { _, bundle -> - handleContextMenuSelection( - bundle.getSerializableCompat(DeckPickerMenuContentProvider.CONTEXT_MENU_DECK_OPTION) - ?: error("Unable to retrieve selected context menu option"), - bundle.getLong(DeckPickerMenuContentProvider.CONTEXT_MENU_DECK_ID, -1), - ) + setDeckPickerContextMenuResultListener { result -> + handleContextMenuSelection(result.option, result.deckId) } setFragmentResultListener(StudyOptionsFragment.REQUEST_STUDY_OPTIONS_STUDY) { _, _ -> diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/contextmenu/DeckPickerMenuContentProvider.kt b/AnkiDroid/src/main/java/com/ichi2/anki/contextmenu/DeckPickerMenuContentProvider.kt index 6b93feecb3c2..3569b6e3465f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/contextmenu/DeckPickerMenuContentProvider.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/contextmenu/DeckPickerMenuContentProvider.kt @@ -18,9 +18,10 @@ package com.ichi2.anki.contextmenu import android.view.Menu import android.view.MenuItem -import androidx.core.os.bundleOf import com.ichi2.anki.DeckPicker import com.ichi2.anki.dialogs.DeckPickerContextMenu +import com.ichi2.anki.dialogs.DeckPickerContextMenuResult +import com.ichi2.anki.dialogs.setDeckPickerContextMenuResult import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.settings.Prefs @@ -44,12 +45,8 @@ class DeckPickerMenuContentProvider( val options = createOptionsList() val selectedOption = options.getOrNull(item.itemId) ?: return false - deckPicker.supportFragmentManager.setFragmentResult( - REQUEST_KEY_CONTEXT_MENU, - bundleOf( - CONTEXT_MENU_DECK_ID to id, - CONTEXT_MENU_DECK_OPTION to selectedOption, - ), + deckPicker.supportFragmentManager.setDeckPickerContextMenuResult( + DeckPickerContextMenuResult(deckId = id, option = selectedOption), ) return true } @@ -96,9 +93,5 @@ class DeckPickerMenuContentProvider( } add(DeckPickerContextMenu.DeckPickerContextMenuOption.DELETE_DECK) } - - const val REQUEST_KEY_CONTEXT_MENU = "request_key_deck_context_menu_provider" - const val CONTEXT_MENU_DECK_OPTION = "context_menu_deck_option" - const val CONTEXT_MENU_DECK_ID = "context_menu_deck_id" } } 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 3bdac6a992e7..5872650b1834 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckPickerContextMenu.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckPickerContextMenu.kt @@ -21,10 +21,16 @@ import androidx.annotation.StringRes import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AlertDialog import androidx.core.os.bundleOf +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.FragmentManager import com.ichi2.anki.R import com.ichi2.anki.analytics.AnalyticsDialogFragment +import com.ichi2.anki.compat.requireSerializableCompat import com.ichi2.anki.contextmenu.DeckPickerMenuContentProvider +import com.ichi2.anki.dialogs.DeckPickerContextMenu.DeckPickerContextMenuOption import com.ichi2.anki.libanki.DeckId +import com.ichi2.anki.utils.ext.requireLong +import com.ichi2.anki.utils.ext.setFragmentResultListener import com.ichi2.utils.title class DeckPickerContextMenu : AnalyticsDialogFragment() { @@ -41,11 +47,10 @@ class DeckPickerContextMenu : AnalyticsDialogFragment() { .setItems( options.map { resources.getString(it.optionName) }.toTypedArray(), ) { _, index: Int -> - parentFragmentManager.setFragmentResult( - REQUEST_KEY_CONTEXT_MENU, - bundleOf( - CONTEXT_MENU_DECK_ID to requireArguments().getLong(ARG_DECK_ID), - CONTEXT_MENU_DECK_OPTION to options[index], + parentFragmentManager.setDeckPickerContextMenuResult( + DeckPickerContextMenuResult( + deckId = requireArguments().getLong(ARG_DECK_ID), + option = options[index], ), ) }.create() @@ -77,10 +82,6 @@ class DeckPickerContextMenu : AnalyticsDialogFragment() { } companion object { - const val REQUEST_KEY_CONTEXT_MENU = "request_key_context_menu" - const val CONTEXT_MENU_DECK_OPTION = "context_menu_deck_option" - const val CONTEXT_MENU_DECK_ID = "context_menu_deck_id" - @VisibleForTesting const val ARG_DECK_ID = "arg_deck_id" @@ -110,3 +111,43 @@ class DeckPickerContextMenu : AnalyticsDialogFragment() { } } } + +/** + * Result delivered by the deck-picker context menus + * + * @see DeckPickerContextMenuOption + * @see DeckPickerContextMenu + * @see DeckPickerMenuContentProvider + */ +data class DeckPickerContextMenuResult( + val deckId: DeckId, + val option: DeckPickerContextMenuOption, +) { + fun toBundle(): Bundle = + Bundle().apply { + putLong(ARG_DECK_ID, deckId) + putSerializable(ARG_OPTION, option) + } + + companion object { + const val REQUEST_KEY = "request_key_deck_picker_context_menu" + private const val ARG_DECK_ID = "deck_id" + private const val ARG_OPTION = "option" + + fun fromBundle(bundle: Bundle) = + DeckPickerContextMenuResult( + deckId = bundle.requireLong(ARG_DECK_ID), + option = bundle.requireSerializableCompat(ARG_OPTION), + ) + } +} + +fun FragmentManager.setDeckPickerContextMenuResult(result: DeckPickerContextMenuResult) { + setFragmentResult(DeckPickerContextMenuResult.REQUEST_KEY, result.toBundle()) +} + +fun FragmentActivity.setDeckPickerContextMenuResultListener(listener: (DeckPickerContextMenuResult) -> Unit) { + setFragmentResultListener(DeckPickerContextMenuResult.REQUEST_KEY) { _, bundle -> + listener(DeckPickerContextMenuResult.fromBundle(bundle)) + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt index 5946f15c19e7..781fd1040835 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt @@ -7,7 +7,6 @@ import android.content.Intent import android.content.SharedPreferences import android.content.pm.PackageManager import android.database.sqlite.SQLiteDatabaseCorruptException -import android.os.Bundle import android.view.Menu import android.view.View import android.widget.TextView @@ -16,7 +15,6 @@ import androidx.core.content.IntentCompat import androidx.core.content.edit import androidx.core.content.pm.ShortcutManagerCompat import androidx.core.view.children -import androidx.fragment.app.FragmentManager import androidx.test.core.app.ActivityScenario import anki.collection.opChanges import anki.scheduler.CardAnswer.Rating @@ -26,8 +24,9 @@ import com.ichi2.anki.common.utils.annotation.KotlinCleanup import com.ichi2.anki.deckpicker.DeckPickerViewModel import com.ichi2.anki.dialogs.DatabaseErrorDialog import com.ichi2.anki.dialogs.DatabaseErrorDialog.DatabaseErrorDialogType -import com.ichi2.anki.dialogs.DeckPickerContextMenu import com.ichi2.anki.dialogs.DeckPickerContextMenu.DeckPickerContextMenuOption +import com.ichi2.anki.dialogs.DeckPickerContextMenuResult +import com.ichi2.anki.dialogs.setDeckPickerContextMenuResult import com.ichi2.anki.dialogs.utils.title import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.observability.ChangeManager @@ -81,6 +80,8 @@ import kotlin.test.assertNotNull import kotlin.test.assertTrue import kotlin.time.Duration.Companion.seconds +typealias ContextMenuOption = DeckPickerContextMenuOption + @KotlinCleanup("SPMockBuilder") @RunWith(ParameterizedRobolectricTestRunner::class) class DeckPickerTest : RobolectricTest() { @@ -386,15 +387,15 @@ class DeckPickerTest : RobolectricTest() { deckPicker { val didA = addDeck("Deck 1") - supportFragmentManager.selectContextMenuOption(DeckPickerContextMenuOption.RENAME_DECK, didA) + selectContextMenuOption(ContextMenuOption.RENAME_DECK, didA) assertDialogTitleEquals("Rename deck") dismissAllDialogFragments() - supportFragmentManager.selectContextMenuOption(DeckPickerContextMenuOption.CREATE_SUBDECK, didA) + selectContextMenuOption(ContextMenuOption.CREATE_SUBDECK, didA) assertDialogTitleEquals("Create subdeck") dismissAllDialogFragments() - supportFragmentManager.selectContextMenuOption(DeckPickerContextMenuOption.CUSTOM_STUDY, didA) + selectContextMenuOption(ContextMenuOption.CUSTOM_STUDY, didA) assertDialogTitleEquals("Custom study") dismissAllDialogFragments() @@ -405,17 +406,12 @@ class DeckPickerTest : RobolectricTest() { } /** Simulates a selection in the context menu by setting the specific result in FragmentManager */ - private fun FragmentManager.selectContextMenuOption( + private fun DeckPicker.selectContextMenuOption( option: DeckPickerContextMenuOption, deckId: DeckId, - ) { - val arguments = - Bundle().apply { - putLong(DeckPickerContextMenu.CONTEXT_MENU_DECK_ID, deckId) - putSerializable(DeckPickerContextMenu.CONTEXT_MENU_DECK_OPTION, option) - } - setFragmentResult(DeckPickerContextMenu.REQUEST_KEY_CONTEXT_MENU, arguments) - } + ) = supportFragmentManager.setDeckPickerContextMenuResult( + DeckPickerContextMenuResult(deckId = deckId, option = option), + ) private fun assertDialogTitleEquals(expectedTitle: String) { val actualTitle = (ShadowDialog.getLatestDialog() as AlertDialog).title @@ -427,12 +423,12 @@ class DeckPickerTest : RobolectricTest() { fun `ContextMenu starts expected activities when specific options are selected`() = deckPicker { suspend fun DeckPicker.selectContextMenuOptionForActivity( - option: DeckPickerContextMenuOption, + option: ContextMenuOption, deckId: DeckId, ): Intent { var result: Destination? = null viewModel.flowOfDestination.test(1.seconds) { - supportFragmentManager.selectContextMenuOption(option, deckId) + selectContextMenuOption(option, deckId) result = awaitItem() } return result!!.toIntent(this) @@ -469,7 +465,7 @@ class DeckPickerTest : RobolectricTest() { fun `ContextMenu deletes deck when selecting DELETE_DECK`() = deckPicker { val didA = addDeck("Deck 1") - supportFragmentManager.selectContextMenuOption(DeckPickerContextMenuOption.DELETE_DECK, didA) + selectContextMenuOption(ContextMenuOption.DELETE_DECK, didA) assertThat(getColUnsafe.decks.allNamesAndIds().map { it.id }, not(containsInAnyOrder(didA))) } @@ -477,7 +473,7 @@ class DeckPickerTest : RobolectricTest() { fun `ContextMenu creates deck shortcut when selecting CREATE_SHORTCUT`() = deckPicker { val didA = addDeck("Deck 1") - supportFragmentManager.selectContextMenuOption(DeckPickerContextMenuOption.CREATE_SHORTCUT, didA) + selectContextMenuOption(ContextMenuOption.CREATE_SHORTCUT, didA) // Wait for the shortcut creation to complete ShadowLooper.runUiThreadTasksIncludingDelayedTasks() assertEquals( @@ -502,7 +498,7 @@ class DeckPickerTest : RobolectricTest() { advanceRobolectricLooper() assertEquals(1, visibleDeckCount) assertTrue(getColUnsafe.sched.haveBuried(), "Deck should have buried cards") - supportFragmentManager.selectContextMenuOption(DeckPickerContextMenuOption.UNBURY, deckId) + selectContextMenuOption(ContextMenuOption.UNBURY, deckId) kotlin.test.assertFalse(getColUnsafe.sched.haveBuried()) } @@ -519,11 +515,11 @@ class DeckPickerTest : RobolectricTest() { updateDeckList() assertEquals(1, visibleDeckCount) - supportFragmentManager.selectContextMenuOption(DeckPickerContextMenuOption.CUSTOM_STUDY_EMPTY, deckId) // Empty + selectContextMenuOption(ContextMenuOption.CUSTOM_STUDY_EMPTY, deckId) assertTrue(allCardsInSameDeck(cardIds, 1)) - supportFragmentManager.selectContextMenuOption(DeckPickerContextMenuOption.CUSTOM_STUDY_REBUILD, deckId) // Rebuild + selectContextMenuOption(ContextMenuOption.CUSTOM_STUDY_REBUILD, deckId) assertTrue(allCardsInSameDeck(cardIds, deckId)) } diff --git a/compat/src/main/java/com/ichi2/anki/compat/CompatHelper.kt b/compat/src/main/java/com/ichi2/anki/compat/CompatHelper.kt index 05468ab5a33b..6ae88076b6ea 100644 --- a/compat/src/main/java/com/ichi2/anki/compat/CompatHelper.kt +++ b/compat/src/main/java/com/ichi2/anki/compat/CompatHelper.kt @@ -217,3 +217,8 @@ class CompatHelper private constructor() { * @param tooltipText the tooltip text */ fun View.setTooltipTextCompat(tooltipText: CharSequence?) = TooltipCompat.setTooltipText(this, tooltipText) + +inline fun Bundle.requireSerializableCompat(key: String): T = + requireNotNull(compat.getSerializable(this, key, T::class.java)) { + "key: '$key' not found or null" + } From 7495a6e1d84fe937e670a0586d84b9b7072b5c52 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:37:10 +0100 Subject: [PATCH 04/87] refactor(deck-picker): instantiating context menus Extract the logic from the DeckPicker - the menus should be responsible for querying their required data suspend factory methods are a better abstraction `bundleOf()` will be deprecated, so remove a few instances `withCol { decks.select(deckId) }` remains as this is a Deck Picker concern - this shows the visual change of the deck when long-pressed Assisted-by: Claude Opus 4.6 --- .../main/java/com/ichi2/anki/DeckPicker.kt | 44 +++++-------------- .../DeckPickerMenuContentProvider.kt | 18 ++++++++ .../anki/dialogs/DeckPickerContextMenu.kt | 35 ++++++++------- 3 files changed, 46 insertions(+), 51 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt index fed0f32a4d22..cd14c12dce02 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt @@ -442,49 +442,25 @@ open class DeckPicker : } } - private fun showDeckPickerContextMenu(deckId: DeckId) { + private fun showDeckPickerContextMenu(deckId: DeckId) = launchCatchingTask { - val (deckName, isDynamic, hasBuriedInDeck) = - withCol { - decks.select(deckId) - Triple( - decks.name(deckId), - decks.isFiltered(deckId), - sched.haveBuried(), - ) - } + withCol { decks.select(deckId) } + val menu = DeckPickerContextMenu.newInstance(deckId) CardBrowser.clearLastDeckId() updateDeckList() - showDialogFragment( - DeckPickerContextMenu.newInstance( - id = deckId, - name = deckName, - isDynamic = isDynamic, - hasBuriedInDeck = hasBuriedInDeck, - ), - ) + showDialogFragment(menu) } - } private fun showDeckPickerRightClickContextMenu( deckId: DeckId, x: Float, y: Float, - ) { - launchCatchingTask { - val (isDynamic, hasBuriedInDeck) = - withCol { - decks.select(deckId) - Pair( - decks.isFiltered(deckId), - sched.haveBuried(), - ) - } - updateDeckList() - val menuContentProvider = DeckPickerMenuContentProvider(deckId, isDynamic, hasBuriedInDeck, this@DeckPicker) - mouseContextMenuHandler = MouseContextMenuHandler(deckPickerBinding.deckPickerContent, menuContentProvider) - mouseContextMenuHandler.showContextMenu(deckPickerBinding.decks, x, y) - } + ) = launchCatchingTask { + withCol { decks.select(deckId) } + val menuContentProvider = DeckPickerMenuContentProvider.newInstance(deckId, this@DeckPicker) + updateDeckList() + mouseContextMenuHandler = MouseContextMenuHandler(deckPickerBinding.deckPickerContent, menuContentProvider) + mouseContextMenuHandler.showContextMenu(deckPickerBinding.decks, x, y) } // ---------------------------------------------------------------------------- diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/contextmenu/DeckPickerMenuContentProvider.kt b/AnkiDroid/src/main/java/com/ichi2/anki/contextmenu/DeckPickerMenuContentProvider.kt index 3569b6e3465f..1a91bcd8ce94 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/contextmenu/DeckPickerMenuContentProvider.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/contextmenu/DeckPickerMenuContentProvider.kt @@ -18,6 +18,7 @@ package com.ichi2.anki.contextmenu import android.view.Menu import android.view.MenuItem +import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.DeckPicker import com.ichi2.anki.dialogs.DeckPickerContextMenu import com.ichi2.anki.dialogs.DeckPickerContextMenuResult @@ -61,6 +62,23 @@ class DeckPickerMenuContentProvider( private fun createOptionsList(): List = createOptionsList(isDynamic, hasBuriedInDeck) companion object { + /** + * Builds a [DeckPickerMenuContentProvider] for [deckId], reading the dynamic / + * has-buried flags from the collection. + */ + suspend fun newInstance( + deckId: DeckId, + deckPicker: DeckPicker, + ): DeckPickerMenuContentProvider = + withCol { + DeckPickerMenuContentProvider( + id = deckId, + isDynamic = decks.isFiltered(deckId), + hasBuriedInDeck = sched.haveBuried(), + deckPicker = deckPicker, + ) + } + fun createOptionsList( isDynamic: Boolean, hasBuriedInDeck: Boolean, 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 5872650b1834..8f6151c4c42c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckPickerContextMenu.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckPickerContextMenu.kt @@ -20,9 +20,9 @@ import android.os.Bundle import androidx.annotation.StringRes import androidx.annotation.VisibleForTesting import androidx.appcompat.app.AlertDialog -import androidx.core.os.bundleOf import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentManager +import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.R import com.ichi2.anki.analytics.AnalyticsDialogFragment import com.ichi2.anki.compat.requireSerializableCompat @@ -82,6 +82,23 @@ class DeckPickerContextMenu : AnalyticsDialogFragment() { } companion object { + /** + * Builds a [DeckPickerContextMenu] for [deckId], reading the deck's name and + * the dynamic / has-buried flags from the collection. + */ + suspend fun newInstance(deckId: DeckId): DeckPickerContextMenu = + withCol { + DeckPickerContextMenu().apply { + arguments = + Bundle().apply { + putLong(ARG_DECK_ID, deckId) + putString(ARG_DECK_NAME, decks.name(deckId)) + putBoolean(ARG_DECK_IS_DYN, decks.isFiltered(deckId)) + putBoolean(ARG_DECK_HAS_BURIED_IN_DECK, sched.haveBuried()) + } + } + } + @VisibleForTesting const val ARG_DECK_ID = "arg_deck_id" @@ -93,22 +110,6 @@ class DeckPickerContextMenu : AnalyticsDialogFragment() { @VisibleForTesting const val ARG_DECK_HAS_BURIED_IN_DECK = "arg_deck_has_buried_in_deck" - - fun newInstance( - id: DeckId, - name: String, - isDynamic: Boolean, - hasBuriedInDeck: Boolean, - ): DeckPickerContextMenu = - DeckPickerContextMenu().apply { - arguments = - bundleOf( - ARG_DECK_ID to id, - ARG_DECK_NAME to name, - ARG_DECK_IS_DYN to isDynamic, - ARG_DECK_HAS_BURIED_IN_DECK to hasBuriedInDeck, - ) - } } } From 70c79ba80ed47ec5037ed9df04a54d458dc7aac2 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:44:08 +0100 Subject: [PATCH 05/87] refactor(deck-picker): right click menu * remove unnecessary state * define 'show' which handles DeckPicker specific logic more verbose, but easier to understand Assisted-by: Claude Opus 4.6 --- .../main/java/com/ichi2/anki/DeckPicker.kt | 13 ++++--- .../DeckPickerMenuContentProvider.kt | 35 ++++++++++++------- 2 files changed, 28 insertions(+), 20 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt index cd14c12dce02..17a7288ac86f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt @@ -97,7 +97,6 @@ import com.ichi2.anki.common.time.TimeManager import com.ichi2.anki.common.utils.annotation.KotlinCleanup import com.ichi2.anki.compat.CompatHelper.Companion.getSerializableCompat import com.ichi2.anki.contextmenu.DeckPickerMenuContentProvider -import com.ichi2.anki.contextmenu.MouseContextMenuHandler import com.ichi2.anki.databinding.ActivityHomescreenBinding import com.ichi2.anki.databinding.IncludeDeckPickerBinding import com.ichi2.anki.databinding.IncludeFloatingAddButtonBinding @@ -268,9 +267,6 @@ open class DeckPicker : private lateinit var deckListAdapter: DeckAdapter private lateinit var pullToSyncWrapper: SwipeRefreshLayout - // Right-click context menu handler using decoupled menu system - private lateinit var mouseContextMenuHandler: MouseContextMenuHandler - private lateinit var floatingActionMenu: DeckPickerFloatingActionMenu var activeSnackBar: Snackbar? = null @@ -457,10 +453,13 @@ open class DeckPicker : y: Float, ) = launchCatchingTask { withCol { decks.select(deckId) } - val menuContentProvider = DeckPickerMenuContentProvider.newInstance(deckId, this@DeckPicker) updateDeckList() - mouseContextMenuHandler = MouseContextMenuHandler(deckPickerBinding.deckPickerContent, menuContentProvider) - mouseContextMenuHandler.showContextMenu(deckPickerBinding.decks, x, y) + DeckPickerMenuContentProvider.show( + deckPicker = this@DeckPicker, + deckId = deckId, + x = x, + y = y, + ) } // ---------------------------------------------------------------------------- diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/contextmenu/DeckPickerMenuContentProvider.kt b/AnkiDroid/src/main/java/com/ichi2/anki/contextmenu/DeckPickerMenuContentProvider.kt index 1a91bcd8ce94..a39a03285b39 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/contextmenu/DeckPickerMenuContentProvider.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/contextmenu/DeckPickerMenuContentProvider.kt @@ -63,21 +63,30 @@ class DeckPickerMenuContentProvider( companion object { /** - * Builds a [DeckPickerMenuContentProvider] for [deckId], reading the dynamic / - * has-buried flags from the collection. + * Builds a [DeckPickerMenuContentProvider] for [deckId] (reading the dynamic / + * has-buried flags from the collection) and shows it as a popup anchored at + * ([x], [y]) on the deck picker's recycler view. */ - suspend fun newInstance( - deckId: DeckId, + suspend fun show( deckPicker: DeckPicker, - ): DeckPickerMenuContentProvider = - withCol { - DeckPickerMenuContentProvider( - id = deckId, - isDynamic = decks.isFiltered(deckId), - hasBuriedInDeck = sched.haveBuried(), - deckPicker = deckPicker, - ) - } + deckId: DeckId, + x: Float, + y: Float, + ) { + val provider = + withCol { + DeckPickerMenuContentProvider( + id = deckId, + isDynamic = decks.isFiltered(deckId), + hasBuriedInDeck = sched.haveBuried(), + deckPicker = deckPicker, + ) + } + val anchorParent = deckPicker.deckPickerBinding.deckPickerContent + val target = deckPicker.deckPickerBinding.decks + MouseContextMenuHandler(viewGroup = anchorParent, menuContentProvider = provider) + .showContextMenu(view = target, x = x, y = y) + } fun createOptionsList( isDynamic: Boolean, From ad7db061e1c8226f1d688ae49e8209d6d1fde995 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 7 Apr 2026 20:58:10 +0100 Subject: [PATCH 06/87] refactor(deck-picker): move deck selection to ViewModel Assisted-by: Claude Opus 4.6 --- AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt | 6 ++---- .../com/ichi2/anki/deckpicker/DeckPickerViewModel.kt | 11 +++++++++++ 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt index 17a7288ac86f..fdb45e6f83da 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt @@ -440,10 +440,9 @@ open class DeckPicker : private fun showDeckPickerContextMenu(deckId: DeckId) = launchCatchingTask { - withCol { decks.select(deckId) } + viewModel.selectDeck(deckId).join() val menu = DeckPickerContextMenu.newInstance(deckId) CardBrowser.clearLastDeckId() - updateDeckList() showDialogFragment(menu) } @@ -452,8 +451,7 @@ open class DeckPicker : x: Float, y: Float, ) = launchCatchingTask { - withCol { decks.select(deckId) } - updateDeckList() + viewModel.selectDeck(deckId).join() DeckPickerMenuContentProvider.show( deckPicker = this@DeckPicker, deckId = deckId, diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt index 1465f6d5229e..b3de501632ed 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt @@ -277,6 +277,17 @@ class DeckPickerViewModel : flowOfDeckCountsChanged.emit(Unit) } + /** + * Marks [deckId] as the currently selected deck and updates the selection in the deck list. + */ + fun selectDeck(deckId: DeckId) = + viewModelScope.launch { + // TODO: should we always reset the Card Browser default deck here? + withCol { decks.select(deckId) } + focusedDeck = deckId + flowOfRefreshDeckList.emit(Unit) + } + fun browseCards(deckId: DeckId) = launchCatchingIO { withCol { decks.select(deckId) } From d48f6f5b7be38783b2d8e7d9aae5e545838fedec Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Wed, 15 Apr 2026 12:03:37 +0100 Subject: [PATCH 07/87] fix(notifications): use app language On API 32 and earlier, AppCompatDelegate.setApplicationLocales only works with AppCompatActivity. Work around this by manually setting the app locale Fixes 19048 Assisted-by: Claude Opus 4.6 - withAppLocale + investigation --- .../main/java/com/ichi2/anki/AnkiDroidApp.kt | 6 +++-- .../anki/services/NotificationService.kt | 7 ++++- .../main/java/com/ichi2/utils/LanguageUtil.kt | 26 +++++++++++++++++++ 3 files changed, 36 insertions(+), 3 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt index 3c7ef70fc8b9..28ef7e41bbc6 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt @@ -62,6 +62,7 @@ import com.ichi2.anki.ui.dialogs.ActivityAgnosticDialogs import com.ichi2.utils.AdaptionUtil import com.ichi2.utils.ExceptionUtil import com.ichi2.utils.LanguageUtil +import com.ichi2.utils.LanguageUtil.withAppLocale import com.ichi2.utils.Permissions import com.ichi2.utils.setWebContentsDebuggingEnabled import com.ichi2.widget.cardanalysis.CardAnalysisWidget @@ -201,13 +202,14 @@ open class AnkiDroidApp : initializeAnkiDroidDirectory() + val context = this.withAppLocale() if (Prefs.newReviewRemindersEnabled) { Timber.i("Setting review reminder notifications if they have not already been set") - AlarmManagerService.scheduleAllNotifications(applicationContext) + AlarmManagerService.scheduleAllNotifications(context) } else { // Register for notifications Timber.i("AnkiDroidApp: Starting Services") - notifications.observeForever { NotificationService.triggerNotificationFor(this) } + notifications.observeForever { NotificationService.triggerNotificationFor(context) } } // listen for day rollover: time + timezone changes 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 2fdb64f4d8ab..dcd38c37f645 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/services/NotificationService.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/services/NotificationService.kt @@ -41,9 +41,12 @@ import com.ichi2.anki.reviewreminders.ReviewReminderScope import com.ichi2.anki.reviewreminders.ReviewRemindersDatabase import com.ichi2.anki.reviewreminders.upsertReminder import com.ichi2.anki.runGloballyWithTimeout +import com.ichi2.anki.services.NotificationService.Companion.addAction +import com.ichi2.anki.services.NotificationService.Companion.getIntent import com.ichi2.anki.settings.Prefs import com.ichi2.anki.utils.ext.allDecksCounts import com.ichi2.anki.utils.remainingTime +import com.ichi2.utils.LanguageUtil.withAppLocale import com.ichi2.widget.WidgetStatus import net.ankiweb.rsdroid.BackendException import timber.log.Timber @@ -448,9 +451,11 @@ class NotificationService : BroadcastReceiver() { * @see getIntent */ override fun onReceive( - context: Context, + rawContext: Context, intent: Intent, ) { + // #19048: On API < 33, BroadcastReceiver context uses the system locale. + val context = rawContext.withAppLocale() if (Prefs.newReviewRemindersEnabled) { Timber.d("onReceive") val action = intent.action ?: return diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/LanguageUtil.kt b/AnkiDroid/src/main/java/com/ichi2/utils/LanguageUtil.kt index 42aa7160d794..d72ed8000b05 100644 --- a/AnkiDroid/src/main/java/com/ichi2/utils/LanguageUtil.kt +++ b/AnkiDroid/src/main/java/com/ichi2/utils/LanguageUtil.kt @@ -27,6 +27,7 @@ import com.ichi2.anki.R import com.ichi2.anki.compat.CompatHelper import com.ichi2.anki.preferences.sharedPrefs import net.ankiweb.rsdroid.BackendFactory +import timber.log.Timber import java.util.Locale /** @@ -363,6 +364,31 @@ object LanguageUtil { return createConfigurationContext(configuration).resources.getString(stringRes, *formatArgs) } + /** + * Returns a [Context] with resources using the app language. + * + * Needed for resources accessed outside an Activity (e.g. from a [BroadcastReceiver][android.content.BroadcastReceiver] + * or [Service][android.app.Service]): + * + * On API < 33, [AppCompatDelegate.setApplicationLocales] only applies to Activity contexts, so + * resources resolve in the system locale. + * + * Returns [this] unchanged (System language) when no in-app language is set. + * + * This method will not throw. + */ + fun Context.withAppLocale(): Context = + try { + val tag = getCurrentLocaleTag() + if (tag.isEmpty()) return this + val configuration = Configuration(resources.configuration) + configuration.setLocale(Locale.forLanguageTag(tag)) + return createConfigurationContext(configuration) + } catch (e: Exception) { + Timber.w(e, "withAppLocale") + return this + } + /** @return string defined with [stringRes] on the specified [locale] */ fun Fragment.getStringByLocale( @StringRes stringRes: Int, From 2668e57899938eed5fd81067df16014cf15d3473 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Fri, 17 Apr 2026 10:04:13 +0100 Subject: [PATCH 08/87] fix: use app language in BroadcastReceivers On API 32 and earlier, AppCompatDelegate.setApplicationLocales only works with AppCompatActivity. Work around this by manually setting the app locale Issue 19048 Assisted-by: Claude Opus 4.6 - most of the refactoring --- .../main/java/com/ichi2/anki/AnkiActivity.kt | 5 ++- .../java/com/ichi2/anki/CoroutineHelpers.kt | 24 +++++----- .../java/com/ichi2/anki/DayRolloverHandler.kt | 12 ++--- .../ichi2/anki/SharedDecksDownloadFragment.kt | 13 +++--- .../anki/android/AnkiBroadcastReceiver.kt | 44 +++++++++++++++++++ .../com/ichi2/anki/receiver/SdCardReceiver.kt | 6 +-- .../anki/services/AlarmManagerService.kt | 12 +++-- .../com/ichi2/anki/services/BootService.kt | 6 +-- .../anki/services/NotificationService.kt | 23 +++++----- .../ichi2/widget/WidgetPermissionReceiver.kt | 6 +-- .../cardanalysis/CardAnalysisWidgetConfig.kt | 12 ++--- .../deckpicker/DeckPickerWidgetConfig.kt | 14 +++--- 12 files changed, 114 insertions(+), 63 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/android/AnkiBroadcastReceiver.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt index dfd0bec11470..43fca6a427e9 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt @@ -57,6 +57,7 @@ import com.ichi2.anim.ActivityTransitionAnimation.Direction import com.ichi2.anim.ActivityTransitionAnimation.Direction.DEFAULT import com.ichi2.anim.ActivityTransitionAnimation.Direction.NONE import com.ichi2.anki.analytics.UsageAnalytics +import com.ichi2.anki.android.AnkiBroadcastReceiver import com.ichi2.anki.android.input.ShortcutGroup import com.ichi2.anki.android.input.ShortcutGroupProvider import com.ichi2.anki.android.input.shortcut @@ -245,8 +246,8 @@ open class AnkiActivity( return } broadcastReceiver = - object : BroadcastReceiver() { - override fun onReceive( + object : AnkiBroadcastReceiver() { + override fun onReceiveBroadcast( context: Context, intent: Intent, ) { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt index b6045ea8a774..ec3c06612208 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt @@ -18,7 +18,6 @@ package com.ichi2.anki import android.app.Activity import android.app.Dialog -import android.content.BroadcastReceiver import android.content.Context import android.content.DialogInterface import android.database.sqlite.SQLiteDatabaseCorruptException @@ -41,6 +40,7 @@ import com.ichi2.anki.CrashReportData.Companion.toCrashReportData import com.ichi2.anki.CrashReportData.HelpAction import com.ichi2.anki.CrashReportData.HelpAction.AnkiBackendLink import com.ichi2.anki.CrashReportData.HelpAction.OpenDeckOptions +import com.ichi2.anki.android.AnkiBroadcastReceiver import com.ichi2.anki.common.annotations.UseContextParameter import com.ichi2.anki.dialogs.DatabaseErrorDialog import com.ichi2.anki.dialogs.DatabaseErrorDialog.DatabaseErrorDialogType @@ -614,30 +614,34 @@ private fun Activity.showError( ) = showError(throwable.toString(), throwable.toCrashReportData(context = this, reportException)) /** - * Since BroadcastReceiver onReceive methods are expected to finish quickly, this helper function - * is required to run a suspending function from an onReceive method. [BroadcastReceiver.goAsync] - * extends the lifetime of the onReceive method and tells the OS not to kill the process prematurely. + * Since AnkiBroadcastReceiver's `onReceiveBroadcast` methods is expected to finish quickly, this + * helper function is required to run a suspending function from an `onReceiveBroadcast` method. + * [AnkiBroadcastReceiver.goAsync] extends the lifetime of the `onReceiveBroadcast` method and tells + * the OS not to kill the process prematurely. * - * Do not call [BroadcastReceiver.goAsync] directly before calling this function. + * Do not call [AnkiBroadcastReceiver.goAsync] directly before calling this function. * - * @param timeout Just in case the block hangs. Cannot exceed 8 seconds, because an ANR may occur if a - * BroadcastReceiver's onReceive method runs for longer than 10 seconds. + * @param timeout Just in case the block hangs. Cannot exceed 8 seconds, because an ANR may occur if + * an AnkiBroadcastReceiver's onReceiveBroadcast method runs for longer than 10 seconds. * See [the docs](https://developer.android.com/reference/android/content/BroadcastReceiver#goAsync()). * @param block The suspending function to run. + * + * @see AnkiBroadcastReceiver.goAsync + * @see AnkiBroadcastReceiver.onReceiveBroadcast */ -fun BroadcastReceiver.runGloballyWithTimeout( +fun AnkiBroadcastReceiver.runGloballyWithTimeout( timeout: Duration, block: suspend () -> Unit, ) { val pendingResult = goAsync() if (pendingResult == null) { // pendingResult should never be null, so this should never happen. - // According to the implementation of goAsync, if it is, that indicates goAsync was called twice for the same onReceive. + // According to the implementation of goAsync, if it is, that indicates goAsync was called twice for the same onReceiveBroadcast. Timber.w("goAsync returned null, cannot run block") CrashReportService.sendExceptionReport( message = "goAsync returned null for BroadcastReceiver: " + - "This should never happen and indicates goAsync was called twice for the same onReceive", + "This should never happen and indicates goAsync was called twice for the same onReceiveBroadcast", origin = "CoroutineHelpers:BroadcastReceiver.runGloballyWithTimeout", ) return diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DayRolloverHandler.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DayRolloverHandler.kt index c65f9ac290b4..b0a6be526db4 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DayRolloverHandler.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DayRolloverHandler.kt @@ -23,7 +23,6 @@ package com.ichi2.anki -import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.Intent.ACTION_TIMEZONE_CHANGED @@ -35,6 +34,7 @@ import androidx.core.content.ContextCompat.RECEIVER_EXPORTED import anki.collection.OpChanges import anki.collection.opChanges import com.ichi2.anki.CollectionManager.withOpenColOrNull +import com.ichi2.anki.android.AnkiBroadcastReceiver import com.ichi2.anki.exception.ManuallyReportedException import com.ichi2.anki.libanki.EpochSeconds import com.ichi2.anki.libanki.sched.Scheduler @@ -51,7 +51,7 @@ import timber.log.Timber * HACK: This exists due to Android's complexities of scheduling an event at a given time. * It would be preferred to receive an event at the instant of rollover */ -object DayRolloverHandler : BroadcastReceiver() { +object DayRolloverHandler : AnkiBroadcastReceiver() { /** @see Scheduler.dayCutoff */ private var lastCutoff: EpochSeconds? = null @@ -67,13 +67,13 @@ object DayRolloverHandler : BroadcastReceiver() { register(IntentFilter(ACTION_TIMEZONE_CHANGED)) } - override fun onReceive( - context: Context?, - intent: Intent?, + override fun onReceiveBroadcast( + 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}") + Timber.v("received ${intent.action}") // launch coroutine as we need access to `col.sched` AnkiDroidApp.applicationScope.launchCatching(Dispatchers.IO, errorMessageHandler = { msg -> CrashReportService.sendExceptionReport( diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/SharedDecksDownloadFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/SharedDecksDownloadFragment.kt index f1dd210a9098..fa13bc77b434 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/SharedDecksDownloadFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/SharedDecksDownloadFragment.kt @@ -37,6 +37,7 @@ import androidx.core.net.toUri import androidx.fragment.app.Fragment import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.SharedDecksActivity.Companion.DOWNLOAD_FILE +import com.ichi2.anki.android.AnkiBroadcastReceiver import com.ichi2.anki.compat.CompatHelper.Companion.getSerializableCompat import com.ichi2.anki.compat.CompatHelper.Companion.registerReceiverCompat import com.ichi2.anki.databinding.FragmentSharedDecksDownloadBinding @@ -204,7 +205,7 @@ class SharedDecksDownloadFragment : Fragment(R.layout.fragment_shared_decks_down val downloadRequest = generateDeckDownloadRequest(fileToBeDownloaded, currentFileName) - // Store unique download ID to be used when onReceive() of BroadcastReceiver gets executed. + // Store unique download ID to be used when onReceiveBroadcast() of AnkiBroadcastReceiver gets executed. downloadId = downloadManager.enqueue(downloadRequest) fileName = currentFileName isDownloadInProgress = true @@ -241,13 +242,13 @@ class SharedDecksDownloadFragment : Fragment(R.layout.fragment_shared_decks_down /** * Registered in downloadFile() method. - * When onReceive() is called, open the deck file in AnkiDroid to import it. + * When [AnkiBroadcastReceiver.onReceiveBroadcast] is called, open the deck file in AnkiDroid to import it. */ private var onComplete: BroadcastReceiver = - object : BroadcastReceiver() { - override fun onReceive( + object : AnkiBroadcastReceiver() { + override fun onReceiveBroadcast( context: Context, - intent: Intent?, + intent: Intent, ) { Timber.i("Download might be complete now, verify and continue with import") @@ -265,7 +266,7 @@ class SharedDecksDownloadFragment : Fragment(R.layout.fragment_shared_decks_down } // Return if mDownloadId does not match with the ID of the completed download. - if (downloadId != intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0)) { + if (downloadId != intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, 0)) { Timber.w("Download id did not match expected id. Ignoring this download completion") return false } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/android/AnkiBroadcastReceiver.kt b/AnkiDroid/src/main/java/com/ichi2/anki/android/AnkiBroadcastReceiver.kt new file mode 100644 index 000000000000..6c3b6b2ea128 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/android/AnkiBroadcastReceiver.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2026 David Allison + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.anki.android + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import com.ichi2.utils.LanguageUtil.withAppLocale + +/** + * A base class for all [BroadcastReceiver] instances in the app + * + * @see BroadcastReceiver + */ +abstract class AnkiBroadcastReceiver : BroadcastReceiver() { + // #19048: On API < 33, BroadcastReceiver context uses the system locale. + final override fun onReceive( + rawContext: Context, + intent: Intent, + ) { + val context = rawContext.withAppLocale() + onReceiveBroadcast(context, intent) + } + + /** @see onReceive */ + abstract fun onReceiveBroadcast( + context: Context, + intent: Intent, + ) +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/receiver/SdCardReceiver.kt b/AnkiDroid/src/main/java/com/ichi2/anki/receiver/SdCardReceiver.kt index 5e9786e60ad1..2e45461d21c6 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/receiver/SdCardReceiver.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/receiver/SdCardReceiver.kt @@ -16,10 +16,10 @@ package com.ichi2.anki.receiver -import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import com.ichi2.anki.CollectionManager +import com.ichi2.anki.android.AnkiBroadcastReceiver import timber.log.Timber /** @@ -27,8 +27,8 @@ import timber.log.Timber * intent to all activities which might be open in order to show an appropriate screen After media has been remounted, * another broadcast intent will be sent to let the activities know about it */ -class SdCardReceiver : BroadcastReceiver() { - override fun onReceive( +class SdCardReceiver : AnkiBroadcastReceiver() { + override fun onReceiveBroadcast( context: Context, intent: Intent, ) { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/services/AlarmManagerService.kt b/AnkiDroid/src/main/java/com/ichi2/anki/services/AlarmManagerService.kt index a4c07b8269de..e8d5f79cf2a2 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/services/AlarmManagerService.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/services/AlarmManagerService.kt @@ -19,7 +19,6 @@ package com.ichi2.anki.services import android.app.AlarmManager import android.app.NotificationManager import android.app.PendingIntent -import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import androidx.annotation.VisibleForTesting @@ -27,11 +26,16 @@ import androidx.core.app.PendingIntentCompat import androidx.core.content.getSystemService import androidx.core.os.BundleCompat import com.ichi2.anki.R +import com.ichi2.anki.android.AnkiBroadcastReceiver import com.ichi2.anki.common.time.TimeManager import com.ichi2.anki.reviewreminders.ReviewReminder import com.ichi2.anki.reviewreminders.ReviewReminderScope import com.ichi2.anki.reviewreminders.ReviewRemindersDatabase import com.ichi2.anki.reviewreminders.upsertReminder +import com.ichi2.anki.services.AlarmManagerService.Companion.WINDOW_LENGTH_MS +import com.ichi2.anki.services.AlarmManagerService.Companion.getIntent +import com.ichi2.anki.services.AlarmManagerService.Companion.scheduleAllEnabledReviewReminderNotifications +import com.ichi2.anki.services.AlarmManagerService.Companion.unscheduleReviewReminderNotifications import com.ichi2.anki.showThemedToast import timber.log.Timber import java.util.Calendar @@ -46,11 +50,11 @@ import kotlin.time.Duration.Companion.minutes * * This service also handles scheduling snoozed instances of review reminders. * Notifications have snooze buttons (defined in [NotificationService]) which, when clicked, - * trigger the [onReceive] method of this BroadcastReceiver. This service handles the snooze delay, + * trigger the [onReceiveBroadcast] method of this BroadcastReceiver. This service handles the snooze delay, * after which it dispatches a one-time [NotificationService.NotificationServiceAction.SnoozeNotification] * request to [NotificationService]. */ -class AlarmManagerService : BroadcastReceiver() { +class AlarmManagerService : AnkiBroadcastReceiver() { companion object { /** * Extra key for sending a review reminder as an extra to this BroadcastReceiver. @@ -349,7 +353,7 @@ class AlarmManagerService : BroadcastReceiver() { * Begins snoozing a review reminder. * @see getIntent */ - override fun onReceive( + override fun onReceiveBroadcast( context: Context, intent: Intent, ) { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/services/BootService.kt b/AnkiDroid/src/main/java/com/ichi2/anki/services/BootService.kt index 67d828fef93b..3e3cec9f9fb0 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/services/BootService.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/services/BootService.kt @@ -18,13 +18,13 @@ package com.ichi2.anki.services import android.app.AlarmManager -import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import androidx.core.app.PendingIntentCompat import com.ichi2.anki.CollectionManager import com.ichi2.anki.IntentHandler.Companion.grantedStoragePermissions import com.ichi2.anki.R +import com.ichi2.anki.android.AnkiBroadcastReceiver import com.ichi2.anki.common.annotations.LegacyNotifications import com.ichi2.anki.common.annotations.NeedsTest import com.ichi2.anki.common.time.Time @@ -45,11 +45,11 @@ import java.util.Calendar * intent, which could cause review reminders to not be scheduled. */ @NeedsTest("Check on various Android versions that this can execute") -class BootService : BroadcastReceiver() { +class BootService : AnkiBroadcastReceiver() { @LegacyNotifications("Notifications will be scheduled rather than instantly shown on boot or app launch") private var failedToShowNotifications = false - override fun onReceive( + override fun onReceiveBroadcast( context: Context, intent: Intent, ) { 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 dcd38c37f645..9d14517bd333 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/services/NotificationService.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/services/NotificationService.kt @@ -16,7 +16,6 @@ package com.ichi2.anki.services import android.app.NotificationManager import android.app.PendingIntent -import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.graphics.Color @@ -30,6 +29,7 @@ import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.DeckPicker import com.ichi2.anki.IntentHandler import com.ichi2.anki.R +import com.ichi2.anki.android.AnkiBroadcastReceiver import com.ichi2.anki.canUserAccessDeck import com.ichi2.anki.common.annotations.LegacyNotifications import com.ichi2.anki.libanki.Decks @@ -46,7 +46,6 @@ import com.ichi2.anki.services.NotificationService.Companion.getIntent import com.ichi2.anki.settings.Prefs import com.ichi2.anki.utils.ext.allDecksCounts import com.ichi2.anki.utils.remainingTime -import com.ichi2.utils.LanguageUtil.withAppLocale import com.ichi2.widget.WidgetStatus import net.ankiweb.rsdroid.BackendException import timber.log.Timber @@ -65,7 +64,7 @@ import kotlin.time.Duration.Companion.seconds * This service can be triggered in one of two possible ways, depending on whether the notification * being fired is a recurring notification or a one-time snoozed notification. See [NotificationServiceAction]. */ -class NotificationService : BroadcastReceiver() { +class NotificationService : AnkiBroadcastReceiver() { companion object { /** * NotificationManager tag for review reminder notifications, passed to the [NotificationManager.notify] method. @@ -83,13 +82,13 @@ class NotificationService : BroadcastReceiver() { /** * Timeout for the process of sending a review reminder notification. - * Should be below 10 seconds as BroadcastReceivers may ANR when onReceive takes longer than 10 seconds. + * Should be below 10 seconds as BroadcastReceivers may ANR when onReceiveBroadcast takes longer than 10 seconds. * See [the docs](https://developer.android.com/reference/android/content/BroadcastReceiver#goAsync()). */ private val SEND_REVIEW_REMINDER_TIMEOUT = 8.seconds /** - * Triggered by [onReceive]. Does some bookkeeping if the triggered notification is of the recurring type + * Triggered by [onReceiveBroadcast]. Does some bookkeeping if the triggered notification is of the recurring type * or does nothing if it is a snoozed one, and then begins the process of sending the notification. */ @VisibleForTesting @@ -429,7 +428,7 @@ class NotificationService : BroadcastReceiver() { * pressed snooze. * * @see AlarmManagerService.getReviewReminderNotificationPendingIntent - * @see onReceive + * @see onReceiveBroadcast */ sealed class NotificationServiceAction( val actionString: String, @@ -450,14 +449,12 @@ class NotificationService : BroadcastReceiver() { /** * @see getIntent */ - override fun onReceive( - rawContext: Context, + override fun onReceiveBroadcast( + context: Context, intent: Intent, ) { - // #19048: On API < 33, BroadcastReceiver context uses the system locale. - val context = rawContext.withAppLocale() if (Prefs.newReviewRemindersEnabled) { - Timber.d("onReceive") + Timber.d("onReceiveBroadcast") val action = intent.action ?: return val extras = intent.extras ?: return val reviewReminder = @@ -466,9 +463,9 @@ class NotificationService : BroadcastReceiver() { EXTRA_REVIEW_REMINDER, ReviewReminder::class.java, ) ?: return - Timber.d("onReceive: ${reviewReminder.id}") + Timber.d("onReceiveBroadcast: ${reviewReminder.id}") - // We must run some suspending functions. Hence we mark this onReceive function as long-running + // We must run some suspending functions. Hence we mark this onReceiveBroadcast function as long-running // and use the global scope for simplicity's sake. Theoretically, we could also use an expedited Worker, // but AnkiDroid is only allotted a fixed number of expedited Worker calls per day // and those expedited calls are also used by the sync service, so it's best to conserve them. diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/WidgetPermissionReceiver.kt b/AnkiDroid/src/main/java/com/ichi2/widget/WidgetPermissionReceiver.kt index 5aa76c49ad5a..cb3d51305edb 100644 --- a/AnkiDroid/src/main/java/com/ichi2/widget/WidgetPermissionReceiver.kt +++ b/AnkiDroid/src/main/java/com/ichi2/widget/WidgetPermissionReceiver.kt @@ -17,18 +17,18 @@ package com.ichi2.widget -import android.content.BroadcastReceiver import android.content.ComponentName import android.content.Context import android.content.Intent import com.ichi2.anki.IntentHandler +import com.ichi2.anki.android.AnkiBroadcastReceiver /** * BroadcastReceiver to handle the scenario where storage permissions are granted, * triggering an update for widgets using the AddNoteWidget class. */ -class WidgetPermissionReceiver : BroadcastReceiver() { - override fun onReceive( +class WidgetPermissionReceiver : AnkiBroadcastReceiver() { + override fun onReceiveBroadcast( context: Context, intent: Intent, ) { diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidgetConfig.kt b/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidgetConfig.kt index 97b43cfca751..29d4378eea95 100644 --- a/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidgetConfig.kt +++ b/AnkiDroid/src/main/java/com/ichi2/widget/cardanalysis/CardAnalysisWidgetConfig.kt @@ -18,7 +18,6 @@ package com.ichi2.widget.cardanalysis import android.appwidget.AppWidgetManager -import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter @@ -26,6 +25,7 @@ import android.os.Bundle import androidx.core.os.BundleCompat import com.ichi2.anki.AnkiActivity import com.ichi2.anki.R +import com.ichi2.anki.android.AnkiBroadcastReceiver import com.ichi2.anki.databinding.ActivityCardAnalysisWidgetConfigBinding import com.ichi2.anki.dialogs.DeckSelectionDialog import com.ichi2.anki.dialogs.DeckSelectionDialog.DeckSelectionListener @@ -196,12 +196,12 @@ class CardAnalysisWidgetConfig : /** BroadcastReceiver to handle widget removal. */ private val widgetRemovedReceiver = - object : BroadcastReceiver() { - override fun onReceive( - context: Context?, - intent: Intent?, + object : AnkiBroadcastReceiver() { + override fun onReceiveBroadcast( + context: Context, + intent: Intent, ) { - if (intent?.action != AppWidgetManager.ACTION_APPWIDGET_DELETED) { + if (intent.action != AppWidgetManager.ACTION_APPWIDGET_DELETED) { return } diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/deckpicker/DeckPickerWidgetConfig.kt b/AnkiDroid/src/main/java/com/ichi2/widget/deckpicker/DeckPickerWidgetConfig.kt index 531b8a8f7fa1..582a7f4530c7 100644 --- a/AnkiDroid/src/main/java/com/ichi2/widget/deckpicker/DeckPickerWidgetConfig.kt +++ b/AnkiDroid/src/main/java/com/ichi2/widget/deckpicker/DeckPickerWidgetConfig.kt @@ -17,7 +17,6 @@ package com.ichi2.widget.deckpicker import android.appwidget.AppWidgetManager -import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter @@ -34,6 +33,7 @@ import androidx.recyclerview.widget.RecyclerView import com.google.android.material.snackbar.Snackbar import com.ichi2.anki.AnkiActivity import com.ichi2.anki.R +import com.ichi2.anki.android.AnkiBroadcastReceiver import com.ichi2.anki.databinding.WidgetDeckPickerConfigBinding import com.ichi2.anki.dialogs.DeckSelectionDialog import com.ichi2.anki.dialogs.DeckSelectionDialog.DeckSelectionListener @@ -440,12 +440,12 @@ class DeckPickerWidgetConfig : /** BroadcastReceiver to handle widget removal. */ private val widgetRemovedReceiver = - object : BroadcastReceiver() { - override fun onReceive( - context: Context?, - intent: Intent?, + object : AnkiBroadcastReceiver() { + override fun onReceiveBroadcast( + context: Context, + intent: Intent, ) { - if (intent?.action != AppWidgetManager.ACTION_APPWIDGET_DELETED) { + if (intent.action != AppWidgetManager.ACTION_APPWIDGET_DELETED) { return } @@ -454,7 +454,7 @@ class DeckPickerWidgetConfig : return } - context?.let { deckPickerWidgetPreferences.deleteDeckData(appWidgetId) } + deckPickerWidgetPreferences.deleteDeckData(appWidgetId) } } From 4a2ccb4d3069b7031a0e6516af950349c81fbdbc Mon Sep 17 00:00:00 2001 From: Manoel Cortes Mendez Date: Wed, 15 Apr 2026 13:48:50 +0200 Subject: [PATCH 09/87] Fix controls: prevent duplicate bindings --- .../preferences/ReviewerControlPreference.kt | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/preferences/ReviewerControlPreference.kt b/AnkiDroid/src/main/java/com/ichi2/preferences/ReviewerControlPreference.kt index d43dea321649..92508ff4371b 100644 --- a/AnkiDroid/src/main/java/com/ichi2/preferences/ReviewerControlPreference.kt +++ b/AnkiDroid/src/main/java/com/ichi2/preferences/ReviewerControlPreference.kt @@ -84,11 +84,13 @@ open class ReviewerControlPreference : ControlPreference { val cardSide = side ?: return super.getPreferenceAssignedTo(binding) val reviewerBinding = ReviewerBinding(binding, cardSide) // Bindings only conflict when the card sides overlap - return getRelatedPreferences().firstOrNull { preference -> - reviewerBinding in preference.getMappableBindings() - } + return getPreferencesAssignedTo(reviewerBinding).firstOrNull() } + @NeedsTest("Ensure correct preferences are returned for side-specific binding") + private fun getPreferencesAssignedTo(binding: ReviewerBinding): List = + getRelatedPreferences().filter { preference -> binding in preference.getMappableBindings() } + fun interface OnBindingSelectedListener { /** * Called when a binding is selected, before the side is set. This allows listeners @@ -125,12 +127,22 @@ open class ReviewerControlPreference : ControlPreference { side: CardSide, ) { val newBinding = ReviewerBinding(binding, side) - getPreferenceAssignedTo(binding)?.removeMappableBinding(newBinding) + // Before adding new binding, remove all conflicting bindings + getPreferencesAssignedTo(newBinding).forEach { preference -> + preference.removeDuplicateBindings(newBinding) + } val bindings = ReviewerBinding.fromPreferenceString(value).toMutableList() bindings.add(newBinding) value = bindings.toPreferenceString() } + @NeedsTest("Check dup removal, including partial side overlap: e.g. QUESTION & BOTH") + private fun removeDuplicateBindings(binding: ReviewerBinding) { + val bindings = ReviewerBinding.fromPreferenceString(value).toMutableList() + bindings.removeAll { it == binding } // Uses overridden .equals() to detect overlaps + value = bindings.toPreferenceString() + } + /** * If this command can be executed on a single side, execute the callback on this side. * Otherwise, ask the user to select one or two side(s) and execute the callback on them. From df3d306901413663a2357ef2e2d97538930c6cd4 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 19 Apr 2026 11:51:38 +0100 Subject: [PATCH 10/87] fix(ci): APK Size Comparison `setup-gradle@v6` now hard-errors when it can't find files to build a hash key. Previously it had nothing to hash, now it hashes the selected PR Fixes 20779 Assisted-by: Claude Opus 4.7 - diagnostics --- .github/workflows/compare_apk_size.yml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/compare_apk_size.yml b/.github/workflows/compare_apk_size.yml index 940a263c2ad0..ec2b1e6d977e 100644 --- a/.github/workflows/compare_apk_size.yml +++ b/.github/workflows/compare_apk_size.yml @@ -42,6 +42,12 @@ jobs: echo y | keytool -genkeypair -dname "cn=AnkiDroid, ou=ankidroid, o=AnkiDroid, c=US" -alias $KEYALIAS -keypass $KEYPWD -keystore $KEYSTOREPATH -storepass $KEYSTOREPWD -keyalg RSA -validity 20000 shell: bash + - name: Checkout PR + uses: actions/checkout@v6 + with: + fetch-depth: 0 + ref: 'refs/pull/${{ github.event.inputs.prNumber }}/head' + - name: Setup Gradle uses: gradle/actions/setup-gradle@v6 timeout-minutes: 5 @@ -50,12 +56,6 @@ jobs: cache-provider: basic cache-read-only: true - - name: Checkout PR - uses: actions/checkout@v6 - with: - fetch-depth: 0 - ref: 'refs/pull/${{ github.event.inputs.prNumber }}/head' - - name: Assemble PR APK # This makes sure we fetch gradle network resources with a retry uses: nick-fields/retry@v4 From c2282e0f2bde6ced5b6744ce3e4fcb8b690378cb Mon Sep 17 00:00:00 2001 From: Alex Date: Mon, 22 Dec 2025 01:34:27 -0500 Subject: [PATCH 11/87] Fix `repairCollection` requiring the database to be opened Changes the method to accept a file instead of a collection object. Obtaining the collection object requires opening the database, which will propagate an exception if the collection file is indeed corrupt, preventing the repair from executing. --- .../src/main/java/com/ichi2/anki/BackupManager.kt | 6 ++---- AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt | 10 +++++----- 2 files changed, 7 insertions(+), 9 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/BackupManager.kt b/AnkiDroid/src/main/java/com/ichi2/anki/BackupManager.kt index 1e0cb40005c9..b48ba7cf538e 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/BackupManager.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/BackupManager.kt @@ -74,12 +74,10 @@ open class BackupManager { * * @return whether the repair was successful */ - fun repairCollection(col: Collection): Boolean { - val colFile = col.colDb + fun repairCollection(colFile: File): Boolean { val colPath = colFile.absolutePath val time = TimeManager.time - Timber.i("BackupManager - RepairCollection - Closing Collection") - col.close() + Timber.i("BackupManager - RepairCollection") // repair file val execString = "sqlite3 $colPath .dump | sqlite3 $colPath.tmp" diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt index fdb45e6f83da..7d025edbf3c3 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt @@ -1749,11 +1749,11 @@ open class DeckPicker : Timber.d("doInBackgroundRepairCollection") val result = withProgress(resources.getString(R.string.backup_repair_deck_progress)) { - withCol { - Timber.i("RepairCollection: Closing collection") - close() - BackupManager.repairCollection(this@withCol) - } + Timber.i("RepairCollection: Closing collection") + CollectionManager.ensureClosed() + val colFile = + CollectionManager.collectionPathInValidFolder().requireDiskBasedCollection().colDb + BackupManager.repairCollection(colFile) } if (!result) { showThemedToast(this@DeckPicker, resources.getString(R.string.deck_repair_error), true) From 232418ded827938686c7aa7fecfbb687aa559e42 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 27 Jan 2026 13:29:28 +0000 Subject: [PATCH 12/87] docs: define 'BrowserColumnKey' Co-authored-by: ShaanNarendran --- .../ichi2/anki/browser/BrowserColumnKey.kt | 44 +++++++++++++++++++ .../anki/browser/BrowserColumnKeyTest.kt | 40 +++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserColumnKey.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/browser/BrowserColumnKeyTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserColumnKey.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserColumnKey.kt new file mode 100644 index 000000000000..318bbf2edf86 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserColumnKey.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2026 David Allison + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.anki.browser + +import android.os.Parcelable +import anki.search.BrowserColumns +import com.ichi2.anki.libanki.Collection +import kotlinx.parcelize.Parcelize + +/** + * The key defining a column in the Card Browser: [BrowserColumns.Column.key] + * + * Example: `noteFld` + * + * @see Collection.getBrowserColumn + * @see Collection.allBrowserColumns + * @see Collection.loadBrowserCardColumns + * @see Collection.loadBrowserNoteColumns + * @see Collection.setBrowserCardColumns + * @see Collection.setBrowserNoteColumns + */ +@JvmInline +@Parcelize +value class BrowserColumnKey( + val value: String, +) : Parcelable { + companion object { + fun from(column: BrowserColumns.Column) = BrowserColumnKey(column.key) + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/browser/BrowserColumnKeyTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/browser/BrowserColumnKeyTest.kt new file mode 100644 index 000000000000..6267a35edd8b --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/browser/BrowserColumnKeyTest.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2026 Shaan Narendran + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.anki.browser + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.testutils.EmptyApplication +import com.ichi2.testutils.JvmTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import kotlin.test.assertEquals +import kotlin.test.assertNotNull + +/** Tests for [BrowserColumnKey] */ +@RunWith(AndroidJUnit4::class) +@Config(application = EmptyApplication::class) +class BrowserColumnKeyTest : JvmTest() { + @Test + fun `browser column key test`() { + val column = assertNotNull(col.getBrowserColumn(("noteFld"))) + + val key = BrowserColumnKey.from(column) + + assertEquals("noteFld", key.value) + } +} From ebf128fcaf72e293cc324f521d43ea254b402633 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 27 Jan 2026 14:02:28 +0000 Subject: [PATCH 13/87] feat(card-browser): prepare to replace 'SortType' * Move 'SortType' to 'LegacySortType' * Define and test the new 'SortType' Prep for issue 17732 --- .../ichi2/anki/browser/CardBrowserFragment.kt | 4 +- .../anki/browser/CardBrowserViewModel.kt | 12 +- .../anki/dialogs/CardBrowserOrderDialog.kt | 4 +- .../java/com/ichi2/anki/model/SortType.kt | 103 ++++++++- .../java/com/ichi2/anki/CardBrowserTest.kt | 10 +- .../anki/browser/CardBrowserViewModelTest.kt | 14 +- .../com/ichi2/anki/browser/SortTypeTest.kt | 207 ++++++++++++++++++ 7 files changed, 328 insertions(+), 26 deletions(-) create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/browser/SortTypeTest.kt 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 f4cc59a3a790..6d446acfaee2 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt @@ -101,8 +101,8 @@ import com.ichi2.anki.libanki.undoAvailable import com.ichi2.anki.libanki.undoLabel import com.ichi2.anki.model.CardStateFilter import com.ichi2.anki.model.CardsOrNotes.CARDS +import com.ichi2.anki.model.LegacySortType import com.ichi2.anki.model.SelectableDeck -import com.ichi2.anki.model.SortType import com.ichi2.anki.observability.ChangeManager import com.ichi2.anki.observability.undoableOp import com.ichi2.anki.requireAnkiActivity @@ -1212,7 +1212,7 @@ class CardBrowserFragment : // TODO: move this into the ViewModel CardBrowserOrderDialog.newInstance { dialog: DialogInterface, which: Int -> dialog.dismiss() - activityViewModel.changeCardOrder(SortType.fromCardBrowserLabelIndex(which)) + activityViewModel.changeCardOrder(LegacySortType.fromCardBrowserLabelIndex(which)) }, ) } 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 25aacb4e495c..16e15f9a4d31 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt @@ -71,8 +71,8 @@ import com.ichi2.anki.model.CardStateFilter import com.ichi2.anki.model.CardsOrNotes import com.ichi2.anki.model.CardsOrNotes.CARDS import com.ichi2.anki.model.CardsOrNotes.NOTES +import com.ichi2.anki.model.LegacySortType import com.ichi2.anki.model.SelectableDeck -import com.ichi2.anki.model.SortType import com.ichi2.anki.observability.ChangeManager import com.ichi2.anki.observability.undoableOp import com.ichi2.anki.pages.CardInfoDestination @@ -193,7 +193,7 @@ class CardBrowserViewModel( val flowOfScrollRequest = MutableSharedFlow() - private val sortTypeFlow = MutableStateFlow(SortType.NO_SORTING) + private val sortTypeFlow = MutableStateFlow(LegacySortType.NO_SORTING) val order get() = sortTypeFlow.value private val reverseDirectionFlow = MutableStateFlow(ReverseDirection(orderAsc = false)) @@ -495,7 +495,7 @@ class CardBrowserViewModel( flowOfCardsOrNotes.update { cardsOrNotes } withCol { - sortTypeFlow.update { SortType.fromCol(config, cardsOrNotes, prefs) } + sortTypeFlow.update { LegacySortType.fromCol(config, cardsOrNotes, prefs) } reverseDirectionFlow.update { ReverseDirection.fromConfig(config) } } Timber.i("initCompleted") @@ -832,12 +832,12 @@ class CardBrowserViewModel( searchRequestFlow.value.filters.decks .isEmpty() - fun changeCardOrder(which: SortType) { + fun changeCardOrder(which: LegacySortType) { val changeType = when { which != order -> ChangeCardOrder.OrderChange(which) // if the same element is selected again, reverse the order - which != SortType.NO_SORTING -> ChangeCardOrder.DirectionChange + which != LegacySortType.NO_SORTING -> ChangeCardOrder.DirectionChange else -> null } ?: return @@ -1433,7 +1433,7 @@ class CardBrowserViewModel( private sealed interface ChangeCardOrder { data class OrderChange( - val sortType: SortType, + val sortType: LegacySortType, ) : ChangeCardOrder data object DirectionChange : ChangeCardOrder diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/CardBrowserOrderDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/CardBrowserOrderDialog.kt index e6c7f78eed74..88dce410c8e2 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/CardBrowserOrderDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/CardBrowserOrderDialog.kt @@ -25,10 +25,10 @@ import com.ichi2.anki.R import com.ichi2.anki.analytics.AnalyticsDialogFragment import com.ichi2.anki.browser.CardBrowserViewModel import com.ichi2.anki.browser.ReverseDirection -import com.ichi2.anki.model.SortType +import com.ichi2.anki.model.LegacySortType /** - * Allows a user to set the [SortType] and [ReverseDirection] + * Allows a user to set the [LegacySortType] and [ReverseDirection] */ class CardBrowserOrderDialog : AnalyticsDialogFragment() { private val viewModel: CardBrowserViewModel by activityViewModels() diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/model/SortType.kt b/AnkiDroid/src/main/java/com/ichi2/anki/model/SortType.kt index 8caddf320c7c..498f3716aebc 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/model/SortType.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/model/SortType.kt @@ -17,13 +17,20 @@ package com.ichi2.anki.model import android.content.SharedPreferences +import android.os.Parcelable import androidx.annotation.VisibleForTesting +import anki.search.BrowserColumns.Column import com.ichi2.anki.CardBrowser +import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.R +import com.ichi2.anki.browser.BrowserColumnKey +import com.ichi2.anki.libanki.BrowserConfig import com.ichi2.anki.libanki.Config import com.ichi2.anki.libanki.SortOrder +import com.ichi2.anki.model.CardsOrNotes.NOTES import com.ichi2.anki.settings.Prefs import com.ichi2.anki.settings.PrefsRepository +import kotlinx.parcelize.Parcelize import timber.log.Timber /** @@ -38,7 +45,7 @@ import timber.log.Timber * @param cardBrowserLabelIndex The index into [R.array.card_browser_order_labels] */ @Suppress("unused") // 'unused' entries are iterated over by .entries -enum class SortType( +enum class LegacySortType( val ankiSortType: String?, val cardBrowserLabelIndex: Int, ) { @@ -68,7 +75,7 @@ enum class SortType( prefs.cardBrowserNoSorting = this == NO_SORTING } - /** Converts the [SortType] to a [SortOrder] */ + /** Converts the [LegacySortType] to a [SortOrder] */ fun toSortOrder(): SortOrder = if (this == NO_SORTING) SortOrder.NoOrdering else SortOrder.UseCollectionOrdering companion object { @@ -76,7 +83,7 @@ enum class SortType( config: Config, cardsOrNotes: CardsOrNotes, prefs: PrefsRepository = Prefs, - ): SortType { + ): LegacySortType { val configKey = if (cardsOrNotes == CardsOrNotes.CARDS) "sortType" else "noteSortType" val colOrder = config.get(configKey) val type = entries.firstOrNull { it.ankiSortType == colOrder } ?: NO_SORTING @@ -86,7 +93,81 @@ enum class SortType( return type } - fun fromCardBrowserLabelIndex(index: Int): SortType = entries.firstOrNull { it.cardBrowserLabelIndex == index } ?: NO_SORTING + fun fromCardBrowserLabelIndex(index: Int): LegacySortType = entries.firstOrNull { it.cardBrowserLabelIndex == index } ?: NO_SORTING + } +} + +/** + * How to sort the rows in the [CardBrowser] + * + * A parcelable subset of the [SortOrders][SortOrder] which AnkiDroid supports + * + * [NoOrdering] is not supported by the upstream browser. + * See: [Prefs.cardBrowserNoSorting] + * + * Other properties are stored in the collection config and synced: + * [getBrowserColumnKey], [getSortBackwards]; [BrowserConfig] + */ +@Parcelize +sealed class SortType : Parcelable { + /** + * @see SortOrder.NoOrdering + */ + data object NoOrdering : SortType() + + /** + * @see SortOrder.UseCollectionOrdering + * @see SortOrder.BuiltinColumnSortKind + */ + data class CollectionOrdering( + val key: BrowserColumnKey, + val reverse: Boolean, + ) : SortType() + + suspend fun save(cardsOrNotes: CardsOrNotes) { + Timber.i("saving %s", this) + + when (this) { + is NoOrdering -> Prefs.cardBrowserNoSorting = true + is CollectionOrdering -> { + val isNotesMode = cardsOrNotes == NOTES + + val sortKey = BrowserConfig.sortColumnKey(isNotesMode) + val reverseKey = BrowserConfig.sortBackwardsKey(isNotesMode) + + withCol { config.set(sortKey, this@SortType.key.value) } + withCol { config.set(reverseKey, this@SortType.reverse) } + + Prefs.cardBrowserNoSorting = false + } + } + } + + companion object { + suspend fun build(cardsOrNotes: CardsOrNotes) = + when (Prefs.cardBrowserNoSorting) { + true -> NoOrdering + false -> resolveColumnOrdering(cardsOrNotes) + } + + private suspend fun resolveColumnOrdering(cardsOrNotes: CardsOrNotes): SortType { + val browserColumnKey = getBrowserColumnKey(cardsOrNotes) + val browserColumn: Column? = withCol { getBrowserColumn(browserColumnKey) } + + return if (browserColumn == null) { + NoOrdering + } else { + val reverse = getSortBackwards(cardsOrNotes) + val key = BrowserColumnKey.from(browserColumn) + CollectionOrdering(key = key, reverse = reverse) + } + } + + fun buildSortOrder(): SortOrder = + when (Prefs.cardBrowserNoSorting) { + true -> SortOrder.NoOrdering + false -> SortOrder.UseCollectionOrdering + } } } @@ -103,3 +184,17 @@ var PrefsRepository.cardBrowserNoSorting: Boolean set(value) { putBoolean(R.string.pref_browser_no_sorting, value) } + +private suspend fun getBrowserColumnKey(cardsOrNotes: CardsOrNotes): String { + val isNotesMode = cardsOrNotes == NOTES + val sortKey = BrowserConfig.sortColumnKey(isNotesMode) + + return withCol { config.get(sortKey) } ?: "noteFld" +} + +private suspend fun getSortBackwards(cardsOrNotes: CardsOrNotes): Boolean { + val isNotesMode = cardsOrNotes == NOTES + val sortBackwardsKey = BrowserConfig.sortBackwardsKey(isNotesMode) + + return withCol { config.get(sortBackwardsKey) } ?: false +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt index c7a99757d3b4..be43059ab2dd 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt @@ -90,8 +90,8 @@ import com.ichi2.anki.libanki.QueueType import com.ichi2.anki.libanki.testutils.AnkiTest import com.ichi2.anki.model.CardsOrNotes.CARDS import com.ichi2.anki.model.CardsOrNotes.NOTES +import com.ichi2.anki.model.LegacySortType import com.ichi2.anki.model.SelectableDeck -import com.ichi2.anki.model.SortType import com.ichi2.anki.scheduling.ForgetCardsDialog import com.ichi2.anki.servicelayer.PreferenceUpgradeService import com.ichi2.anki.servicelayer.PreferenceUpgradeService.PreferenceUpgrade.UpgradeBrowserColumns.Companion.LEGACY_COLUMN1_KEYS @@ -523,7 +523,7 @@ class CardBrowserTest : RobolectricTest() { ) // reverse - b.viewModel.changeCardOrder(SortType.SORT_FIELD) + b.viewModel.changeCardOrder(LegacySortType.SORT_FIELD) b.replaceSelectionWith(intArrayOf(0)) val intentAfterReverse = b.viewModel.queryPreviewIntentData() @@ -822,7 +822,7 @@ class CardBrowserTest : RobolectricTest() { ) // Change the display order of the card browser - cardBrowserController.get().viewModel.changeCardOrder(SortType.EASE) + cardBrowserController.get().viewModel.changeCardOrder(LegacySortType.EASE) // Kill and restart the activity and ensure that display order is preserved val outBundle = Bundle() @@ -907,9 +907,9 @@ class CardBrowserTest : RobolectricTest() { @Test fun checkDisplayOrderAfterTogglingCardsToNotes() = withBrowser { - viewModel.changeCardOrder(SortType.EASE) // order no. 7 corresponds to "cardEase" + viewModel.changeCardOrder(LegacySortType.EASE) // order no. 7 corresponds to "cardEase" - viewModel.changeCardOrder(SortType.EASE) // reverse the list + viewModel.changeCardOrder(LegacySortType.EASE) // reverse the list viewModel.setCardsOrNotes(NOTES) searchCards() 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 23ce1859b007..aad690c215ea 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserViewModelTest.kt @@ -73,10 +73,10 @@ import com.ichi2.anki.libanki.QueueType.New import com.ichi2.anki.libanki.testutils.AnkiTest import com.ichi2.anki.model.CardStateFilter import com.ichi2.anki.model.CardsOrNotes +import com.ichi2.anki.model.LegacySortType +import com.ichi2.anki.model.LegacySortType.NO_SORTING +import com.ichi2.anki.model.LegacySortType.SORT_FIELD import com.ichi2.anki.model.SelectableDeck -import com.ichi2.anki.model.SortType -import com.ichi2.anki.model.SortType.NO_SORTING -import com.ichi2.anki.model.SortType.SORT_FIELD import com.ichi2.anki.servicelayer.NoteService import com.ichi2.anki.setFlagFilterSync import com.ichi2.anki.settings.Prefs @@ -610,17 +610,17 @@ class CardBrowserViewModelTest : JvmTest() { assertThat("initial direction", !orderAsc) // changing the order performs a search & changes order - changeCardOrder(SortType.EASE) + changeCardOrder(LegacySortType.EASE) expectMostRecentItem() - assertThat("order changed", order, equalTo(SortType.EASE)) + assertThat("order changed", order, equalTo(LegacySortType.EASE)) assertThat("changed direction is the default", !orderAsc) waitForSearchResults() // pressing 'ease' again changes direction - changeCardOrder(SortType.EASE) + changeCardOrder(LegacySortType.EASE) expectMostRecentItem() - assertThat("order unchanged", order, equalTo(SortType.EASE)) + assertThat("order unchanged", order, equalTo(LegacySortType.EASE)) assertThat("direction is changed", orderAsc) } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/browser/SortTypeTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/browser/SortTypeTest.kt new file mode 100644 index 000000000000..577131903f3b --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/browser/SortTypeTest.kt @@ -0,0 +1,207 @@ +/* + * Copyright (c) 2026 David Allison + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.anki.browser + +import androidx.core.content.edit +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.libanki.SortOrder +import com.ichi2.anki.model.CardsOrNotes +import com.ichi2.anki.model.SortType +import com.ichi2.anki.model.SortType.CollectionOrdering +import com.ichi2.anki.model.SortType.NoOrdering +import com.ichi2.anki.model.cardBrowserNoSorting +import com.ichi2.anki.settings.Prefs +import org.junit.Before +import org.junit.Ignore +import org.junit.Test +import org.junit.jupiter.api.assertInstanceOf +import org.junit.runner.RunWith +import kotlin.test.assertEquals + +@RunWith(AndroidJUnit4::class) +class SortTypeTest : RobolectricTest() { + @Before + override fun setUp() { + super.setUp() + Prefs.sharedPrefs.edit { remove("cardBrowserNoSorting") } + } + + @Test + fun `default sort type - CARDS`() = + runTest { + val defaultCards = SortType.build(cardsOrNotes = CardsOrNotes.CARDS) + + val ordering = assertInstanceOf(defaultCards) + + assertEquals("noteFld", ordering.key.value) + assertEquals(false, ordering.reverse) + } + + @Test + fun `default sort type - NOTES`() = + runTest { + val defaultNotes = SortType.build(cardsOrNotes = CardsOrNotes.NOTES) + + val ordering = assertInstanceOf(defaultNotes) + + assertEquals("noteFld", ordering.key.value) + assertEquals(false, ordering.reverse) + } + + @Test + fun `build produces no ordering when column is corrupt`() = + runTest { + col.config.set("noteSortType", "blah") + + val result = SortType.build(cardsOrNotes = CardsOrNotes.NOTES) + + assertInstanceOf(result) + } + + @Test + fun `cardBrowserNoSorting controls build type`() = + runTest { + Prefs.cardBrowserNoSorting = true + + SortType.build(cardsOrNotes = CardsOrNotes.NOTES).apply { + assertInstanceOf(this) + } + + Prefs.cardBrowserNoSorting = false + + SortType.build(cardsOrNotes = CardsOrNotes.NOTES).apply { + assertInstanceOf(this) + } + } + + @Test + fun `collection controls build() results - NOTES`() = + runTest { + col.config.set("noteSortType", "noteTags") + col.config.set("browserNoteSortBackwards", true) + + val sortType = SortType.build(cardsOrNotes = CardsOrNotes.NOTES) + val collectionSortType = assertInstanceOf(sortType) + + assertEquals("noteTags", collectionSortType.key.value) + assertEquals(true, collectionSortType.reverse) + } + + @Test + fun `collection controls build() results - CARDS`() = + runTest { + col.config.set("sortType", "noteTags") + col.config.set("sortBackwards", true) + + val sortType = SortType.build(cardsOrNotes = CardsOrNotes.CARDS) + val collectionSortType = assertInstanceOf(sortType) + + assertEquals("noteTags", collectionSortType.key.value) + assertEquals(true, collectionSortType.reverse) + } + + @Test + fun `test buildSortOrder`() = + runTest { + NoOrdering.save(CardsOrNotes.NOTES) + assertInstanceOf(SortType.buildSortOrder()) + + CollectionOrdering(BrowserColumnKey("noteTags"), true).save(CardsOrNotes.CARDS) + assertInstanceOf(SortType.buildSortOrder()) + + NoOrdering.save(CardsOrNotes.CARDS) + assertInstanceOf(SortType.buildSortOrder()) + + CollectionOrdering(BrowserColumnKey("noteTags"), true).save(CardsOrNotes.NOTES) + assertInstanceOf(SortType.buildSortOrder()) + } + + @Test + fun `save no ordering - CARDS`() = + runTest { + NoOrdering.save(CardsOrNotes.CARDS) + + val sortType = SortType.build(cardsOrNotes = CardsOrNotes.CARDS) + assertInstanceOf(sortType) + } + + @Test + fun `save no ordering - NOTES`() = + runTest { + NoOrdering.save(CardsOrNotes.NOTES) + + val sortType = SortType.build(cardsOrNotes = CardsOrNotes.NOTES) + assertInstanceOf(sortType) + } + + @Test + fun `save collection - CARDS`() = + runTest { + CollectionOrdering(BrowserColumnKey("noteTags"), true).save(CardsOrNotes.NOTES) + + val notesSortType = SortType.build(cardsOrNotes = CardsOrNotes.NOTES) + assertInstanceOf(notesSortType).apply { + assertEquals("noteTags", key.value, "notes - key") + assertEquals(true, reverse, "notes - reverse") + } + } + + @Test + fun `save collection - NOTES`() = + runTest { + CollectionOrdering(BrowserColumnKey("noteCrt"), false).save(CardsOrNotes.CARDS) + + val cardsSortType = SortType.build(cardsOrNotes = CardsOrNotes.CARDS) + assertInstanceOf(cardsSortType).apply { + assertEquals("noteCrt", key.value, "cards - key") + assertEquals(false, reverse, "cards - reverse") + } + } + + @Test + fun `save different column - NOTES and CARDS`() = + runTest { + CollectionOrdering(BrowserColumnKey("noteTags"), true).save(CardsOrNotes.NOTES) + CollectionOrdering(BrowserColumnKey("noteCrt"), false).save(CardsOrNotes.CARDS) + + val notesSortType = SortType.build(cardsOrNotes = CardsOrNotes.NOTES) + assertInstanceOf(notesSortType).apply { + assertEquals("noteTags", key.value, "notes - key") + assertEquals(true, reverse, "notes - reverse") + } + + val cardsSortType = SortType.build(cardsOrNotes = CardsOrNotes.CARDS) + assertInstanceOf(cardsSortType).apply { + assertEquals("noteCrt", key.value, "cards - key") + assertEquals(false, reverse, "cards - reverse") + } + } + + @Test + @Ignore("TODO: NoOrdering should not affect both") + fun `save types different - NOTES and CARDS`() = + runTest { + NoOrdering.save(CardsOrNotes.NOTES) + + val notesSortType = SortType.build(cardsOrNotes = CardsOrNotes.NOTES) + assertInstanceOf(notesSortType) + + val cardsSortType = SortType.build(cardsOrNotes = CardsOrNotes.CARDS) + assertInstanceOf(cardsSortType) + } +} From 2295a1540402c8cc733f99cd57e698d83b9cb847 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 27 Jan 2026 14:26:30 +0000 Subject: [PATCH 14/87] feat(card-browser): support setting new SortType in ViewModel Prep for issue 17732 --- .../anki/browser/CardBrowserViewModel.kt | 19 ++++++ .../java/com/ichi2/anki/model/SortType.kt | 13 ++++ .../anki/browser/CardBrowserViewModelTest.kt | 62 +++++++++++++++++++ 3 files changed, 94 insertions(+) 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 16e15f9a4d31..314b170b37ea 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt @@ -73,6 +73,7 @@ import com.ichi2.anki.model.CardsOrNotes.CARDS import com.ichi2.anki.model.CardsOrNotes.NOTES import com.ichi2.anki.model.LegacySortType import com.ichi2.anki.model.SelectableDeck +import com.ichi2.anki.model.SortType import com.ichi2.anki.observability.ChangeManager import com.ichi2.anki.observability.undoableOp import com.ichi2.anki.pages.CardInfoDestination @@ -832,6 +833,24 @@ class CardBrowserViewModel( searchRequestFlow.value.filters.decks .isEmpty() + /** + * Updates the [SortType] and updates the search results + */ + fun setSortType(sortType: SortType) = + viewModelScope.launch { + Timber.i("setting sort type: %s", sortType) + + // Temporarily update legacy flows + sortTypeFlow.update { sortType.toLegacy() } + sortType.toLegacyReverse()?.let { newValue -> + reverseDirectionFlow.update { newValue } + } + + sortType.save(cardsOrNotes) + + launchSearchForCards() + } + fun changeCardOrder(which: LegacySortType) { val changeType = when { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/model/SortType.kt b/AnkiDroid/src/main/java/com/ichi2/anki/model/SortType.kt index 498f3716aebc..03388200abbc 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/model/SortType.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/model/SortType.kt @@ -24,6 +24,7 @@ import com.ichi2.anki.CardBrowser import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.R import com.ichi2.anki.browser.BrowserColumnKey +import com.ichi2.anki.browser.ReverseDirection import com.ichi2.anki.libanki.BrowserConfig import com.ichi2.anki.libanki.Config import com.ichi2.anki.libanki.SortOrder @@ -143,6 +144,18 @@ sealed class SortType : Parcelable { } } + fun toLegacy(): LegacySortType = + when (this) { + is NoOrdering -> LegacySortType.NO_SORTING + is CollectionOrdering -> LegacySortType.entries.firstOrNull { it.ankiSortType == this.key.value } ?: LegacySortType.NO_SORTING + } + + fun toLegacyReverse(): ReverseDirection? = + when (this) { + is NoOrdering -> null + is CollectionOrdering -> ReverseDirection(orderAsc = this.reverse) + } + companion object { suspend fun build(cardsOrNotes: CardsOrNotes) = when (Prefs.cardBrowserNoSorting) { 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 aad690c215ea..ba9ee3dba544 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserViewModelTest.kt @@ -77,6 +77,7 @@ import com.ichi2.anki.model.LegacySortType import com.ichi2.anki.model.LegacySortType.NO_SORTING import com.ichi2.anki.model.LegacySortType.SORT_FIELD import com.ichi2.anki.model.SelectableDeck +import com.ichi2.anki.model.SortType import com.ichi2.anki.servicelayer.NoteService import com.ichi2.anki.setFlagFilterSync import com.ichi2.anki.settings.Prefs @@ -1531,6 +1532,67 @@ class CardBrowserViewModelTest : JvmTest() { } } + @Test + fun `updating sort type launches search`() = + runViewModelTest { + flowOfSearchState.test { + expectNoEvents() + + setSortType(SortType.NoOrdering) + + expectMostRecentItem() + } + } + + @Test + fun `updating sort type updates flows - no ordering`() = + runViewModelTest { + assertEquals(LegacySortType.SORT_FIELD, order) + + setSortType(SortType.NoOrdering) + + assertEquals(LegacySortType.NO_SORTING, order) + } + + @Test + fun `updating sort type updates flows - known column`() = + runViewModelTest { + assertEquals(LegacySortType.SORT_FIELD, order) + + setSortType(SortType.CollectionOrdering(BrowserColumnKey("cardDue"), true)) + + assertEquals(LegacySortType.DUE_TIME, order) + } + + @Test + fun `updating sort type updates order`() = + runViewModelTest { + assertEquals(false, orderAsc) + + setSortType(SortType.CollectionOrdering(BrowserColumnKey("cardDue"), true)) + + assertEquals(true, orderAsc) + } + + @Test + fun `sort type integration test`() { + val firstId = addBasicNote("a").firstCard().id + addBasicNote("b") + val lastId = addBasicNote("c").firstCard().id + + runViewModelTest { + assertEquals(firstId, this.cards[0].cardOrNoteId) + + setSortType(SortType.CollectionOrdering(BrowserColumnKey("noteFld"), reverse = true)) + + assertEquals(lastId, this.cards[0].cardOrNoteId) + + setSortType(SortType.CollectionOrdering(BrowserColumnKey("noteFld"), reverse = false)) + + assertEquals(firstId, this.cards[0].cardOrNoteId) + } + } + private fun assertDate(str: String?) { // 2025-01-09 @ 18:06 assertNotNull(str) From 130f3d2a74e6607dd118aa8beb2a284f1057f27f Mon Sep 17 00:00:00 2001 From: Pankaj Gupta Date: Sun, 22 Feb 2026 16:33:55 +0530 Subject: [PATCH 15/87] fix: keep deck selection dialog open for multi-select in widget config update --- .../ichi2/anki/dialogs/DeckSelectionDialog.kt | 33 +++++++++-- .../deckpicker/DeckPickerWidgetConfig.kt | 41 +++++++------ .../anki/dialogs/DeckSelectionDialogTest.kt | 58 +++++++++++++++++++ 3 files changed, 109 insertions(+), 23 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckSelectionDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckSelectionDialog.kt index 6feeceb64421..2121a7dfeceb 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckSelectionDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckSelectionDialog.kt @@ -77,6 +77,7 @@ import java.util.Locale open class DeckSelectionDialog : AnalyticsDialogFragment() { private lateinit var binding: DialogDeckPickerBinding private var dialog: AlertDialog? = null + private lateinit var decksAdapter: DecksArrayAdapter private lateinit var expandImage: Drawable private lateinit var collapseImage: Drawable private lateinit var decksRoot: DeckNode @@ -113,9 +114,9 @@ open class DeckSelectionDialog : AnalyticsDialogFragment() { val dividerItemDecoration = DividerItemDecoration(binding.list.context, DividerItemDecoration.VERTICAL) binding.list.addItemDecoration(dividerItemDecoration) val decks: List = getDeckNames(arguments) - val adapter = DecksArrayAdapter(decks) - binding.list.adapter = adapter - adjustToolbar(binding.root, adapter) + decksAdapter = DecksArrayAdapter(decks) + binding.list.adapter = decksAdapter + adjustToolbar(binding.root, decksAdapter) dialog = AlertDialog.Builder(requireActivity()).create { negativeButton(R.string.dialog_cancel) @@ -239,13 +240,27 @@ open class DeckSelectionDialog : AnalyticsDialogFragment() { var deckCreationListener: DeckCreationListener? = null + private val isMultiSelect: Boolean + get() = requireArguments().getBoolean(MULTI_SELECT) + /** * Same action as pressing on the deck in the list. I.e. send the deck to listener and close the dialog. + * When [isMultiSelect] is true, the dialog stays open and removes the selected deck from the list. */ protected fun selectDeckAndClose(deck: SelectableDeck) { Timber.d("selected deck '%s'", deck) onDeckSelected(deck) - dialog!!.dismiss() + if (isMultiSelect) { + if (deck is SelectableDeck.Deck) { + decksAdapter.removeDeck(deck.deckId) + } + // dismiss dialog when all decks have been selected + if (decksAdapter.itemCount == 0) { + dialog!!.dismiss() + } + } else { + dialog!!.dismiss() + } } protected fun displayErrorAndCancel() { @@ -308,6 +323,13 @@ open class DeckSelectionDialog : AnalyticsDialogFragment() { notifyDataSetChanged() } + fun removeDeck(deckId: DeckId) { + val idsToRemove = mutableSetOf(deckId) + decksRoot.find(deckId)?.forEach { idsToRemove.add(it.did) } + allDecksList.removeAll { it.did in idsToRemove } + updateCurrentlyDisplayedDecks() + } + private val allDecksList = ArrayList() private val currentlyDisplayedDecks = ArrayList() @@ -437,6 +459,7 @@ open class DeckSelectionDialog : AnalyticsDialogFragment() { private const val TITLE = "title" private const val KEEP_RESTORE_DEFAULT_BUTTON = "keepRestoreDefaultButton" private const val DECK_NAMES = "deckNames" + private const val MULTI_SELECT = "multiSelect" /** * A dialog which handles selecting a deck @@ -446,6 +469,7 @@ open class DeckSelectionDialog : AnalyticsDialogFragment() { summaryMessage: String?, keepRestoreDefaultButton: Boolean, decks: List, + isMultiSelect: Boolean = false, ): DeckSelectionDialog { val f = DeckSelectionDialog() val args = Bundle() @@ -453,6 +477,7 @@ open class DeckSelectionDialog : AnalyticsDialogFragment() { args.putString(TITLE, title) args.putBoolean(KEEP_RESTORE_DEFAULT_BUTTON, keepRestoreDefaultButton) args.putParcelableArrayList(DECK_NAMES, ArrayList(decks)) + args.putBoolean(MULTI_SELECT, isMultiSelect) f.arguments = args return f } diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/deckpicker/DeckPickerWidgetConfig.kt b/AnkiDroid/src/main/java/com/ichi2/widget/deckpicker/DeckPickerWidgetConfig.kt index 582a7f4530c7..5505090be98c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/widget/deckpicker/DeckPickerWidgetConfig.kt +++ b/AnkiDroid/src/main/java/com/ichi2/widget/deckpicker/DeckPickerWidgetConfig.kt @@ -154,8 +154,6 @@ class DeckPickerWidgetConfig : setupDoneButton() - // TODO: Implement multi-select functionality so that user can select desired decks in once. - // TODO: Implement a functionality to hide already selected deck. binding.fabWidgetDeckPicker.setOnClickListener { showDeckSelectionDialog() } @@ -302,10 +300,10 @@ class DeckPickerWidgetConfig : } } - /** Asynchronously displays the list of deck in the selection dialog. */ + /** Displays the deck selection dialog, filtering out already-selected decks. */ private fun showDeckSelectionDialog() { lifecycleScope.launch { - val decks = fetchDecks() + val decks = fetchDecks().filter { it.deckId !in deckAdapter.deckIds } displayDeckSelectionDialog(decks) } } @@ -324,8 +322,13 @@ class DeckPickerWidgetConfig : summaryMessage = null, keepRestoreDefaultButton = false, decks = decks, + isMultiSelect = true, ) - dialog.show(supportFragmentManager, "DeckSelectionDialog") + dialog.show(supportFragmentManager, DECK_SELECTION_DIALOG_TAG) + } + + private fun dismissDeckSelectionDialog() { + (supportFragmentManager.findFragmentByTag(DECK_SELECTION_DIALOG_TAG) as? DeckSelectionDialog)?.dismiss() } /** Called when a deck is selected from the deck selection dialog. */ @@ -338,7 +341,6 @@ class DeckPickerWidgetConfig : val isDeckAlreadySelected = deckAdapter.deckIds.contains(deck.deckId) if (isDeckAlreadySelected) { - // TODO: Eventually, ensure that the user can't select a deck that is already selected. showSnackbar(getString(R.string.deck_already_selected_message)) return } @@ -350,19 +352,19 @@ class DeckPickerWidgetConfig : showSnackbar(resources.getQuantityString(R.plurals.deck_limit_reached, MAX_DECKS_ALLOWED, MAX_DECKS_ALLOWED)) } // The FAB visibility should be handled in updateFabVisibility() - } else { - // Add the deck and update views - deckAdapter.addDeck(deck) - updateViewVisibility() - updateFabVisibility() - setupDoneButton() - hasUnsavedChanges = true - setUnsavedChanges(true) - - // Show snackbar if the deck is the 5th deck - if (deckAdapter.itemCount == MAX_DECKS_ALLOWED) { - showSnackbar(resources.getQuantityString(R.plurals.deck_limit_reached, MAX_DECKS_ALLOWED, MAX_DECKS_ALLOWED)) - } + return + } + // Add the deck and update views + deckAdapter.addDeck(deck) + updateViewVisibility() + updateFabVisibility() + setupDoneButton() + hasUnsavedChanges = true + setUnsavedChanges(true) + // Show snackbar and dismiss the selection dialog once the 5th deck is reached + if (deckAdapter.itemCount == MAX_DECKS_ALLOWED) { + showSnackbar(resources.getQuantityString(R.plurals.deck_limit_reached, MAX_DECKS_ALLOWED, MAX_DECKS_ALLOWED)) + dismissDeckSelectionDialog() } } @@ -463,5 +465,6 @@ class DeckPickerWidgetConfig : * Maximum number of decks allowed in the widget. */ private const val MAX_DECKS_ALLOWED = 5 + private const val DECK_SELECTION_DIALOG_TAG = "DeckSelectionDialog" } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/DeckSelectionDialogTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/DeckSelectionDialogTest.kt index 633d93eee6ad..df5c161283b1 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/DeckSelectionDialogTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/DeckSelectionDialogTest.kt @@ -16,12 +16,17 @@ package com.ichi2.anki.dialogs +import anki.decks.deckTreeNode import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.libanki.sched.DeckNode import com.ichi2.anki.model.SelectableDeck import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertNotNull import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers +import org.hamcrest.Matchers.containsInAnyOrder +import org.hamcrest.Matchers.hasItem +import org.hamcrest.Matchers.not import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -51,4 +56,57 @@ class DeckSelectionDialogTest : RobolectricTest() { assertEquals(dialogTitle, dialog.arguments?.getString("title")) assertEquals(summaryMessage, dialog.arguments?.getString("summaryMessage")) } + + @Test + fun `selecting child deck does not collect parent or sibling ids`() { + // Build tree: Parent (id=1) -> Child1 (id=2), Child2 (id=3) + val child1 = makeNode("Child1", deckId = 2, level = 2) + val child2 = makeNode("Child2", deckId = 3, level = 2) + val parent = makeNode("Parent", deckId = 1, level = 1, children = listOf(child1, child2)) + + val selectedChild = parent.find(2L)!! + val idsToRemove = mutableSetOf(selectedChild.did) + selectedChild.forEach { idsToRemove.add(it.did) } + + assertThat(idsToRemove, containsInAnyOrder(2L)) + assertThat(idsToRemove, not(hasItem(1L))) + assertThat(idsToRemove, not(hasItem(3L))) + } + + @Test + fun `selecting parent deck with nested children collects all descendants`() { + // Build tree: A (id=1) -> B (id=2) -> C (id=3) + // -> D (id=4) + val c = makeNode("C", deckId = 3, level = 3) + val b = makeNode("B", deckId = 2, level = 2, children = listOf(c)) + val d = makeNode("D", deckId = 4, level = 2) + val a = makeNode("A", deckId = 1, level = 1, children = listOf(b, d)) + + val idsToRemove = mutableSetOf(a.did) + a.forEach { idsToRemove.add(it.did) } + + assertThat(idsToRemove, containsInAnyOrder(1L, 2L, 3L, 4L)) + } + + private fun makeNode( + name: String, + deckId: Long, + level: Int, + collapsed: Boolean = false, + children: List = emptyList(), + ): DeckNode { + val treeNode = + deckTreeNode { + this.name = name + this.deckId = deckId + this.level = level + this.collapsed = collapsed + children.forEach { this.children.add(it.node) } + this.reviewCount = 0 + this.newCount = 0 + this.learnCount = 0 + this.filtered = false + } + return DeckNode(treeNode, name) + } } From 6b9043b97f76ce4905b1abe46996d3d8faf9e1a8 Mon Sep 17 00:00:00 2001 From: KT-1114 Date: Fri, 27 Mar 2026 03:29:56 +0530 Subject: [PATCH 16/87] fix(NoteEditor): move all sibling cards when changing deck in Note mode --- .../com/ichi2/anki/AbstractFlashcardViewer.kt | 2 +- .../main/java/com/ichi2/anki/CardBrowser.kt | 46 +++++++++---------- .../java/com/ichi2/anki/NoteEditorFragment.kt | 32 ++++++++++++- .../anki/browser/CardBrowserViewModel.kt | 22 +++++++++ .../anki/noteeditor/NoteEditorLauncher.kt | 10 ++-- 5 files changed, 83 insertions(+), 29 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt index 0de5b64f3d18..08f89f2c310f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt @@ -799,7 +799,7 @@ abstract class AbstractFlashcardViewer : } val animation = fromGesture.toAnimationTransition().invert() Timber.i("Launching 'edit card'") - val editCardIntent = NoteEditorLauncher.EditSelection(currentCard!!.id, animation).toIntent(this) + val editCardIntent = NoteEditorLauncher.EditSelection(listOf(currentCard!!.id), animation).toIntent(this) editCurrentCardLauncher.launch(editCardIntent) } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt index 0703cdebceec..1210ed1ed616 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardBrowser.kt @@ -123,26 +123,24 @@ open class CardBrowser : get() = createAddNoteLauncher(viewModel) /** - * Provides an instance of NoteEditorLauncher for editing a note + * Provides an instance of NoteEditorLauncher for editing the current selection. */ - private val editNoteLauncher: NoteEditorLauncher? - get() { - val cardId = viewModel.currentCardId - if (cardId == null) { - Timber.w("EditSelection skipped: no card selected") - return null - } + private suspend fun editNoteLauncher(): NoteEditorLauncher? { + val cardIds = viewModel.getCardIdsForNoteEditor() - return NoteEditorLauncher - .EditSelection( - cardId, - Direction.DEFAULT, - fragmented, - ).also { - Timber.i("editNoteLauncher: %s", it) - } + if (cardIds.isEmpty()) { + Timber.w("EditSelection skipped: card list is empty") + return null } + return NoteEditorLauncher + .EditSelection( + cardIds = cardIds, + animation = Direction.DEFAULT, + inCardBrowserActivity = fragmented, + ) + } + override fun onDeckSelected(deck: SelectableDeck?) { deck?.let { deck -> launchCatchingTask { viewModel.setSelectedDeck(deck) } } } @@ -519,14 +517,14 @@ open class CardBrowser : /** * Loads the NoteEditor fragment in container if the view is x-large. */ - fun loadNoteEditorFragmentIfFragmented() { + suspend fun loadNoteEditorFragmentIfFragmented() { if (!fragmented) { return } // Show note editor frame binding.noteEditorFrame!!.isVisible = true - val launcher = editNoteLauncher ?: return + val launcher = editNoteLauncher() ?: return // If there are unsaved changes in NoteEditor then show dialog for confirmation if (fragment?.hasUnsavedChanges() == true) { showSaveChangesDialog(launcher) @@ -601,11 +599,13 @@ open class CardBrowser : } fun onSelectedCardUpdated(unit: Unit) { - if (fragmented) { - loadNoteEditorFragmentIfFragmented() - } else { - editNoteLauncher?.let { - onEditCardActivityResult.launch(it.toIntent(this)) + launchCatchingTask { + if (fragmented) { + loadNoteEditorFragmentIfFragmented() + } else { + editNoteLauncher()?.let { + onEditCardActivityResult.launch(it.toIntent(this@CardBrowser)) + } } } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt index 420130261334..d968c1df14e2 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt @@ -104,6 +104,7 @@ import com.ichi2.anki.dialogs.tags.TagsDialogListener import com.ichi2.anki.exception.MediaSizeLimitExceededException import com.ichi2.anki.exception.toBytesShortString import com.ichi2.anki.libanki.Card +import com.ichi2.anki.libanki.CardId import com.ichi2.anki.libanki.CardOrdinal import com.ichi2.anki.libanki.Collection import com.ichi2.anki.libanki.Consts @@ -306,6 +307,10 @@ class NoteEditorFragment : private val noteEditorActivity get() = requireAnkiActivity() as? NoteEditorActivity + // List of affected cards (siblings) when editing notes. + private val cardIdsFromArguments: LongArray? + get() = arguments?.getLongArray(EXTRA_CARD_IDS) + /** * Whether this is displayed in a fragment view. * If true, this fragment is on the trailing side of the card browser. @@ -1379,7 +1384,10 @@ class NoteEditorFragment : // changed did? this has to be done first as remFromDyn() involves a direct write to the database if (currentEditedCard != null && currentEditedCard!!.currentDeckId() != deckId) { reloadRequired = true - undoableOp { setDeck(listOf(currentEditedCard!!.id), deckId) } + + val cardIdsToMove = getAffectedCards() + undoableOp { setDeck(cardIdsToMove, deckId) } + // refresh the card object to reflect the database changes from above currentEditedCard!!.load(getColUnsafe) // also reload the note object @@ -1387,7 +1395,8 @@ class NoteEditorFragment : // then set the card ID to the new deck currentEditedCard!!.did = deckId modified = true - Timber.d("deck ID updated to '%d'", deckId) + + Timber.d("deck ID updated to '%d' for %d card(s) of note %d", deckId, cardIdsToMove.size, editorNote!!.id) } // now load any changes to the fields from the form for (f in editFields!!) { @@ -1425,6 +1434,24 @@ class NoteEditorFragment : delegate?.onNoteSaved() } + /** + * Returns the list of Card IDs that should be affected by bulk operations. + * For example deck changes. + * + * This list is determined by the caller (e.g., Card Browser) and passed + * via arguments. + * + * When the list is not provided, we use the current card. + */ + private fun getAffectedCards(): List { + val ids = cardIdsFromArguments + return if (ids != null && ids.isNotEmpty()) { + ids.toList() + } else { + listOf(currentEditedCard!!.id) + } + } + private fun closeNoteEditorAfterSave() { isFieldEdited = false isTagsEdited = false @@ -2955,6 +2982,7 @@ class NoteEditorFragment : const val RELOAD_REQUIRED_EXTRA_KEY = "reloadRequired" const val EXTRA_IMG_OCCLUSION = "image_uri" const val IN_CARD_BROWSER_ACTIVITY = "inCardBrowserActivity" + const val EXTRA_CARD_IDS = "EXTRA_CARD_IDS" // calling activity enum class NoteEditorCaller( 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 314b170b37ea..5baf163460d2 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt @@ -305,6 +305,28 @@ class CardBrowserViewModel( suspend fun queryAllSelectedNoteIds() = selectedRows.queryNoteIds(this.cardsOrNotes) + /** + * Returns the list of Card IDs that should be updated. + * + * In 'Notes' mode, this includes all cards of the current note (sibling cards). + * In 'Cards' mode, this returns only the selected cards. + */ + suspend fun getCardIdsForNoteEditor(): List { + val cardId = currentCardId ?: return emptyList() + + return if (cardsOrNotes == NOTES) { + withCol { + getCard(cardId).note(this).cardIds(this) + } + } else { + if (isInMultiSelectMode) { + queryAllSelectedCardIds() + } else { + listOf(cardId) + } + } + } + fun requestChangeNoteType() = viewModelScope.launch { val noteIds = queryAllSelectedNoteIds() diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/NoteEditorLauncher.kt b/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/NoteEditorLauncher.kt index 84586a6a3734..d5aac03bcaca 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/NoteEditorLauncher.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/NoteEditorLauncher.kt @@ -165,18 +165,22 @@ sealed interface NoteEditorLauncher : Destination { /** * Opens the NoteEditor for the current selection (card or note). - * @property cardId The selected card ID, or null when editing a note. + * @property cardIds The selected card ID when editing a card, or the IDs of cards of the same note when editing a note. * @property animation The animation direction. + * @property inCardBrowserActivity True if opened within Card Browser Activity. */ data class EditSelection( - val cardId: CardId?, + val cardIds: List, val animation: ActivityTransitionAnimation.Direction, val inCardBrowserActivity: Boolean = false, ) : NoteEditorLauncher { override fun toBundle(): Bundle = bundleOf( NoteEditorFragment.EXTRA_CALLER to NoteEditorCaller.EDIT.value, - NoteEditorFragment.EXTRA_CARD_ID to cardId, + // To handle single card selection + NoteEditorFragment.EXTRA_CARD_ID to cardIds.first(), + // To handle multi select and note edit + NoteEditorFragment.EXTRA_CARD_IDS to cardIds.toLongArray(), AnkiActivity.FINISH_ANIMATION_EXTRA to animation as Parcelable, NoteEditorFragment.IN_CARD_BROWSER_ACTIVITY to inCardBrowserActivity, ) From 462a75993d6b526052e297bd644eeb6adb742b47 Mon Sep 17 00:00:00 2001 From: KT-1114 Date: Mon, 30 Mar 2026 16:02:16 +0530 Subject: [PATCH 17/87] test: add test for moving all cards of a note to a new deck --- .../java/com/ichi2/anki/NoteEditorTest.kt | 62 ++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/NoteEditorTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/NoteEditorTest.kt index 4a4d727990aa..b9dc45324bec 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/NoteEditorTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/NoteEditorTest.kt @@ -663,6 +663,66 @@ class NoteEditorTest : RobolectricTest() { assertFalse(hasUnsavedChanges()) } + @Test + fun `changing deck with multiple card ids moves all sibling cards`() = + runTest { + // Create a note with 2 cards (Basic and Reversed) + val note = addBasicAndReversedNote() + + val cardIds: List = note.cardIds(col) + val testDeckId: Long = addDeck("Test Deck") + + // Launch Editor using the Launcher bundle (mimic launch from browser) + val bundle = + NoteEditorLauncher + .EditSelection( + cardIds = cardIds, + animation = DEFAULT, + ).toBundle() + + val editor = openNoteEditorWithArgs(bundle) + + // Change the note's deck to test deck and save + editor.onDeckSelected(SelectableDeck.Deck(testDeckId, "Test Deck")) + editor.saveNote() + + advanceRobolectricLooper() + + // Check if both cards belonging to the note have moved to the test deck + assertEquals(testDeckId, col.getCard(cardIds[0]).did, "First card should be in the test deck") + assertEquals(testDeckId, col.getCard(cardIds[1]).did, "Second card should also be in the test deck") + } + + @Test + fun `changing deck with single card id moves only that card`() = + runTest { + // Create a note with 2 cards (Basic and Reversed) + val note = addBasicAndReversedNote() + + val cardIds: List = note.cardIds(col) + val initialDeckId = col.getCard(cardIds[1]).did + val newDeckId: Long = addDeck("Test Deck") + + // Launch Editor using the Launcher bundle with a single card id + val bundle = + NoteEditorLauncher + .EditSelection( + cardIds = listOf(cardIds[0]), + animation = DEFAULT, + ).toBundle() + + val editor = openNoteEditorWithArgs(bundle) + + // Change the card's deck to test deck and save + editor.onDeckSelected(SelectableDeck.Deck(newDeckId, "Test Deck")) + editor.saveNote() + advanceRobolectricLooper() + + // Check whether sibling cards are unaffected and only the target card has moved to the test deck + assertEquals(newDeckId, col.getCard(cardIds[0]).did, "Selected card should move") + assertEquals(initialDeckId, col.getCard(cardIds[1]).did, "Sibling card should NOT move") + } + private suspend fun withNoteEditorAdding( from: FromScreen = FromScreen.DECK_LIST, block: suspend NoteEditorFragment.() -> Unit, @@ -758,7 +818,7 @@ class NoteEditorTest : RobolectricTest() { ): NoteEditorFragment { val bundle = when (from) { - REVIEWER -> NoteEditorLauncher.EditSelection(n.firstCard().id, DEFAULT).toBundle() + REVIEWER -> NoteEditorLauncher.EditSelection(listOf(n.firstCard().id), DEFAULT).toBundle() DECK_LIST -> NoteEditorLauncher.AddNote().toBundle() } return openNoteEditorWithArgs(bundle) From ff65815758cadc7b3d6a5af2320c9b8b77115de7 Mon Sep 17 00:00:00 2001 From: DoomsCoder Date: Tue, 24 Mar 2026 00:31:02 +0530 Subject: [PATCH 18/87] Fix: Restore widget recurring alarms on startup --- .../main/java/com/ichi2/anki/AnkiDroidApp.kt | 3 ++ .../com/ichi2/anki/services/BootService.kt | 3 ++ .../main/java/com/ichi2/widget/WidgetAlarm.kt | 50 +++++++++++++++++++ .../main/java/com/ichi2/widget/WidgetUtils.kt | 14 ++++++ .../java/com/ichi2/widget/WidgetAlarmTest.kt | 46 +++++++++++++++++ 5 files changed, 116 insertions(+) create mode 100644 AnkiDroid/src/test/java/com/ichi2/widget/WidgetAlarmTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt index 28ef7e41bbc6..d91e85ca3f08 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt @@ -67,6 +67,7 @@ import com.ichi2.utils.Permissions import com.ichi2.utils.setWebContentsDebuggingEnabled import com.ichi2.widget.cardanalysis.CardAnalysisWidget import com.ichi2.widget.deckpicker.DeckPickerWidget +import com.ichi2.widget.restoreRecurringAlarms import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -215,6 +216,8 @@ open class AnkiDroidApp : // listen for day rollover: time + timezone changes DayRolloverHandler.listenForRolloverEvents(this) + restoreRecurringAlarms(this) + registerActivityLifecycleCallbacks( object : ActivityLifecycleCallbacks { override fun onActivityCreated( diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/services/BootService.kt b/AnkiDroid/src/main/java/com/ichi2/anki/services/BootService.kt index 3e3cec9f9fb0..5e07f7cd9ba1 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/services/BootService.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/services/BootService.kt @@ -34,6 +34,7 @@ import com.ichi2.anki.preferences.PENDING_NOTIFICATIONS_ONLY import com.ichi2.anki.preferences.sharedPrefs import com.ichi2.anki.settings.Prefs import com.ichi2.anki.showThemedToast +import com.ichi2.widget.restoreRecurringAlarms import timber.log.Timber import java.util.Calendar @@ -79,6 +80,8 @@ class BootService : AnkiBroadcastReceiver() { catchAlarmManagerErrors(context) { scheduleNotification(TimeManager.time, context) } failedToShowNotifications = false } + + restoreRecurringAlarms(context) wasRun = true } diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/WidgetAlarm.kt b/AnkiDroid/src/main/java/com/ichi2/widget/WidgetAlarm.kt index 54cec1dd8b59..8dbfad51a4ef 100644 --- a/AnkiDroid/src/main/java/com/ichi2/widget/WidgetAlarm.kt +++ b/AnkiDroid/src/main/java/com/ichi2/widget/WidgetAlarm.kt @@ -19,6 +19,7 @@ package com.ichi2.widget import android.app.AlarmManager import android.app.PendingIntent import android.appwidget.AppWidgetManager +import android.content.ComponentName import android.content.Context import android.content.Intent import android.os.SystemClock @@ -126,3 +127,52 @@ fun cancelRecurringAlarm( alarmManager.cancel(pendingIntent) } } + +private fun AppWidgetManager.getAppWidgetIds( + context: Context, + widgetClass: Class, +): List? { + val componentName = ComponentName(context, widgetClass) + val ids = this.getAppWidgetIds(componentName) + + if (ids.isEmpty()) return null + + return ids.map { AppWidgetId(it) } +} + +fun restoreRecurringAlarms(context: Context) { + val appWidgetManager = getAppWidgetManager(context) ?: return + + for (widgetClass in RECURRING_WIDGETS) { + val activeIds = + try { + appWidgetManager.getAppWidgetIds(context, widgetClass) + } catch (e: SecurityException) { + Timber.w(e, "Failed to get widget IDs for %s because security exception", widgetClass.simpleName) + null + } catch (e: Exception) { + Timber.w(e, "Failed to get widget IDs for %s", widgetClass.simpleName) + null + } ?: continue + + Timber.d("Restoring %d alarms for %s", activeIds.size, widgetClass.simpleName) + + for (id in activeIds) { + try { + setRecurringAlarm( + context, + id, + widgetClass, + ) + } catch (e: SecurityException) { + Timber.w( + e, + "Failed to restore alarms for %s because system alarm limit reached", + widgetClass.simpleName, + ) + } catch (e: Exception) { + Timber.w(e, "Failed to restore alarms for %s", widgetClass.simpleName) + } + } + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/WidgetUtils.kt b/AnkiDroid/src/main/java/com/ichi2/widget/WidgetUtils.kt index f16cf44bcab7..3b0557c2f8a6 100644 --- a/AnkiDroid/src/main/java/com/ichi2/widget/WidgetUtils.kt +++ b/AnkiDroid/src/main/java/com/ichi2/widget/WidgetUtils.kt @@ -18,6 +18,8 @@ package com.ichi2.widget import android.appwidget.AppWidgetManager import android.content.Context +import com.ichi2.widget.cardanalysis.CardAnalysisWidget +import com.ichi2.widget.deckpicker.DeckPickerWidget /** * @return An [AppWidgetManager] for the provided context, or `null` @@ -34,3 +36,15 @@ fun getAppWidgetManager(context: Context): AppWidgetManager? { /** Whether 'Material You' dynamic color should be used for widgets */ val disableMaterialYouDynamicColor: Boolean get() = true + +val RECURRING_WIDGETS = + listOf( + DeckPickerWidget::class.java, + CardAnalysisWidget::class.java, + ) + +val NON_RECURRING_WIDGETS = + listOf( + AddNoteWidget::class.java, + AnkiDroidWidgetSmall::class.java, + ) diff --git a/AnkiDroid/src/test/java/com/ichi2/widget/WidgetAlarmTest.kt b/AnkiDroid/src/test/java/com/ichi2/widget/WidgetAlarmTest.kt new file mode 100644 index 000000000000..0288e3f2b151 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/widget/WidgetAlarmTest.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2026 Vedant Kakade + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.widget + +import com.google.common.reflect.ClassPath +import org.junit.Test +import java.lang.reflect.Modifier +import kotlin.test.assertEquals + +class WidgetAlarmTest { + @Test + fun `all AnalyticsWidgetProvider subclasses are categorized`() { + val classLoader = Thread.currentThread().contextClassLoader ?: return + + val allWidgetClasses = + ClassPath + .from(classLoader) + .getTopLevelClassesRecursive("com.ichi2.widget") + .map { it.load() } + .filter { AnalyticsWidgetProvider::class.java.isAssignableFrom(it) } + .filter { !Modifier.isAbstract(it.modifiers) } + .toSet() + + val expectedWidgets = (RECURRING_WIDGETS + NON_RECURRING_WIDGETS).toSet() + + assertEquals( + expectedWidgets, + allWidgetClasses, + "all AnalyticsWidgetProvider subclasses must be included in either RECURRING_WIDGETS or NON_RECURRING_WIDGETS", + ) + } +} From 538f16d99fc8a379848c0f07a3dfe4d57927f522 Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Sat, 18 Apr 2026 05:40:12 +0530 Subject: [PATCH 19/87] fix: keep sync snackbar anchored correctly - onOptionsItemSelected(R.id.action_sync) : collapse toolbarSearchItem before starting the sync. That fires onMenuItemActionCollapse -> showFloatingActionButton(), so the FAB is back by the time the sync result snackbar appears. - onMenuItemActionExpand: after hideFloatingActionButton(), null out activeSnackBar?.anchorView so a live snackbar drops to sit above the keyboard instead of hovering where the FAB used to be. - onMenuItemActionCollapse: after showFloatingActionButton(), re-anchor activeSnackBar?.anchorView to fabMain so it sits above the FAB instead of being overlapped by it. --- AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt index 7d025edbf3c3..43e573d94f18 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt @@ -1086,6 +1086,7 @@ open class DeckPicker : Timber.i("DeckPicker:: SearchItem opened") // Hide the floating action button if it is visible floatingActionMenu.hideFloatingActionButton() + activeSnackBar?.anchorView = null return true } @@ -1094,6 +1095,7 @@ open class DeckPicker : Timber.i("DeckPicker:: SearchItem closed") // Show the floating action button if it is hidden floatingActionMenu.showFloatingActionButton() + activeSnackBar?.anchorView = floatingActionButtonBinding.fabMain return true } }, @@ -1207,6 +1209,7 @@ open class DeckPicker : } R.id.action_sync -> { Timber.i("DeckPicker:: Sync button pressed") + toolbarSearchItem?.collapseActionView() val actionProvider = MenuItemCompat.getActionProvider(item) as? SyncActionProvider if (actionProvider?.isProgressShown == true) { launchCatchingTask { From 4152002eae3c4201236681c8dde2291feaa96897 Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Sat, 4 Apr 2026 23:20:25 +0530 Subject: [PATCH 20/87] feat: define trigger app restart guard - unit test for profile guard Assisted-by: Gemini 3.1 Pro - Unit test: ProfileSwitchGuardTest --- .../ichi2/anki/multiprofile/ProfileManager.kt | 28 +++- .../anki/multiprofile/ProfileSwitchGuard.kt | 81 ++++++++++ .../multiprofile/ProfileSwitchGuardTest.kt | 138 ++++++++++++++++++ 3 files changed, 240 insertions(+), 7 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileSwitchGuard.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileSwitchGuardTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt index 0c56675c7523..8157fb1e23cd 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt @@ -18,13 +18,16 @@ package com.ichi2.anki.multiprofile import android.content.Context +import android.content.Intent import android.content.SharedPreferences import android.os.Build import android.webkit.CookieManager import android.webkit.WebView +import androidx.annotation.VisibleForTesting import androidx.core.content.ContextCompat import androidx.core.content.edit import com.ichi2.anki.CrashReportService +import com.ichi2.anki.IntentHandler import com.ichi2.anki.common.time.TimeManager import com.ichi2.anki.common.time.getTimestamp import org.acra.ACRA @@ -148,11 +151,16 @@ class ProfileManager private constructor( return newId } + /** + * Persists [newProfileId] as the active profile. + * + * @param newProfileId The [ProfileId] to activate on next launch. + */ + @VisibleForTesting + context(_: ProfileSwitchContext) fun switchActiveProfile(newProfileId: ProfileId) { Timber.i("Switching profile to ID: $newProfileId") - profileRegistry.setLastActiveProfileId(newProfileId) - triggerAppRestart() } private fun loadProfileData(profileId: ProfileId) { @@ -224,11 +232,6 @@ class ProfileManager private constructor( return ProfileRestrictedDirectory(directoryFile) } - private fun triggerAppRestart() { - Timber.w("Restarting app to apply profile switch") - // TODO: Implement process restart logic (e.g. ProcessPhoenix) - } - /** * Holds the meta-data for a profile. * Converted to JSON for storage to allow future extensibility (e.g. avatars, themes). @@ -330,6 +333,17 @@ class ProfileManager private constructor( fun contains(id: ProfileId): Boolean = globalPrefs.contains(id.value) } + /** + * A context representing that it is safe to switch profiles + * + * - Backups are not occurring + * - Sync is completed + * - Collection is not open + * + * @see ProfileSwitchGuard + */ + object ProfileSwitchContext + companion object { private const val MAX_ATTEMPTS = 10 const val PROFILE_REGISTRY_FILENAME = "profiles_prefs" diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileSwitchGuard.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileSwitchGuard.kt new file mode 100644 index 000000000000..85adb854621a --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileSwitchGuard.kt @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2026 Ashish Yadav + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.anki.multiprofile + +import com.ichi2.anki.multiprofile.ProfileManager.ProfileSwitchContext + +/** + * Guards profile switching by running a set of safety checks + * before delegating to [ProfileManager]. + * + * @param profileManager The manager that persists the switch. + * @param checks Ordered list of safety checks to run + * before allowing the switch. + */ +class ProfileSwitchGuard( + private val profileManager: ProfileManager, + private val checks: List, +) { + sealed class Result { + data object Success : Result() + + /** + * Indicates the switch was blocked. + * @param reasons All currently active blocks that are NOT being ignored. + */ + data class Blocked( + val reasons: Set, + ) : Result() + } + + enum class BlockReason { + BACKUP_IN_PROGRESS, + MEDIA_SYNC_IN_PROGRESS, + COLLECTION_BUSY, + } + + fun interface SafetyCheck { + suspend fun verify(): BlockReason? + } + + /** + * Runs all checks. + * @param newProfileId The target profile. + * @param skipReasons A set of reasons the user has explicitly chosen to ignore. + */ + suspend operator fun invoke( + newProfileId: ProfileId, + skipReasons: Set = emptySet(), + ): Result { + val activeBlockedReasons = mutableSetOf() + + for (check in checks) { + val reason = check.verify() + if (reason != null && !skipReasons.contains(reason)) { + activeBlockedReasons.add(reason) + } + } + + return if (activeBlockedReasons.isEmpty()) { + with(ProfileSwitchContext) { profileManager.switchActiveProfile(newProfileId) } + Result.Success + } else { + Result.Blocked(activeBlockedReasons) + } + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileSwitchGuardTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileSwitchGuardTest.kt new file mode 100644 index 000000000000..f3b6db219daf --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileSwitchGuardTest.kt @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2026 Ashish Yadav + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.anki.multiprofile + +import com.ichi2.anki.multiprofile.ProfileManager.ProfileSwitchContext +import com.ichi2.anki.multiprofile.ProfileSwitchGuard.BlockReason +import com.ichi2.anki.multiprofile.ProfileSwitchGuard.Result +import com.ichi2.anki.multiprofile.ProfileSwitchGuard.SafetyCheck +import io.mockk.coEvery +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ProfileSwitchGuardTest { + private val profileManager: ProfileManager = mockk(relaxed = true) + private val targetId = ProfileId("p_12345678") + + @Test + fun `invoke returns Success and switches when all checks pass`() = + runTest { + val check1 = + mockk { + coEvery { verify() } returns null + } + val check2 = + mockk { + coEvery { verify() } returns null + } + + val guard = ProfileSwitchGuard(profileManager, listOf(check1, check2)) + + val result = guard(targetId) + + assertEquals(Result.Success, result) + with(ProfileSwitchContext) { + verify(exactly = 1) { profileManager.switchActiveProfile(targetId) } + } + } + + @Test + fun `invoke returns Blocked and does NOT switch when a check fails`() = + runTest { + val check1 = + mockk { + coEvery { verify() } returns BlockReason.BACKUP_IN_PROGRESS + } + + val guard = ProfileSwitchGuard(profileManager, listOf(check1)) + + val result = guard(targetId) + + assertTrue(result is Result.Blocked) + assertEquals(setOf(BlockReason.BACKUP_IN_PROGRESS), (result as Result.Blocked).reasons) + + with(ProfileSwitchContext) { + verify(exactly = 0) { profileManager.switchActiveProfile(any()) } + } + } + + @Test + fun `invoke returns Success if the blocked reason is in skipReasons`() = + runTest { + val check1 = + mockk { + coEvery { verify() } returns BlockReason.MEDIA_SYNC_IN_PROGRESS + } + + val guard = ProfileSwitchGuard(profileManager, listOf(check1)) + + val result = guard(targetId, skipReasons = setOf(BlockReason.MEDIA_SYNC_IN_PROGRESS)) + + assertEquals(Result.Success, result) + with(ProfileSwitchContext) { + verify(exactly = 1) { profileManager.switchActiveProfile(targetId) } + } + } + + @Test + fun `invoke returns Blocked if multiple checks fail and only one is skipped`() = + runTest { + val syncCheck = + mockk { + coEvery { verify() } returns BlockReason.MEDIA_SYNC_IN_PROGRESS + } + val backupCheck = + mockk { + coEvery { verify() } returns BlockReason.BACKUP_IN_PROGRESS + } + + val guard = ProfileSwitchGuard(profileManager, listOf(syncCheck, backupCheck)) + + val result = guard(targetId, skipReasons = setOf(BlockReason.MEDIA_SYNC_IN_PROGRESS)) + + assertTrue(result is Result.Blocked) + val blockedReasons = (result as Result.Blocked).reasons + assertEquals(1, blockedReasons.size) + assertTrue(blockedReasons.contains(BlockReason.BACKUP_IN_PROGRESS)) + + with(ProfileSwitchContext) { + verify(exactly = 0) { profileManager.switchActiveProfile(any()) } + } + } + + @Test + fun `invoke collects all active blocked reasons if multiple fail`() = + runTest { + val check1 = mockk { coEvery { verify() } returns BlockReason.BACKUP_IN_PROGRESS } + val check2 = mockk { coEvery { verify() } returns BlockReason.COLLECTION_BUSY } + + val guard = ProfileSwitchGuard(profileManager, listOf(check1, check2)) + + val result = guard(targetId) + + assertTrue(result is Result.Blocked) + assertEquals( + setOf(BlockReason.BACKUP_IN_PROGRESS, BlockReason.COLLECTION_BUSY), + (result as Result.Blocked).reasons, + ) + } +} From 570c05accde169cfadff368290f52d576d3f4f5b Mon Sep 17 00:00:00 2001 From: Kunal Date: Thu, 26 Mar 2026 02:20:06 +0530 Subject: [PATCH 21/87] feat(api): added fields to cards endpoints Refine card field docs and tests test: fix nullable cursor handling in provider tests docs: refine card field KDoc in FlashCardsContract docs: clarify FlashCardsContract.Card.TYPE KDoc --- .../ichi2/anki/tests/ContentProviderTest.kt | 152 ++++++++++++++++++ .../anki/provider/CardContentProvider.kt | 4 + .../java/com/ichi2/anki/FlashCardsContract.kt | 54 +++++++ 3 files changed, 210 insertions(+) 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 344d9cf82ce8..967466d589dd 100644 --- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/ContentProviderTest.kt +++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/ContentProviderTest.kt @@ -391,6 +391,158 @@ class ContentProviderTest : InstrumentedTest() { } } + @Test + fun testQueryCardReps() { + val noteId = createdNotes.first().lastPathSegment!!.toLong() + val note = col.getNote(noteId) + val card = note.cards(col).single() + val expectedReps = 7 + + card.update { + reps = expectedReps + } + + val cursor = + checkNotNull( + contentResolver.query( + FlashCardsContract.Card.CONTENT_URI, + arrayOf(FlashCardsContract.Card.REPS), + "cid:${card.id}", + null, + null, + ), + ) { "cursor from /cards" } + + cursor.use { + assertEquals(1, it.count) + assertTrue(it.moveToFirst()) + assertEquals(listOf(FlashCardsContract.Card.REPS), it.columnNames.toList()) + assertEquals(expectedReps, it.getInt(it.getColumnIndex(FlashCardsContract.Card.REPS))) + } + } + + @Test + fun testQueryCardLapses() { + val noteId = createdNotes.first().lastPathSegment!!.toLong() + val noteUri = Uri.withAppendedPath(FlashCardsContract.Note.CONTENT_URI, noteId.toString()) + val noteCardsUri = Uri.withAppendedPath(noteUri, "cards") + val card = col.getNote(noteId).cards(col).single() + val expectedLapses = 3 + + card.update { + lapses = expectedLapses + } + + val cursor = + checkNotNull( + contentResolver.query( + noteCardsUri, + arrayOf(FlashCardsContract.Card.LAPSES), + null, + null, + null, + ), + ) { "cursor from /notes/#/cards" } + + cursor.use { + assertEquals(1, it.count) + assertTrue(it.moveToFirst()) + assertEquals(listOf(FlashCardsContract.Card.LAPSES), it.columnNames.toList()) + assertEquals(expectedLapses, it.getInt(it.getColumnIndex(FlashCardsContract.Card.LAPSES))) + } + } + + @Test + fun testQueryCardType() { + val noteId = createdNotes.first().lastPathSegment!!.toLong() + val card = col.getNote(noteId).cards(col).single() + val expectedType = 2 + val cardUri = + Uri.withAppendedPath( + FlashCardsContract.Card.CONTENT_URI, + card.id.toString(), + ) + + card.moveToReviewQueue() + + val cursor = + checkNotNull( + contentResolver.query( + cardUri, + arrayOf(FlashCardsContract.Card.TYPE), + null, + null, + null, + ), + ) { "cursor from /cards/#" } + + cursor.use { + assertEquals(1, it.count) + assertTrue(it.moveToFirst()) + assertEquals(listOf(FlashCardsContract.Card.TYPE), it.columnNames.toList()) + assertEquals(expectedType, it.getInt(it.getColumnIndex(FlashCardsContract.Card.TYPE))) + } + } + + @Test + fun testQueryCardOriginalDeckId() { + val noteId = createdNotes.first().lastPathSegment!!.toLong() + val noteUri = Uri.withAppendedPath(FlashCardsContract.Note.CONTENT_URI, noteId.toString()) + val noteCardsUri = Uri.withAppendedPath(noteUri, "cards") + val card = col.getNote(noteId).cards(col).single() + val expectedOriginalDeckId = testDeckIds[0] + val noteCardUri = Uri.withAppendedPath(noteCardsUri, card.ord.toString()) + + card.update { + oDid = expectedOriginalDeckId + } + + val cursor = + checkNotNull( + contentResolver.query( + noteCardUri, + arrayOf(FlashCardsContract.Card.ORIGINAL_DECK_ID), + null, + null, + null, + ), + ) { "cursor from /notes/#/cards/#" } + + cursor.use { + assertEquals(1, it.count) + assertTrue(it.moveToFirst()) + assertEquals(listOf(FlashCardsContract.Card.ORIGINAL_DECK_ID), it.columnNames.toList()) + assertEquals( + expectedOriginalDeckId, + it.getLong(it.getColumnIndex(FlashCardsContract.Card.ORIGINAL_DECK_ID)), + ) + } + } + + @Test + fun testQueryCardDefaultProjectionOmitsRawProperties() { + val noteId = createdNotes.first().lastPathSegment!!.toLong() + val card = col.getNote(noteId).cards(col).single() + val cardUri = + Uri.withAppendedPath( + FlashCardsContract.Card.CONTENT_URI, + card.id.toString(), + ) + + val cursor = + checkNotNull(contentResolver.query(cardUri, null, null, null, null)) { + "cursor from default projection query" + } + + cursor.use { + assertTrue(it.moveToFirst()) + assertEquals(-1, it.getColumnIndex(FlashCardsContract.Card.TYPE)) + assertEquals(-1, it.getColumnIndex(FlashCardsContract.Card.REPS)) + assertEquals(-1, it.getColumnIndex(FlashCardsContract.Card.LAPSES)) + assertEquals(-1, it.getColumnIndex(FlashCardsContract.Card.ORIGINAL_DECK_ID)) + } + } + @Test fun testQueryCardsRoot_returnsCards() { val cursor = diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt b/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt index c492cc28dba8..646590086ed9 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt @@ -1178,6 +1178,10 @@ class CardContentProvider : ContentProvider() { FlashCardsContract.Card.CARD_ORD -> rb.add(currentCard.ord) FlashCardsContract.Card.CARD_NAME -> rb.add(cardName) FlashCardsContract.Card.DECK_ID -> rb.add(currentCard.did) + FlashCardsContract.Card.REPS -> rb.add(currentCard.reps) + FlashCardsContract.Card.LAPSES -> rb.add(currentCard.lapses) + FlashCardsContract.Card.TYPE -> rb.add(currentCard.type.code) + FlashCardsContract.Card.ORIGINAL_DECK_ID -> rb.add(currentCard.oDid) FlashCardsContract.Card.QUESTION -> rb.add(question) FlashCardsContract.Card.ANSWER -> rb.add(answer) FlashCardsContract.Card.QUESTION_SIMPLE -> rb.add(currentCard.renderOutput(col).questionText) diff --git a/api/src/main/java/com/ichi2/anki/FlashCardsContract.kt b/api/src/main/java/com/ichi2/anki/FlashCardsContract.kt index ea679b22eb33..cc5fa535b04a 100644 --- a/api/src/main/java/com/ichi2/anki/FlashCardsContract.kt +++ b/api/src/main/java/com/ichi2/anki/FlashCardsContract.kt @@ -608,6 +608,60 @@ public object FlashCardsContract { */ public const val DECK_ID: String = "deck_id" + /** + * The stored Anki repetition count for this card. + * + * Counts how many times Anki has recorded this card as answered during study. The Anki + * manual's + * [`prop:reps`](https://docs.ankiweb.net/searching.html#card-properties) search uses the + * same stored counter. New cards typically start at 0. + * + * This provider exposes the backend value as-is; exact effects of manual operations such as + * rescheduling depend on Anki's scheduler/backend behavior. + */ + public const val REPS: String = "reps" + + /** + * The stored Anki lapse count for this card. + * + * Counts how many times Anki has recorded this card as lapsed. In Anki's manual, a + * [lapse](https://docs.ankiweb.net/deck-options.html#lapses) is pressing Again on a review + * card, and [`prop:lapses`](https://docs.ankiweb.net/searching.html#card-properties) + * searches this same stored counter. + * + * This provider exposes the backend value as-is. + */ + public const val LAPSES: String = "lapses" + + /** + * The stored Anki card type code: the card's learning or review stage. + * + * See also [Anki's card states](https://docs.ankiweb.net/getting-started.html#card-states). + * + * Think of this as a simple state machine that affects how the card is scheduled when it + * is answered. A card typically moves `0 -> 1 -> 2`; if a review card lapses, it + * typically moves `2 -> 3 -> 2`. + * + * * `0` = new + * * `1` = learning + * * `2` = review + * * `3` = relearning + * + * Other values should be treated as unknown. + */ + public const val TYPE: String = "type" + + /** + * The stored original deck id for this card. + * + * For cards in filtered decks, Anki keeps a link to the card's + * [home deck](https://docs.ankiweb.net/filtered-decks.html#home-decks). + * + * * If the card is currently in a filtered deck, this is the deck id the card came from. + * * If the card is not currently in a filtered deck, this value is 0. + */ + public const val ORIGINAL_DECK_ID: String = "original_deck_id" + /** * The question for this card. */ From 9d33fd8c53bdf46e26a5530f93f2a02b9cb556f3 Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Sun, 5 Apr 2026 05:50:50 +0530 Subject: [PATCH 22/87] feat: allow renaming profiles - test: unit test for renaming method --- .../ichi2/anki/multiprofile/ProfileManager.kt | 38 ++++++++++++ .../anki/multiprofile/ProfileManagerTest.kt | 62 +++++++++++++++++++ 2 files changed, 100 insertions(+) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt index 8157fb1e23cd..47b01374fdc7 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt @@ -232,6 +232,44 @@ class ProfileManager private constructor( return ProfileRestrictedDirectory(directoryFile) } + /** + * Renames an existing profile by updating its display name in + * the registry. + * + * All other metadata fields (version, creation timestamp) are + * preserved. The change is persisted immediately. + * + * @param profileId The [ProfileId] of the profile to rename. + * @param newDisplayName The new user-facing name. + * + * @throws IllegalArgumentException if [profileId] does not + * exist in the registry. + */ + fun renameProfile( + profileId: ProfileId, + newDisplayName: String, + ) { + Timber.d("ProfileManager::renameProfile called for $profileId") + + require(newDisplayName.isNotBlank()) { + "Profile display name must not be blank" + } + + val existing = + profileRegistry.getProfileMetadata(profileId) + ?: throw IllegalArgumentException("Profile $profileId not found") + + if (existing.displayName == newDisplayName) { + Timber.d("Rename skipped: New name matches existing name for $profileId") + return + } + + val updated = existing.copy(displayName = newDisplayName) + profileRegistry.saveProfile(profileId, updated) + + Timber.d("Renamed profile $profileId to '$newDisplayName'") + } + /** * Holds the meta-data for a profile. * Converted to JSON for storage to allow future extensibility (e.g. avatars, themes). diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileManagerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileManagerTest.kt index 6adba877654e..521d5a2cad43 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileManagerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileManagerTest.kt @@ -39,6 +39,7 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import org.junit.jupiter.api.Assertions.assertThrows import org.junit.runner.RunWith import org.robolectric.annotation.Config import java.io.File @@ -200,4 +201,65 @@ class ProfileManagerTest { assertEquals(1, allProfiles.size) assertTrue(allProfiles.containsKey(ProfileId.DEFAULT)) } + + fun `renameProfile updates displayName in registry`() { + val manager = ProfileManager.create(context) + val profileId = manager.createNewProfile("Original Name") + val newName = "Updated Name" + + manager.renameProfile(profileId, newName) + + val json = prefs.getString(profileId.value, null) + val metadata = ProfileManager.ProfileMetadata.fromJson(json!!) + + assertEquals(newName, metadata.displayName) + } + + @Test + fun `renameProfile preserves version and createdTimestamp`() { + val manager = ProfileManager.create(context) + val profileId = manager.createNewProfile("Original Name") + + val originalJson = prefs.getString(profileId.value, null) + val originalMetadata = ProfileManager.ProfileMetadata.fromJson(originalJson!!) + + manager.renameProfile(profileId, "New Name") + + val updatedJson = prefs.getString(profileId.value, null) + val updatedMetadata = ProfileManager.ProfileMetadata.fromJson(updatedJson!!) + + assertEquals("Version must be preserved", originalMetadata.version, updatedMetadata.version) + assertEquals( + "Timestamp must be preserved", + originalMetadata.createdTimestamp, + updatedMetadata.createdTimestamp, + ) + } + + @Test + fun `renameProfile does not write to disk if name is identical`() { + val manager = ProfileManager.create(context) + val name = "No Change" + val profileId = manager.createNewProfile(name) + + val originalJson = prefs.getString(profileId.value, null) + + manager.renameProfile(profileId, name) + + val currentJson = prefs.getString(profileId.value, null) + assertEquals("No disk write should occur for identical names", originalJson, currentJson) + } + + @Test + fun `renameProfile throws IllegalArgumentException for missing profile`() { + val manager = ProfileManager.create(context) + val fakeId = ProfileId("p_ghost") + + val exception = + assertThrows(IllegalArgumentException::class.java) { + manager.renameProfile(fakeId, "New Name") + } + + assertTrue(exception.message!!.contains("not found")) + } } From 6f7146e66e954b392cc7b9242766e60bfd5bdf3d Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Thu, 9 Apr 2026 11:31:28 +0530 Subject: [PATCH 23/87] feat: profile name validation --- .../ichi2/anki/multiprofile/ProfileManager.kt | 18 +-- .../ichi2/anki/multiprofile/ProfileName.kt | 79 ++++++++++ .../anki/multiprofile/ProfileManagerTest.kt | 25 +-- .../anki/multiprofile/ProfileNameTest.kt | 145 ++++++++++++++++++ 4 files changed, 244 insertions(+), 23 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileName.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileNameTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt index 47b01374fdc7..61db266ba782 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt @@ -76,7 +76,7 @@ class ProfileManager private constructor( val defaultId = ProfileId.DEFAULT val metadata = - ProfileMetadata(displayName = DEFAULT_PROFILE_DISPLAY_NAME) + ProfileMetadata(displayName = ProfileName.fromTrustedSource(DEFAULT_PROFILE_DISPLAY_NAME)) profileRegistry.saveProfile(id = defaultId, metadata = metadata, isActive = true) profileRegistry.setLastActiveProfileId(defaultId) @@ -96,14 +96,14 @@ class ProfileManager private constructor( * * @throws Exception if profile creation or persistence fails. */ - fun createNewProfile(displayName: String): ProfileId { + fun createNewProfile(displayName: ProfileName): ProfileId { val newProfileId = generateUniqueProfileId() val metadata = ProfileMetadata(displayName = displayName) profileRegistry.saveProfile(newProfileId, metadata) - Timber.i("Created new profile: $displayName (${newProfileId.value})") + Timber.i("Created new profile: ${displayName.value} (${newProfileId.value})") return newProfileId } @@ -247,14 +247,10 @@ class ProfileManager private constructor( */ fun renameProfile( profileId: ProfileId, - newDisplayName: String, + newDisplayName: ProfileName, ) { Timber.d("ProfileManager::renameProfile called for $profileId") - require(newDisplayName.isNotBlank()) { - "Profile display name must not be blank" - } - val existing = profileRegistry.getProfileMetadata(profileId) ?: throw IllegalArgumentException("Profile $profileId not found") @@ -275,14 +271,14 @@ class ProfileManager private constructor( * Converted to JSON for storage to allow future extensibility (e.g. avatars, themes). */ data class ProfileMetadata( - val displayName: String, + val displayName: ProfileName, val version: Int = 1, val createdTimestamp: String = getTimestamp(TimeManager.time), ) { fun toJson(): String = JSONObject() .apply { - put("displayName", displayName) + put("displayName", displayName.value) put("version", version) put("created", createdTimestamp) }.toString() @@ -291,7 +287,7 @@ class ProfileManager private constructor( fun fromJson(jsonString: String): ProfileMetadata { val json = JSONObject(jsonString) return ProfileMetadata( - displayName = json.optString("displayName", "Unknown"), + displayName = ProfileName.fromTrustedSource(json.optString("displayName", "Unknown")), version = json.optInt("version", 1), createdTimestamp = json.optString("created", ""), ) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileName.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileName.kt new file mode 100644 index 000000000000..649030bb55fc --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileName.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2026 Ashish Yadav + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.anki.multiprofile + +/** + * A validated, user-facing profile display name. + * + * Values can only be obtained through [validate] (for user input) or + * [fromTrustedSource] (for persisted / hard-coded values). This makes invalid + * profile names unrepresentable in the domain layer. + */ +@JvmInline +value class ProfileName private constructor( + val value: String, +) { + override fun toString(): String = value + + companion object { + const val MAX_LENGTH = 50 + + /** + * Validates raw user input. Trims leading and trailing whitespace + * before checking the rules; interior whitespace (including multiple + * spaces between words) is preserved as-is. + * + * Any non-whitespace character is permitted — including punctuation, + * emoji, and symbols — matching the desktop Anki behavior. Only + * length and emptiness are enforced here; uniqueness is checked at + * the registry layer. + * + * Returns a [ValidationResult] — never throws. + */ + fun validate(raw: String): ValidationResult { + val cleaned = raw.trim() + return when { + cleaned.isEmpty() -> ValidationResult.Empty + cleaned.length > MAX_LENGTH -> + ValidationResult.TooLong(cleaned.length) + else -> ValidationResult.Valid(ProfileName(cleaned)) + } + } + + /** + * Constructs a [ProfileName] from a value that has already been + * validated elsewhere (persisted metadata, hard-coded defaults). + * + * DO NOT use this for raw user input — use [validate] instead. + */ + internal fun fromTrustedSource(value: String): ProfileName = ProfileName(value) + } + + /** Outcome of validating a candidate profile name. */ + sealed interface ValidationResult { + data class Valid( + val name: ProfileName, + ) : ValidationResult + + data object Empty : ValidationResult + + data class TooLong( + val actualLength: Int, + ) : ValidationResult + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileManagerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileManagerTest.kt index 521d5a2cad43..b2b7f5a1e63b 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileManagerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileManagerTest.kt @@ -163,7 +163,7 @@ class ProfileManagerTest { fun `ProfileMetadata JSON round-trip preserves all fields`() { val original = ProfileManager.ProfileMetadata( - displayName = "Test User", + displayName = ProfileName.fromTrustedSource("Test User"), version = 5, createdTimestamp = "2025-12-31T23:59:59Z", ) @@ -178,8 +178,8 @@ class ProfileManagerTest { fun `getAllProfiles returns all registered profiles`() { val manager = ProfileManager.create(context) - val profile1 = manager.createNewProfile("Work") - val profile2 = manager.createNewProfile("Personal") + val profile1 = manager.createNewProfile(ProfileName.fromTrustedSource("Work")) + val profile2 = manager.createNewProfile(ProfileName.fromTrustedSource("Personal")) val allProfiles = manager.getAllProfiles() @@ -187,9 +187,9 @@ class ProfileManagerTest { assertTrue(allProfiles.containsKey(ProfileId.DEFAULT)) assertTrue(allProfiles.containsKey(profile1)) assertTrue(allProfiles.containsKey(profile2)) - assertEquals("Default", allProfiles[ProfileId.DEFAULT]?.displayName) - assertEquals("Work", allProfiles[profile1]?.displayName) - assertEquals("Personal", allProfiles[profile2]?.displayName) + assertEquals("Default", allProfiles[ProfileId.DEFAULT]?.displayName?.value) + assertEquals("Work", allProfiles[profile1]?.displayName?.value) + assertEquals("Personal", allProfiles[profile2]?.displayName?.value) } @Test @@ -202,10 +202,11 @@ class ProfileManagerTest { assertTrue(allProfiles.containsKey(ProfileId.DEFAULT)) } + @Test fun `renameProfile updates displayName in registry`() { val manager = ProfileManager.create(context) - val profileId = manager.createNewProfile("Original Name") - val newName = "Updated Name" + val profileId = manager.createNewProfile(ProfileName.fromTrustedSource("Original Name")) + val newName = ProfileName.fromTrustedSource("Updated Name") manager.renameProfile(profileId, newName) @@ -218,12 +219,12 @@ class ProfileManagerTest { @Test fun `renameProfile preserves version and createdTimestamp`() { val manager = ProfileManager.create(context) - val profileId = manager.createNewProfile("Original Name") + val profileId = manager.createNewProfile(ProfileName.fromTrustedSource("Original Name")) val originalJson = prefs.getString(profileId.value, null) val originalMetadata = ProfileManager.ProfileMetadata.fromJson(originalJson!!) - manager.renameProfile(profileId, "New Name") + manager.renameProfile(profileId, ProfileName.fromTrustedSource("New Name")) val updatedJson = prefs.getString(profileId.value, null) val updatedMetadata = ProfileManager.ProfileMetadata.fromJson(updatedJson!!) @@ -239,7 +240,7 @@ class ProfileManagerTest { @Test fun `renameProfile does not write to disk if name is identical`() { val manager = ProfileManager.create(context) - val name = "No Change" + val name = ProfileName.fromTrustedSource("No Change") val profileId = manager.createNewProfile(name) val originalJson = prefs.getString(profileId.value, null) @@ -257,7 +258,7 @@ class ProfileManagerTest { val exception = assertThrows(IllegalArgumentException::class.java) { - manager.renameProfile(fakeId, "New Name") + manager.renameProfile(fakeId, ProfileName.fromTrustedSource("New Name")) } assertTrue(exception.message!!.contains("not found")) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileNameTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileNameTest.kt new file mode 100644 index 000000000000..43aa7528d84c --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileNameTest.kt @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2026 Ashish Yadav + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more + * details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.anki.multiprofile + +import com.ichi2.anki.multiprofile.ProfileName.ValidationResult +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ProfileNameTest { + @Test + fun `blank input returns Empty`() { + assertEquals(ValidationResult.Empty, ProfileName.validate("")) + } + + @Test + fun `whitespace-only input returns Empty`() { + assertEquals(ValidationResult.Empty, ProfileName.validate(" ")) + } + + @Test + fun `tabs and newlines only return Empty`() { + assertEquals(ValidationResult.Empty, ProfileName.validate("\t\n ")) + } + + @Test + fun `simple valid name returns Ok`() { + val result = ProfileName.validate("Mike") + assertTrue(result is ValidationResult.Valid) + assertEquals("Mike", (result as ValidationResult.Valid).name.value) + } + + @Test + fun `name exactly at MAX_LENGTH is accepted`() { + val input = "a".repeat(ProfileName.MAX_LENGTH) + val result = ProfileName.validate(input) + assertTrue(result is ValidationResult.Valid) + assertEquals(input, (result as ValidationResult.Valid).name.value) + } + + @Test + fun `name one character over MAX_LENGTH returns TooLong`() { + val input = "a".repeat(ProfileName.MAX_LENGTH + 1) + val result = ProfileName.validate(input) + assertEquals(ValidationResult.TooLong(ProfileName.MAX_LENGTH + 1), result) + } + + @Test + fun `leading and trailing whitespace is trimmed`() { + val result = ProfileName.validate(" David ") + assertTrue(result is ValidationResult.Valid) + assertEquals("David", (result as ValidationResult.Valid).name.value) + } + + @Test + fun `two-word name is trimmed at start and end`() { + val result = ProfileName.validate(" Ashish Yadav ") + assertTrue(result is ValidationResult.Valid) + assertEquals("Ashish Yadav", (result as ValidationResult.Valid).name.value) + } + + @Test + fun `interior whitespace is preserved`() { + val result = ProfileName.validate("Ashish Yadav") + assertTrue(result is ValidationResult.Valid) + assertEquals("Ashish Yadav", (result as ValidationResult.Valid).name.value) + } + + @Test + fun `only leading and trailing whitespace is removed for multi-word names`() { + val result = ProfileName.validate(" a b c ") + assertTrue(result is ValidationResult.Valid) + assertEquals("a b c", (result as ValidationResult.Valid).name.value) + } + + @Test + fun `unicode letters are accepted`() { + val result = ProfileName.validate("日本語") + assertTrue(result is ValidationResult.Valid) + assertEquals("日本語", (result as ValidationResult.Valid).name.value) + } + + @Test + fun `accented letters are accepted`() { + val result = ProfileName.validate("José") + assertTrue(result is ValidationResult.Valid) + assertEquals("José", (result as ValidationResult.Valid).name.value) + } + + @Test + fun `digits hyphens and underscores are accepted`() { + val result = ProfileName.validate("user_1-profile") + assertTrue(result is ValidationResult.Valid) + assertEquals("user_1-profile", (result as ValidationResult.Valid).name.value) + } + + @Test + fun `punctuation is accepted`() { + val result = ProfileName.validate("India!") + assertTrue(result is ValidationResult.Valid) + assertEquals("India!", (result as ValidationResult.Valid).name.value) + } + + @Test + fun `mixed punctuation is accepted`() { + val result = ProfileName.validate("Bob!@#") + assertTrue(result is ValidationResult.Valid) + assertEquals("Bob!@#", (result as ValidationResult.Valid).name.value) + } + + @Test + fun `repeated special characters are preserved`() { + val result = ProfileName.validate("a!!!b") + assertTrue(result is ValidationResult.Valid) + assertEquals("a!!!b", (result as ValidationResult.Valid).name.value) + } + + @Test + fun `emoji is accepted`() { + val result = ProfileName.validate("Ashish 🎉") + assertTrue(result is ValidationResult.Valid) + assertEquals("Ashish 🎉", (result as ValidationResult.Valid).name.value) + } + + @Test + fun `fromTrustedSource preserves value untouched`() { + val untouched = " weird!! value " + assertEquals(untouched, ProfileName.fromTrustedSource(untouched).value) + } +} From 63b5f867c285196f6ac9d04f8c5680afec11f76b Mon Sep 17 00:00:00 2001 From: AnkiDroid Translations Date: Sun, 19 Apr 2026 16:24:53 +0000 Subject: [PATCH 24/87] Updated strings from Crowdin --- .../src/main/res/values-fi/03-dialogs.xml | 2 +- .../src/main/res/values-fr/03-dialogs.xml | 18 +++--- AnkiDroid/src/main/res/values-ja/01-core.xml | 2 +- .../src/main/res/values-ja/02-strings.xml | 46 +++++++-------- .../src/main/res/values-ja/03-dialogs.xml | 26 ++++----- .../src/main/res/values-ja/10-preferences.xml | 2 +- .../main/res/values-ja/17-model-manager.xml | 4 +- .../src/main/res/values-pt-rBR/01-core.xml | 8 +-- .../src/main/res/values-pt-rBR/02-strings.xml | 56 +++++++++---------- .../src/main/res/values-pt-rBR/03-dialogs.xml | 16 +++--- AnkiDroid/src/main/res/values-uk/01-core.xml | 6 +- .../src/main/res/values-uk/02-strings.xml | 28 +++++----- .../src/main/res/values-uk/03-dialogs.xml | 28 +++++----- .../src/main/res/values-uk/07-cardbrowser.xml | 2 +- .../src/main/res/values-uk/09-backup.xml | 2 +- .../src/main/res/values-uk/10-preferences.xml | 4 +- .../res/values-uk/16-multimedia-editor.xml | 6 +- .../main/res/values-uk/17-model-manager.xml | 8 +-- AnkiDroid/src/main/res/values-uz/01-core.xml | 2 +- .../src/main/res/values-uz/02-strings.xml | 8 +-- .../src/main/res/values-uz/03-dialogs.xml | 4 +- .../src/main/res/values-uz/09-backup.xml | 2 +- .../src/main/res/values-uz/10-preferences.xml | 2 +- .../main/res/values-zh-rCN/10-preferences.xml | 2 +- 24 files changed, 142 insertions(+), 142 deletions(-) diff --git a/AnkiDroid/src/main/res/values-fi/03-dialogs.xml b/AnkiDroid/src/main/res/values-fi/03-dialogs.xml index 2aefef909536..68053289be8b 100644 --- a/AnkiDroid/src/main/res/values-fi/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-fi/03-dialogs.xml @@ -275,7 +275,7 @@ The full deck of the card, including parent decks%s The current deck of the card, excluding parent decks%s Outputs ‘%1$s’, where %2$s is the flag code (%3$s\–%4$s\) - The tags of the note%s + Huomautuksen%s tunnisteet The ID of the card%s The name of the card template%s The name of the note type%s diff --git a/AnkiDroid/src/main/res/values-fr/03-dialogs.xml b/AnkiDroid/src/main/res/values-fr/03-dialogs.xml index 6bc3e03d6926..129822911760 100644 --- a/AnkiDroid/src/main/res/values-fr/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-fr/03-dialogs.xml @@ -44,7 +44,7 @@ ~ this program. If not, see . --> - This may take a long time. Proceed? + Cela peut prendre beaucoup de temps. Continuer? Supprimer le jeu de cartes Supprimer le paquet ? @@ -104,7 +104,7 @@ Erreur lors de l’analyse du certificat Certificat mis à jour - This may take a long time with large media collections. Proceed? + Cela peut prendre beaucoup de temps avec les grandes collections de médias. Continuer? Vérification des médias… Ajout d\'étiquettes… Étiquettes ajoutées @@ -271,12 +271,12 @@ Champs Spécial %1$s’]]> - The front template content. Audio is not automatically played - The full deck of the card, including parent decks%s - The current deck of the card, excluding parent decks%s + Contenu du template avant. L\'audio n\'est pas automatiquement lu + Le jeu complet de la carte, y compris les decks parents%s + Le jeu actuel de la carte, excluant les decks parents%s Outputs ‘%1$s’, where %2$s is the flag code (%3$s\–%4$s\) - The tags of the note%s - The ID of the card%s - The name of the card template%s - The name of the note type%s + Les tags de la note%s + L\'ID de la carte%s + Le nom du modèle de carte%s + Le nom du type de note%s diff --git a/AnkiDroid/src/main/res/values-ja/01-core.xml b/AnkiDroid/src/main/res/values-ja/01-core.xml index a65723da26a4..ca6fe4a0e24c 100644 --- a/AnkiDroid/src/main/res/values-ja/01-core.xml +++ b/AnkiDroid/src/main/res/values-ja/01-core.xml @@ -113,7 +113,7 @@ ノートのマークをはずす 発音チェック機能をオン 発音チェック機能をオフ - Create backup + バックアップを作成 デッキを新規作成 フィルターデッキを作成 AnkiDroidディレクトリにアクセスできません diff --git a/AnkiDroid/src/main/res/values-ja/02-strings.xml b/AnkiDroid/src/main/res/values-ja/02-strings.xml index d05d5edeecb0..40578d96b3df 100644 --- a/AnkiDroid/src/main/res/values-ja/02-strings.xml +++ b/AnkiDroid/src/main/res/values-ja/02-strings.xml @@ -54,7 +54,7 @@ ノートタイプ: タグ: %1$s カード: %1$s - Edit occlusions + 画像穴埋め問題を編集 クリップボードから貼り付け タグフィルター / 新規タグ追加 タグを追加 @@ -190,7 +190,7 @@ これは標準のスケジュールから外れて学習する特別なデッキです。 復習が終わったカードは自動的に元のデッキに戻ります。 このデッキを削除すると、残りのカードもすべて元のデッキに戻ります。 「%1$s」の追加を確定するには「%2$s」をタップしてください 既存のタグ「%1$s」を選択しました - Tag already exists + この名前のタグがすでに存在します Ankiファイル(*.apkg)の共有中にエラーが発生しました @@ -204,7 +204,7 @@ デッキリスト画面の背景 画像を選択 画像を背景から削除 - Are you sure you wish to remove the background? + デッキリスト画面に設定した背景画像を削除してもよろしいですか? デフォルトに戻す 空白 @@ -212,9 +212,9 @@ 消しゴム機能をオンにしました 消しゴム機能をオフにしました %1$s/%2$s - Media too large - The file \"%1$s\" (%2$s) exceeds the AnkiWeb limit of %3$s and cannot be synced. Add anyway? - Add anyway + メディアが大きすぎます + ファイル \"%1$s\" (%2$s) はAnkiWebで対応可能なファイルサイズの上限 (%3$s) を超えているため、このファイルをAnkiWebと同期することはできません。それでもこのファイルをノートに添付しますか? + それでも添付 ホワイトボード画像の保存に失敗しました。 %s ホワイトボードの画像を \"%s\" に保存しました @@ -240,20 +240,20 @@ フォントサイズ ツールバーを表示 - Format as bold - Format as italic - Format as underline - 横罫線を挿入する - Insert heading + 太字 + 斜体 + 下線 + 横罫線を挿入 + 見出し フォントサイズを変更 - Insert MathJax equation + MathJax式 ボタンに表示する文字 - HTML before selection - HTML after selection - Create toolbar item - Edit toolbar item + 選択文字列の前のHTML + 選択文字列の後のHTML + ツールバーアイテムを作成 + アイテムを編集 選択した文字列の前後に挿入するHTMLをそれぞれ入力してください\n\n作成したツールバーアイテムを編集または削除したい場合は、そのアイテムのボタンを長押ししてください - Remove toolbar item? + このツールバーアイテムを削除しますか? 画像が大きすぎます。手動で画像を挿入してください 動画ファイルが大きすぎます。手動で動画を挿入してください 音声ファイルが大きすぎます。手動で音声を挿入してください @@ -270,8 +270,8 @@ + - 無効な値です - Maximum value is %d - Minimum value is %d + 最大値は %d です + 最小値は %d です 有効なメールアドレスを入力してください パスワードが必要です @@ -331,7 +331,7 @@ カード ノート - Toggle cards/notes + 表示対象の切り替え リスト画面において、カードまたはノートの内容の表示を1件につき3行までに制限します ブラウザ オプション 録音を保存しました @@ -347,7 +347,7 @@ さらにデッキをダウンロードするにはログインしてください デッキ説明文 説明を更新しました - Format as markdown + Markdown形式でフォーマット 「%s」のヘルプを開く コピーに失敗しました @@ -361,7 +361,7 @@ 再生 次へ - Cannot delete card type + カードタイプを削除できません このカードタイプを削除すると、カードが無いノートが生じます。 読み上げ音声がサポートされていません。別の音声を試すか、音声エンジンをインストールしてください。 @@ -386,5 +386,5 @@ , - Name already exists + この名前のデッキがすでに存在します diff --git a/AnkiDroid/src/main/res/values-ja/03-dialogs.xml b/AnkiDroid/src/main/res/values-ja/03-dialogs.xml index 1da0453c674b..07e27cef99a8 100644 --- a/AnkiDroid/src/main/res/values-ja/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-ja/03-dialogs.xml @@ -44,7 +44,7 @@ ~ this program. If not, see . --> - This may take a long time. Proceed? + これには長い時間がかかることがあります。続行しますか? デッキを削除 デッキを削除しますか? @@ -80,7 +80,7 @@ 他を選択 キャンセル OK - Add + 追加 Restore 確認 いいえ @@ -245,7 +245,7 @@ 読み上げオプション もう一度ファイルをインポートしてください - あなたのデバイスに十分な空きがあることを確認してください + デバイスに十分な空き容量があることを確認してください インターネットに接続されていることを確認してください Copy the file to your device and try again with the local file Open the file using your device’s file browser app @@ -260,15 +260,15 @@ 保存する変更がありません 色を選択 - Fields - Special + フィールド + 特別フィールド %1$s’]]> - The front template content. Audio is not automatically played - The full deck of the card, including parent decks%s - The current deck of the card, excluding parent decks%s - Outputs ‘%1$s’, where %2$s is the flag code (%3$s\–%4$s\) - The tags of the note%s - The ID of the card%s - The name of the card template%s - The name of the note type%s + 表面テンプレートの内容。この中の音声は自動的に再生されません。 + このカードが所属するデッキ(親デッキも含む全体)%s + このカードが直に所属するデッキ(親デッキは含めない)%s + このカードのフラグの状態: %1$s(%2$s はフラグの状態を表す番号 %3$s\~%4$s\ の一つ) + ノートのタグ%s + カードのID%s + カードタイプの名前%s + ノートタイプの名前%s diff --git a/AnkiDroid/src/main/res/values-ja/10-preferences.xml b/AnkiDroid/src/main/res/values-ja/10-preferences.xml index e2857087ad95..3008448b691d 100644 --- a/AnkiDroid/src/main/res/values-ja/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-ja/10-preferences.xml @@ -220,7 +220,7 @@ 未設定の場合は、AnkiWebサーバーを同期に使用します。 Learn more ]]> - Sync URL + 同期URL カスタムルート証明書 (PEM) キーボード diff --git a/AnkiDroid/src/main/res/values-ja/17-model-manager.xml b/AnkiDroid/src/main/res/values-ja/17-model-manager.xml index e16f7c0dc428..82ec6fbf337f 100644 --- a/AnkiDroid/src/main/res/values-ja/17-model-manager.xml +++ b/AnkiDroid/src/main/res/values-ja/17-model-manager.xml @@ -72,8 +72,8 @@ 複製: %1$s カードブラウザでカードをリスト表示する際に使用するテンプレートを設定できます。これを利用すると、内容をより簡潔に表示することができます。\n\n例えば、\n「 {{Country}} の首都はどこですか?」\nという内容の表面テンプレートについて、\n「 {{Country}} の首都」\nという、リストの質問欄用のテンプレートを設定することによって、「フランス の首都」「ドイツ の首都」のように、カード表面の内容をコンパクトに示すことができます。 - Question format - Answer format + 質問欄テンプレート + 解答欄テンプレート Are you sure you want to restore the browser appearance to default? この画面での変更は、一つ手前の画面でカードテンプレートを保存した後にカードブラウザに適用されます。 diff --git a/AnkiDroid/src/main/res/values-pt-rBR/01-core.xml b/AnkiDroid/src/main/res/values-pt-rBR/01-core.xml index 568ee041b5b6..936d802c6bab 100644 --- a/AnkiDroid/src/main/res/values-pt-rBR/01-core.xml +++ b/AnkiDroid/src/main/res/values-pt-rBR/01-core.xml @@ -116,7 +116,7 @@ Desmarcar nota Ativar reprodução de voz Desativar reprodução de voz - Create backup + Criar backup Criar baralho Criar baralho com filtros Diretório do AnkiDroid está inacessível @@ -130,7 +130,7 @@ Esvaziar Criar sub-baralho Esvaziando baralho filtrado… - Deck search + Pesquisar baralho Este baralho está vazio Nome do baralho inválido Parabéns! Você terminou por hoje. @@ -214,8 +214,8 @@ Cartão instantâneo Internet - Required for the app to work - Please grant AnkiDroid the ‘%s’ permission to continue + Necessário para o aplicativo funcionar + Por favor, conceda ao AnkiDroid a permissão de ‘%s’ para continuar Adicionar perfil diff --git a/AnkiDroid/src/main/res/values-pt-rBR/02-strings.xml b/AnkiDroid/src/main/res/values-pt-rBR/02-strings.xml index e70b6902a8e4..395c1aac79c7 100644 --- a/AnkiDroid/src/main/res/values-pt-rBR/02-strings.xml +++ b/AnkiDroid/src/main/res/values-pt-rBR/02-strings.xml @@ -54,7 +54,7 @@ Tipo: Tags: %1$s Cartões: %1$s - Edit occlusions + Editar oclusões Colar da área de transferência Adicionar/filtrar tags Adicionar tag @@ -84,7 +84,7 @@ Desativar borracha Ocultar quadro Limpar quadro - Hide toolbar + Ocultar barra de ferramentas Repetição de mídia Cartão marcado como sanguessuga e suspenso Cartão marcado como sanguessuga @@ -99,9 +99,9 @@ Deslize para cancelar Bandeiras - Delay for again - Delay for hard - Delay for good + Novamente + Difícil + Bom Criando lista de tags… @@ -193,7 +193,7 @@ Esse é um baralho especial para estudar fora do planejamento normal. Os cards retornarão um a um para os baralhos originais após revisá-los. Ao apagar esse baralho os cards restantes retornarão automaticamente à seus baralhos originais. Toque \"%2$s\" para confirmar a adição de \"%1$s\" Tag existente \"%1$s\" selecionada - Tag already exists + Tag já existente Erro ao compartilhar arquivo apkg @@ -207,17 +207,17 @@ Plano de fundo Escolha uma imagem Remover imagem de fundo - Are you sure you wish to remove the background? + Tem certeza de que deseja remover o plano de fundo? - Restore default + Restaurar padrão Vazio Borracha ativada Borracha desativada %1$s/%2$s - Media too large - The file \"%1$s\" (%2$s) exceeds the AnkiWeb limit of %3$s and cannot be synced. Add anyway? - Add anyway + Mídia muito grande + O arquivo \"%1$s\" (%2$s) excede o limite de AnkiWeb do %3$s e não pode ser sincronizado. Adicionar mesmo assim? + Adicionar Falha ao salvar imagem do quadro. %s Imagem do quadro branco salva em %s @@ -243,20 +243,20 @@ Tamanho da fonte Mostrar barra de ferramentas - Format as bold - Format as italic - Format as underline - Insert horizontal line - Insert heading - Change font size - Insert MathJax equation + Formatar como Negrito + Formatar como Itálico + Formatar como Sublinhado + Inserir linha Horizontal + Inserir Título + Mudar Tamanho da Fonte + Inserir equação MathJax Texto do botão - HTML before selection - HTML after selection - Create toolbar item - Edit toolbar item + HTML antes da seleção + HTML depois da seleção + Criar item na Barra de Ferramentas + Editar item na Barra de Ferramentas Insira o código HTML que deve ser inserido antes e depois do texto selecionado\n\nMantenha o item pressionado para editá-lo ou removê-lo - Remove toolbar item? + Remover item na Barra de Ferramentas A imagem é muito grande, por favor insira a imagem manualmente O arquivo de vídeo é muito grande, por favor insira o vídeo manualmente O arquivo de áudio é muito grande, por favor insira o áudio manualmente @@ -268,7 +268,7 @@ Rolar a barra de funções URL - Certificate + Certificado + - @@ -336,7 +336,7 @@ Cards Notas - Toggle cards/notes + Alternar Cartões/Notas Trancar a altura de cada linha do Navegador para mostrar apenas as 3 primeiras linhas do conteúdo Opções do navegador Gravação salva @@ -352,7 +352,7 @@ Faça login para baixar mais baralhos Descrição Descrição atualizada - Format as markdown + Formatar com Markdown Abrir ajuda para ‘%s’ Falha ao copiar @@ -367,7 +367,7 @@ Reproduzir Próximo - Cannot delete card type + Não é possível excluir tipo de cartão Deletar essa categoria de cartão deixará algumas notas sem cartão Voz não suportada. Tente outra ou instale um mecanismo de voz. @@ -392,5 +392,5 @@ , - Name already exists + Nome já existente diff --git a/AnkiDroid/src/main/res/values-pt-rBR/03-dialogs.xml b/AnkiDroid/src/main/res/values-pt-rBR/03-dialogs.xml index 00e833af0c9e..3c9a49f80879 100644 --- a/AnkiDroid/src/main/res/values-pt-rBR/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-pt-rBR/03-dialogs.xml @@ -44,7 +44,7 @@ ~ this program. If not, see . --> - This may take a long time. Proceed? + Isso pode levar muito tempo. Prosseguir? Excluir baralho Excluir baralho? @@ -82,14 +82,14 @@ Procurar Cancelar OK - Add - Restore + Adicionar + Restaurar Confirmar Não Sim Manter Remover - Exit + Sair Continuar Processando… Criar @@ -104,7 +104,7 @@ Erro na análise do certificado. Certificado atualizado - This may take a long time with large media collections. Proceed? + Coleções de mídias grandes podem levar muito tempo. Prosseguir? Verificando mídia… Adicionando tags… Tags adiciondas @@ -266,12 +266,12 @@ If changing to a regular note type, and there are more cloze deletions than available card templates, any extra cards will be removed. Selecione notas de apenas um tipo de nota Nenhuma alteração para salvar - Choose color + Escolher Cor Fields Special - %1$s’]]> - The front template content. Audio is not automatically played + %1$s\']]> + O conteúdo do modelo inicial. O áudio não é reproduzido automaticamente. The full deck of the card, including parent decks%s The current deck of the card, excluding parent decks%s Outputs ‘%1$s’, where %2$s is the flag code (%3$s\–%4$s\) diff --git a/AnkiDroid/src/main/res/values-uk/01-core.xml b/AnkiDroid/src/main/res/values-uk/01-core.xml index cea3c6f77842..f409d0619dbb 100644 --- a/AnkiDroid/src/main/res/values-uk/01-core.xml +++ b/AnkiDroid/src/main/res/values-uk/01-core.xml @@ -122,7 +122,7 @@ Зняти позначку запису Увімкнути запис голосу Вимкнути запис голосу - Create backup + Створити резервну копію Створити колоду Створити фільтровану колоду Тека AnkiDroid недоступна @@ -224,8 +224,8 @@ Миттєве додання картки Інтернет - Необхідно для роботи програми - Будь ласка, надайте доступ до ‘%s’ для AnkiDroid + Необхідно для роботи додатку + Щоб продовжити, надайте AnkiDroid доступ до ‘%s’ Додати профіль diff --git a/AnkiDroid/src/main/res/values-uk/02-strings.xml b/AnkiDroid/src/main/res/values-uk/02-strings.xml index 5a7a207630d2..36f57b2fa0f6 100644 --- a/AnkiDroid/src/main/res/values-uk/02-strings.xml +++ b/AnkiDroid/src/main/res/values-uk/02-strings.xml @@ -84,7 +84,7 @@ Вимкнути гумку Сховати дошку Очистити дошку - Приховати головну панель + Приховати панель інструментів Відтворити медіафайл Картку позначено як набридливу і призупинено Картку позначено як набридливу @@ -99,9 +99,9 @@ Проведіть, щоб скасувати Прапорці - Затримка «Знову» - Затримка «Важко» - Затримка «Добре» + Затримка для «Знову» + Затримка для «Важко» + Затримка для «Добре» Створюється список тегів… @@ -169,7 +169,7 @@ Це не файл пакету Anki Помилка Не вдалося імпортувати пакет\n\n%s - Ім\'я пакету “%s” не містить закінчення .apkg чи .colpkg + Назва пакету “%s” не містить закінчення .apkg або .colpkg Заміна Anki Database (.anki2) ще не підтримується. Перегляньте у посібнику інструкцію по заміні. Вибраний файл не вдалося імпортувати автоматично до AnkiDroid. Будь ласка, перегляньте посібник користувача для того аби вручну імпортувати Anki файли: \n%s Не вдалося зберегти файл в кеш @@ -213,17 +213,17 @@ Фон Оберіть зображення Видалити фон - Are you sure you wish to remove the background? + Ви впевнені, що хочете видалити фон? - Скидання за замовчуванням + Відновити налаштування за замовчуванням Порожня Режим гумки активовано Режим гумки деактивовано %1$s/%2$s - Media too large - The file \"%1$s\" (%2$s) exceeds the AnkiWeb limit of %3$s and cannot be synced. Add anyway? - Add anyway + Медіафайл занадто великий + Файл \"%1$s\" (%2$s) перевищує ліміт AnkiWeb (до %3$s) і не може бути синхронізований. Додати все одно? + Все одно додати Не вдалося зберегти зображення дошки. %s Зображення дошки збережено до %s @@ -274,7 +274,7 @@ Панель прокручування URL - Certificate + Сертифікат + - @@ -340,7 +340,7 @@ AnkiDroid ще не ініціалізовано. Будь ласка, відкрийте AnkiDroid і спробуйте знову Створена колода - Перейменовано колоду + Колоду перейменовано Колоду було видалено. Будь ласка, видаліть ярлик @@ -362,7 +362,7 @@ Ввійдіть, будь ласка, до акаунту, щоб завантажити більше колод Опис Опис оновлено - Відформатувати як розмітку + Форматувати як Markdown Відкрити допомогу для ‘%s’ Не вдалося скопіювати @@ -404,5 +404,5 @@ , - Name already exists + Назва вже існує diff --git a/AnkiDroid/src/main/res/values-uk/03-dialogs.xml b/AnkiDroid/src/main/res/values-uk/03-dialogs.xml index a6af3ebcf4bf..ea5954b481ea 100644 --- a/AnkiDroid/src/main/res/values-uk/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-uk/03-dialogs.xml @@ -44,7 +44,7 @@ ~ this program. If not, see . --> - This may take a long time. Proceed? + Це може тривати довго. Продовжити? Видалити колоду Видалити колоду? @@ -86,14 +86,14 @@ Вибрати інший Скасувати Ок - Add - Restore + Додати + Відновити Підтвердити Ні Так Залишити Видалити - Вихід + Вийти Продовжити Обробка… Створити @@ -108,7 +108,7 @@ Помилка парсингу сертифікату Сертифікат оновлено - This may take a long time with large media collections. Proceed? + У випадку великих медіа-колекцій це може забрати багато часу. Продовжити? Перевіряємо мультимедіа… Додавання тегів… Теги додано @@ -282,17 +282,17 @@ Якщо ви змінюєте тип запису на звичайний, а кількість пропусків Cloze більша, ніж кількість доступних шаблонів карток, то надлишкові картки будуть видалені. Будь ласка, виберіть записи лише одного типу. Немає змін для збереження - Обрати колір + Виберіть колір Поля - Спеціально - %1$s]]> + Спеціальні + %1$s’]]> Вміст переднього шаблону. Аудіо не відтворюється автоматично - Повна колода карти, включаючи батьківські колоди%s - Поточна колода карти, без урахування батьківських колод%s - Outputs ‘%1$s’, where %2$s is the flag code (%3$s\–%4$s\) - Тег нотатку%s + Повна колода картки, включаючи батьківські колоди%s + Поточна колода картки, без урахування батьківських колод%s + Виводить ‘%1$s’, де %2$s це код прапорця (%3$s\–%4$s\) + Теги запису%s ID картки%s - Ім\'я шаблону картки%s - Назва типу нотатки%s + Назва шаблону картки%s + Назва типу запису%s diff --git a/AnkiDroid/src/main/res/values-uk/07-cardbrowser.xml b/AnkiDroid/src/main/res/values-uk/07-cardbrowser.xml index 14d96d729146..ba921d988a25 100644 --- a/AnkiDroid/src/main/res/values-uk/07-cardbrowser.xml +++ b/AnkiDroid/src/main/res/values-uk/07-cardbrowser.xml @@ -75,7 +75,7 @@ Оберіть збережений пошуковий запит Назва для пошукового запиту Не можна зберегти пошуковий запит без назви - Така назва вже існує! + Така назва вже існує Пошук збережено Немає записів для редагування Видалити «%1$s»? diff --git a/AnkiDroid/src/main/res/values-uk/09-backup.xml b/AnkiDroid/src/main/res/values-uk/09-backup.xml index edd79c8f6dea..09086cd034cf 100644 --- a/AnkiDroid/src/main/res/values-uk/09-backup.xml +++ b/AnkiDroid/src/main/res/values-uk/09-backup.xml @@ -51,7 +51,7 @@ Видалити колекцію та створити нову Ви дійсно бажаєте видалити колекцію та створити нову? Це скине ваш навчальний поступ та видалить всі картки! Одностороння синхронізація з сервера - Use default collection folder + Використовувати стандартну папку колекції Ви дійсно бажаєте перезаписати вашу колекцію версією з AnkiWeb? Це скине ваш навчальний поступ та додану інформації з часу останньої синхронізації! Обробка помилок Параметри diff --git a/AnkiDroid/src/main/res/values-uk/10-preferences.xml b/AnkiDroid/src/main/res/values-uk/10-preferences.xml index 0f040b41d700..a633c103858f 100644 --- a/AnkiDroid/src/main/res/values-uk/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-uk/10-preferences.xml @@ -227,7 +227,7 @@ Якщо нічого не вказано, буде використаний сервер AnkiWeb. Дізнатися більше ]]> - Sync URL + Синхронізувати URL Власний кореневий сертифікат (PEM) Клавіатура @@ -387,7 +387,7 @@ відгуки або помилки.]]> Показувати фідбек відповіді Екран - Тільки відповідь + Лише відповідь Перевернути та відповісти Відкрити налаштування diff --git a/AnkiDroid/src/main/res/values-uk/16-multimedia-editor.xml b/AnkiDroid/src/main/res/values-uk/16-multimedia-editor.xml index 1c5166d56887..ad9665720fb6 100644 --- a/AnkiDroid/src/main/res/values-uk/16-multimedia-editor.xml +++ b/AnkiDroid/src/main/res/values-uk/16-multimedia-editor.xml @@ -75,7 +75,7 @@ Вміст поля Це зображення надто велике для редактора. Зменште його розмір і повторіть спробу! Вибрати знову - Show audio waveform - Show field content - Delete recording + Показувати графік звукової хвилі + Показувати вміст поля + Видалити запис diff --git a/AnkiDroid/src/main/res/values-uk/17-model-manager.xml b/AnkiDroid/src/main/res/values-uk/17-model-manager.xml index 478d004e0173..2e91f25f3387 100644 --- a/AnkiDroid/src/main/res/values-uk/17-model-manager.xml +++ b/AnkiDroid/src/main/res/values-uk/17-model-manager.xml @@ -32,7 +32,7 @@ Ви не можете видалити останній тип записів Типи записів повинні містити хоча б одне поле - Необхідно ввести ім\'я + Необхідно ввести назву Назва поля вже існує @@ -78,9 +78,9 @@ Клонувати: %1$s Введіть шаблон, який використовуватиме переглядач карток для показу ваших карток. Використовуйте це для показу стислішої відповіді.\n\nШаблон передньої сторони\n\"Столицею {{Країна}} є:\"\nможна стиснути в переглядачі карток до\n\"Столиця:{{Країна}}\" - Question format - Answer format - Are you sure you want to restore the browser appearance to default? + Формат запитань + Формат відповіді + Ви впевнені, що хочете повернути стандартний вигляд переглядача карток? Зміни застосуються після збереження шаблону картки Перевизначення колоди (увімк.) diff --git a/AnkiDroid/src/main/res/values-uz/01-core.xml b/AnkiDroid/src/main/res/values-uz/01-core.xml index 127ecd796181..ed7c1b82a98d 100644 --- a/AnkiDroid/src/main/res/values-uz/01-core.xml +++ b/AnkiDroid/src/main/res/values-uz/01-core.xml @@ -116,7 +116,7 @@ Qayddan belgini olib tashlash Ovoz ijrosini yoqish Ovoz ijrosini oʻchirish - Create backup + Zaxira yaratish Dasta yaratish Filtrlangan dasta yaratish AnkiDroid katalogiga kirish imkonsiz diff --git a/AnkiDroid/src/main/res/values-uz/02-strings.xml b/AnkiDroid/src/main/res/values-uz/02-strings.xml index 0df39fd6f798..cf3eb26727b2 100644 --- a/AnkiDroid/src/main/res/values-uz/02-strings.xml +++ b/AnkiDroid/src/main/res/values-uz/02-strings.xml @@ -215,9 +215,9 @@ Oʻchirgʻich rejimi yoqildi Oʻchirgʻich rejimi oʻchirildi %1$s/%2$s - Media too large - The file \"%1$s\" (%2$s) exceeds the AnkiWeb limit of %3$s and cannot be synced. Add anyway? - Add anyway + Media fayl juda katta + \"%1$s\" (%2$s) fayli AnkiWebʼning %3$s limitidan oshadi va uni sinxronlab boʻlmaydi. Baribir qoʻshmoqchimisiz? + Baribir qoʻshish Yozuv taxtasi rasmini saqlab boʻlmadi. %s Yozuv taxtasi rasmi %s ga saqlandi @@ -268,7 +268,7 @@ Asboblar panelini aylantirish URL - Certificate + Sertifikat + - diff --git a/AnkiDroid/src/main/res/values-uz/03-dialogs.xml b/AnkiDroid/src/main/res/values-uz/03-dialogs.xml index 0f58e1a220cd..02a2539cf01a 100644 --- a/AnkiDroid/src/main/res/values-uz/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-uz/03-dialogs.xml @@ -44,7 +44,7 @@ ~ this program. If not, see . --> - This may take a long time. Proceed? + Bu uzoq vaqt olishi mumkin. Davom etmoqchimisiz? Dastani oʻchirish Dasta oʻchirilsinmi? @@ -104,7 +104,7 @@ Sertifikatni tekshirishda xatolik Sertifikat yangilandi - This may take a long time with large media collections. Proceed? + Juda katta media kolleksiya uchun uzoq vaqt olishi mumkin. Davom etmoqchimisiz? Media fayllar tekshirilmoqda… Teglar qoʻshilmoqda Teglar qoʻshildi diff --git a/AnkiDroid/src/main/res/values-uz/09-backup.xml b/AnkiDroid/src/main/res/values-uz/09-backup.xml index 05cd7c8a5ec2..04802e9e4daf 100644 --- a/AnkiDroid/src/main/res/values-uz/09-backup.xml +++ b/AnkiDroid/src/main/res/values-uz/09-backup.xml @@ -51,7 +51,7 @@ Kolleksiyani oʻchirib yangisini yaratish Kolleksiyani oʻchirib yangisi yaratmoqchimisiz? Barcha oʻrganish progressingizni yoʻqotasiz va barcha kartalarni oʻchiradi. Serverdan bir tomonlama sinxronlash - Use default collection folder + Birlamchi kolleksiya jildini ishlatish Kolleksiyangiz AnkiWeb kolleksiyasi bilan almashtirilsinmi? Oxirgi sinxronizatsiyadan keyingi barcha oʻrganish progressingiz va qoʻshilgan maʼlumotlaringiz yoʻqoladi. Xatolikni bartaraf qilish Parametrlar diff --git a/AnkiDroid/src/main/res/values-uz/10-preferences.xml b/AnkiDroid/src/main/res/values-uz/10-preferences.xml index d9e91a5bc286..dae9282dbb57 100644 --- a/AnkiDroid/src/main/res/values-uz/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-uz/10-preferences.xml @@ -222,7 +222,7 @@ Agar sozlanmagan boʻlsa, oʻrniga birlamchi AnkiWeb serveri ishlatiladi. Batafsil maʼlumot ]]> - Sync URL + Sinxronlash URLʼi Moslashtirilgan root sertifikati (PEM) Klaviatura diff --git a/AnkiDroid/src/main/res/values-zh-rCN/10-preferences.xml b/AnkiDroid/src/main/res/values-zh-rCN/10-preferences.xml index 9d4b8d6f0c0a..3c14b6b4e95c 100644 --- a/AnkiDroid/src/main/res/values-zh-rCN/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-zh-rCN/10-preferences.xml @@ -220,7 +220,7 @@ 如果未设置,将使用默认AnkiWeb服务器。 了解更多 ]]> - Sync URL + 同步 URL 自定义根证书 (PEM) 键盘 From 82acd19a7b7f48e065b0789ec5e9aed15aca71aa Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Sat, 13 Dec 2025 03:59:56 +0530 Subject: [PATCH 25/87] AudioTimer to use Coroutines and precise time tracking - Replaces the legacy Handler/Runnable implementation with Kotlin Coroutines to improve timing accuracy, lifecycle safety, and code readability. - remove interface and use lambdas - test: Added AudioTimer unit tests --- .../audio/AudioRecordingController.kt | 74 ++++--- .../ichi2/anki/multimedia/audio/AudioTimer.kt | 96 --------- .../com/ichi2/anki/recorder/AudioTimer.kt | 154 ++++++++++++++ .../com/ichi2/anki/recorder/AudioTimerTest.kt | 193 ++++++++++++++++++ 4 files changed, 392 insertions(+), 125 deletions(-) delete mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/multimedia/audio/AudioTimer.kt create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/recorder/AudioTimer.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/recorder/AudioTimerTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/audio/AudioRecordingController.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/audio/AudioRecordingController.kt index 0dae5bc09df3..bb65bd35b20b 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/audio/AudioRecordingController.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/audio/AudioRecordingController.kt @@ -29,9 +29,12 @@ import android.view.WindowManager import android.widget.LinearLayout import android.widget.ScrollView import android.widget.TextView +import androidx.annotation.CheckResult import androidx.annotation.LayoutRes import androidx.core.content.ContextCompat import androidx.core.view.isVisible +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope import com.google.android.material.button.MaterialButton import com.google.android.material.imageview.ShapeableImageView import com.google.android.material.progressindicator.LinearProgressIndicator @@ -48,6 +51,7 @@ import com.ichi2.anki.multimedia.audio.AudioRecordingController.RecordingState.A import com.ichi2.anki.multimedia.audio.AudioRecordingController.RecordingState.ImmediatePlayback import com.ichi2.anki.multimediacard.IMultimediaEditableNote import com.ichi2.anki.recorder.AudioRecorder +import com.ichi2.anki.recorder.AudioTimer import com.ichi2.anki.showThemedToast import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.ui.OnHoldListener @@ -56,6 +60,8 @@ import com.ichi2.anki.utils.elapsed import com.ichi2.ui.FixedTextView import com.ichi2.utils.Permissions.canRecordAudio import com.ichi2.utils.UiUtil +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import timber.log.Timber import java.io.File import java.io.IOException @@ -72,8 +78,7 @@ class AudioRecordingController( val linearLayout: LinearLayout? = null, val viewModel: MultimediaViewModel? = null, val note: IMultimediaEditableNote? = null, -) : AudioTimer.OnTimerTickListener, - AudioTimer.OnAudioTickListener { +) { private lateinit var audioRecorder: AudioRecorder private var state: RecordingState = AppendToRecording.CLEARED @@ -203,7 +208,7 @@ class AudioRecordingController( setUpMediaPlayer() - audioTimer = AudioTimer(this, this) + audioTimer = setupAudioTimer(context) // if the recorder is in the 'cleared' state // holding the 'record' button should start a recording @@ -310,6 +315,42 @@ class AudioRecordingController( } } + @CheckResult + private fun setupAudioTimer(context: Context): AudioTimer { + fun onRecordingTimerTick(duration: Duration) { + if (isPlaying && !isRecording) { + // This may remain at 0 for a few hundred ms while the audio player starts + // BUG: It takes 300ms from elapsed == duration -> onCompletionListener being called + // probably best to move onCompletionListener to here + val elapsed = audioPlayer?.elapsed ?: Duration.ZERO + audioProgressBar.progress = elapsed.inWholeMilliseconds.toInt() + audioTimeView?.text = elapsed.formatAsString() + } else { + audioTimeView?.text = duration.formatAsString() + audioProgressBar.progress = 0 + } + } + + fun onRecordingAudioTick() { + try { + if (isRecording) { + val maxAmplitude = audioRecorder.getMaxAmplitude() / 10 + audioWaveform.addAmplitude(maxAmplitude.toFloat()) + } + } catch (e: IllegalStateException) { + Timber.d(e, "Audio recorder interrupted") + } + } + + return AudioTimer( + scope = + (context as? LifecycleOwner)?.lifecycleScope + ?: CoroutineScope(Dispatchers.Main), + onTimerTick = ::onRecordingTimerTick, + onAudioTick = ::onRecordingAudioTick, + ) + } + private fun setUpMediaPlayer() { try { if (audioPlayer == null) { @@ -472,7 +513,7 @@ class AudioRecordingController( } catch (e: Exception) { Timber.w(e) } - audioTimer = AudioTimer(this, this) + audioTimer = setupAudioTimer(context) } private fun prepareAudioPlayer() { @@ -696,31 +737,6 @@ class AudioRecordingController( } } - override fun onTimerTick(duration: Duration) { - if (isPlaying && !isRecording) { - // This may remain at 0 for a few hundred ms while the audio player starts - // BUG: It takes 300ms from elapsed == duration -> onCompletionListener being called - // probably best to move onCompletionListener to here - val elapsed = audioPlayer!!.elapsed - audioProgressBar.progress = elapsed.inWholeMilliseconds.toInt() - audioTimeView?.text = elapsed.formatAsString() - } else { - audioTimeView?.text = duration.formatAsString() - audioProgressBar.progress = 0 - } - } - - override fun onAudioTick() { - try { - if (isRecording) { - val maxAmplitude = audioRecorder.getMaxAmplitude() / 10 - audioWaveform.addAmplitude(maxAmplitude.toFloat()) - } - } catch (e: IllegalStateException) { - Timber.d(e, "Audio recorder interrupted") - } - } - /** * @see Compat.vibrate */ diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/audio/AudioTimer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/audio/AudioTimer.kt deleted file mode 100644 index f517bcc540dc..000000000000 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/audio/AudioTimer.kt +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright (c) 2023 Ashish Yadav - * - * This program is free software; you can redistribute it and/or modify it under - * the terms of the GNU General Public License as published by the Free Software - * Foundation; either version 3 of the License, or (at your option) any later - * version. - * - * This program is distributed in the hope that it will be useful, but WITHOUT ANY - * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A - * PARTICULAR PURPOSE. See the GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License along with - * this program. If not, see . - */ -package com.ichi2.anki.multimedia.audio - -import android.os.Handler -import android.os.Looper -import com.ichi2.anki.utils.postDelayed -import kotlin.time.Duration -import kotlin.time.Duration.Companion.milliseconds - -/** - * AudioTimer class is a utility for managing timing operations when playing audio. - * It includes a Handler for scheduling tasks, and a Runnable for incrementing the duration and - * triggering a callback to a listener at regular intervals. - * [OnTimerTickListener.onTimerTick] notifies components about the timer's progress. - **/ -class AudioTimer( - listener: OnTimerTickListener, - audioWaveListener: OnAudioTickListener, -) { - private var audioTimeHandler = Handler(Looper.getMainLooper()) - private var audioTimeRunnable: Runnable - - // we use a different handler to audio waveform as 16L is too fast - private var audioWaveHandler = Handler(Looper.getMainLooper()) - private var audioWaveRunnable: Runnable - - private var audioTimeDuration = 0.milliseconds - private var audioTimeDelay = 16.milliseconds - private var audioWaveDuration = 0L - private var audioWaveDelay = 50L - - init { - audioTimeRunnable = - object : Runnable { - override fun run() { - audioTimeDuration += audioTimeDelay - audioTimeHandler.postDelayed(this, audioTimeDelay) - listener.onTimerTick(audioTimeDuration) - } - } - - audioWaveRunnable = - object : Runnable { - override fun run() { - audioWaveDuration += audioWaveDelay - audioWaveHandler.postDelayed(this, audioWaveDelay) - audioWaveListener.onAudioTick() - } - } - } - - fun start() { - audioWaveHandler.postDelayed(audioWaveRunnable, audioWaveDelay) - audioTimeHandler.postDelayed(audioTimeRunnable, audioTimeDelay) - } - - fun pause() { - audioWaveHandler.removeCallbacks(audioWaveRunnable) - audioTimeHandler.removeCallbacks(audioTimeRunnable) - } - - fun stop() { - audioWaveHandler.removeCallbacks(audioWaveRunnable) - audioTimeHandler.removeCallbacks(audioTimeRunnable) - audioTimeDuration = 0.milliseconds - audioWaveDuration = 0L - } - - fun start(customDuration: Duration) { - audioTimeHandler.removeCallbacks(audioTimeRunnable) - audioTimeDuration = customDuration - audioTimeHandler.postDelayed(audioTimeRunnable, audioTimeDelay) - } - - interface OnTimerTickListener { - fun onTimerTick(duration: Duration) - } - - interface OnAudioTickListener { - fun onAudioTick() - } -} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/recorder/AudioTimer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/recorder/AudioTimer.kt new file mode 100644 index 000000000000..b9780b7b5289 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/recorder/AudioTimer.kt @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2023 Ashish Yadav + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.anki.recorder + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeMark +import kotlin.time.TimeSource + +/** + * A timer utility for audio recording and playback + * + * It manages three independent update loops on the provided [kotlinx.coroutines.CoroutineScope]: + * 1. **UI Timer (~16ms):** High-frequency updates for smooth text counters (e.g., 00:01.45). + * 2. **Waveform (~50ms):** Medium-frequency updates for visualizers. + * 3. **Notification (~1000ms):** Optional low-frequency updates for system notifications. + * + * @param scope A lifecycle-aware scope (e.g., `lifecycleScope`) which ensures timers are automatically cancelled when the UI is destroyed. + * @param onTimerTick Lambda invoked every ~16ms with the precise [kotlin.time.Duration] elapsed. + * @param onAudioTick Lambda invoked every ~50ms to trigger waveform visualization updates. + * @param onNotificationTick Optional lambda invoked every 1 second. If null, this loop is not started. + */ +class AudioTimer( + private val scope: CoroutineScope, + private val onTimerTick: (Duration) -> Unit, + private val onAudioTick: () -> Unit, + private val onNotificationTick: ((Duration) -> Unit)? = null, + private val timeSource: TimeSource = TimeSource.Monotonic, +) { + private var timerJob: Job? = null + private var accumulatedDuration: Duration = Duration.ZERO + + // The point in time when the current recording session started (null if not running) + private var sessionStartTime: TimeMark? = null + + /** + * Starts the timer from the current accumulated duration. + * idempotent: calling this while running does nothing. + */ + fun start() { + synchronized(this) { + if (timerJob?.isActive == true) return + + sessionStartTime = timeSource.markNow() + + // A parent job to manage all tick loops concurrently + timerJob = + scope.launch { + launchUiLoop() + launchWaveformLoop() + launchNotificationLoop() + } + } + } + + private fun CoroutineScope.launchUiLoop() = + launch { + while (isActive) { + onTimerTick(calculateDuration()) + delay(UI_TICK_DELAY) + } + } + + private fun CoroutineScope.launchWaveformLoop() = + launch { + while (isActive) { + onAudioTick() + delay(WAVEFORM_TICK_DELAY) + } + } + + private fun CoroutineScope.launchNotificationLoop() { + onNotificationTick?.let { callback -> + launch { + while (isActive) { + delay(NOTIFICATION_TICK_DELAY) + callback(calculateDuration()) + } + } + } + } + + /** + * Resets the timer to a specific duration and starts it immediately. + * Useful when resuming an existing recording. + */ + fun start(fromDuration: Duration) = + synchronized(this) { + timerJob?.cancel() + timerJob = null + + accumulatedDuration = fromDuration + sessionStartTime = null + + start() + } + + /** + * Pauses the timer, saving the current accumulated duration. + */ + fun pause() = + synchronized(this) { + accumulatedDuration = calculateDuration() + timerJob?.cancel() + timerJob = null + sessionStartTime = null + } + + /** + * Stops the timer and resets the duration to zero. + */ + fun stop() { + synchronized(this) { + timerJob?.cancel() + timerJob = null + accumulatedDuration = Duration.ZERO + sessionStartTime = null + } + + onTimerTick(Duration.ZERO) + } + + private fun calculateDuration(): Duration { + // Total = (Saved Time) + (Time since start button pressed) + val currentSession = sessionStartTime?.elapsedNow() ?: Duration.ZERO + return accumulatedDuration + currentSession + } + + companion object { + private val UI_TICK_DELAY = 16.milliseconds + private val WAVEFORM_TICK_DELAY = 50.milliseconds + private val NOTIFICATION_TICK_DELAY = 1.seconds + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/recorder/AudioTimerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/recorder/AudioTimerTest.kt new file mode 100644 index 000000000000..0cb735994be5 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/recorder/AudioTimerTest.kt @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2025 Ashish Yadav + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.anki.recorder + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runTest +import org.hamcrest.CoreMatchers.both +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.greaterThanOrEqualTo +import org.hamcrest.Matchers.lessThanOrEqualTo +import org.junit.Assert.assertEquals +import org.junit.Test +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.nanoseconds +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeMark +import kotlin.time.TimeSource + +class AudioTimerTest { + private val fakeClock = + object : TimeSource { + var currentNanos = 0L + + override fun markNow(): TimeMark = + object : TimeMark { + val startNanos = currentNanos + + override fun elapsedNow(): Duration = (currentNanos - startNanos).nanoseconds + + override fun plus(duration: Duration): TimeMark = this + + override fun minus(duration: Duration): TimeMark = this + + override fun hasPassedNow(): Boolean = elapsedNow() > Duration.ZERO + + override fun hasNotPassedNow(): Boolean = !hasPassedNow() + } + + fun advance(duration: Duration) { + currentNanos += duration.inWholeNanoseconds + } + } + + private fun TestScope.advanceTime(duration: Duration) { + fakeClock.advance(duration) + advanceTimeBy(duration.inWholeMilliseconds) + } + + private fun TestScope.createTimer( + scope: CoroutineScope = backgroundScope, + onTimerTick: (Duration) -> Unit = {}, + onAudioTick: () -> Unit = {}, + onNotificationTick: ((Duration) -> Unit)? = null, + ): AudioTimer = + AudioTimer( + scope = scope, + timeSource = fakeClock, + onTimerTick = onTimerTick, + onAudioTick = onAudioTick, + onNotificationTick = onNotificationTick, + ) + + @Test + fun `start triggers high frequency UI updates`() = + runTest { + val timerTicks = mutableListOf() + val waveTicks = mutableListOf() + + val timer = + createTimer( + onTimerTick = { timerTicks.add(it) }, + onAudioTick = { waveTicks.add(Unit) }, + ) + + timer.start() + advanceTime(100.milliseconds) + + assertThat( + "UI ticks should fire approx every 16ms", + timerTicks.size, + both(greaterThanOrEqualTo(5)).and(lessThanOrEqualTo(7)), + ) + + assertThat( + "Wave ticks should fire approx every 50ms", + waveTicks.size, + both(greaterThanOrEqualTo(1)).and(lessThanOrEqualTo(3)), + ) + + assertEquals("Last tick should match elapsed time", 100.milliseconds, timerTicks.last()) + } + + @Test + fun `notification tick fires exactly once per second`() = + runTest { + var notificationCount = 0 + + val timer = + createTimer( + onNotificationTick = { notificationCount++ }, + ) + + timer.start() + advanceTime(2500.milliseconds) + + assertEquals( + "Should fire twice in 2.5 seconds (at 1s and 2s marks)", + 2, + notificationCount, + ) + } + + @Test + fun `resume continues from paused duration`() = + runTest { + val timerTicks = mutableListOf() + val timer = + createTimer( + onTimerTick = { timerTicks.add(it) }, + ) + + timer.start() + advanceTime(2.seconds) + + timer.pause() + assertEquals("Should capture duration at pause", 2.seconds, timerTicks.last()) + + advanceTime(5.seconds) + + timer.start() + advanceTime(1.seconds) + + assertEquals( + "Should resume from 2s + 1s new run time (ignoring the 5s pause)", + 3.seconds, + timerTicks.last(), + ) + } + + @Test + fun `start with custom duration seeks correctly`() = + runTest { + val timerTicks = mutableListOf() + val timer = + createTimer( + onTimerTick = { timerTicks.add(it) }, + ) + + timer.start(50.seconds) + advanceTime(16.milliseconds) + + assertEquals( + "Should start counting from the provided base duration", + 50.seconds + 16.milliseconds, + timerTicks.last(), + ) + } + + @Test + fun `stop resets everything`() = + runTest { + var lastDuration: Duration = (-1).seconds + val timer = + createTimer( + onTimerTick = { lastDuration = it }, + ) + + timer.start() + advanceTime(1.seconds) + assertEquals(1.seconds, lastDuration) + + timer.stop() + + assertEquals("Stop should reset duration to ZERO", Duration.ZERO, lastDuration) + } +} From b04b726c1157e518a100a62c80d5480d398ec36b Mon Sep 17 00:00:00 2001 From: vinay-singh-dev Date: Sun, 15 Mar 2026 14:34:22 +0530 Subject: [PATCH 26/87] feat(card-template-editor): better menu text * update menu text: * 'Add' -> 'Add card type' * 'Rename' -> 'Rename card type' * 'Delete' -> 'Delete card type' Backend strings contained '...' and therefore were not used. maxLength="28" is required for menu titles Co-authored-by: vinay-singh-dev --- AnkiDroid/src/main/res/menu/card_template_editor.xml | 6 +++--- AnkiDroid/src/main/res/values/17-model-manager.xml | 5 ++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/AnkiDroid/src/main/res/menu/card_template_editor.xml b/AnkiDroid/src/main/res/menu/card_template_editor.xml index 161d4ce25eba..b7a4609d4213 100644 --- a/AnkiDroid/src/main/res/menu/card_template_editor.xml +++ b/AnkiDroid/src/main/res/menu/card_template_editor.xml @@ -15,7 +15,7 @@ android:visible="true"/> Rename note type - Rename card type + Rename card type Set language hint to %s @@ -48,6 +48,9 @@ Updating fields Sort by this field + + Delete card type + Add card type Are you sure you wish to delete this note type? From 61dca606854ec6768acbf968233dea850ac4fc8a Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:50:47 +0000 Subject: [PATCH 27/87] feat(card-template-editor): better dialog titles * 'Delete card type' * 'Add card type' Co-authored-by: vinay-singh-dev --- .../src/main/java/com/ichi2/anki/CardTemplateEditor.kt | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt index 7fffcb966735..8dcd3506e999 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt @@ -1361,7 +1361,10 @@ open class CardTemplateEditor : numAffectedCards, tmpl.jsonObject.optString("name"), ) - d.setArgs(msg) + d.setArgs( + title = getString(R.string.delete_card_type), + message = msg, + ) val deleteCard = Runnable { deleteTemplate(tmpl, notetype) } val confirm = Runnable { executeWithSyncCheck(deleteCard) } @@ -1387,7 +1390,10 @@ open class CardTemplateEditor : ), numAffectedCards, ) - d.setArgs(msg) + d.setArgs( + title = getString(R.string.add_card_type), + message = msg, + ) val addCard = Runnable { addNewTemplate(notetype) } val confirm = Runnable { executeWithSyncCheck(addCard) } From 5d7d4be5bf424a7724b8ded477b73ff277bf40a4 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 24 Mar 2026 12:53:15 +0000 Subject: [PATCH 28/87] feat(card-template-editor): better dialog buttons instead of 'OK' * 'Delete card type' => 'Delete' * 'Add card type' => 'Add' Co-authored-by: vinay-singh-dev --- .../java/com/ichi2/anki/CardTemplateEditor.kt | 2 ++ .../ichi2/anki/dialogs/ConfirmationDialog.kt | 26 ++++++++++++++----- 2 files changed, 22 insertions(+), 6 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt index 8dcd3506e999..6c17a6d67b9b 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt @@ -1364,6 +1364,7 @@ open class CardTemplateEditor : d.setArgs( title = getString(R.string.delete_card_type), message = msg, + positiveButtonText = getString(R.string.dialog_positive_delete), ) val deleteCard = Runnable { deleteTemplate(tmpl, notetype) } @@ -1393,6 +1394,7 @@ open class CardTemplateEditor : d.setArgs( title = getString(R.string.add_card_type), message = msg, + positiveButtonText = getString(R.string.menu_add), ) val addCard = Runnable { addNewTemplate(notetype) } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ConfirmationDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ConfirmationDialog.kt index 0eeadb924181..c2d361229b56 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ConfirmationDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ConfirmationDialog.kt @@ -31,21 +31,31 @@ import com.ichi2.utils.title * Create a new instance, call setArgs(...), setConfirm(), and setCancel() then show it via the fragment manager as usual. */ class ConfirmationDialog : DialogFragment() { + private val positiveButtonText: String? + get() = requireArguments().getString(ARG_POSITIVE_BUTTON_TEXT) + private var confirm = Runnable {} // Do nothing by default private var cancel = Runnable {} // Do nothing by default fun setArgs(message: String?) { - setArgs("", message) + setArgs( + title = "", + message = message, + positiveButtonText = null, + ) } fun setArgs( title: String?, message: String?, + positiveButtonText: String? = null, ) { - val args = Bundle() - args.putString("message", message) - args.putString("title", title) - arguments = args + arguments = + Bundle().apply { + putString("title", title) + putString("message", message) + putString(ARG_POSITIVE_BUTTON_TEXT, positiveButtonText) + } } fun setConfirm(confirm: Runnable) { @@ -63,7 +73,7 @@ class ConfirmationDialog : DialogFragment() { return AlertDialog.Builder(requireContext()).create { title(text = (if ("" == title) res.getString(R.string.app_name) else title)!!) message(text = requireArguments().getString("message")!!) - positiveButton(R.string.dialog_ok) { + positiveButton(text = positiveButtonText ?: getString(R.string.dialog_ok)) { confirm.run() } negativeButton(R.string.dialog_cancel) { @@ -71,4 +81,8 @@ class ConfirmationDialog : DialogFragment() { } } } + + companion object { + private const val ARG_POSITIVE_BUTTON_TEXT = "positiveButtonText" + } } From d55f601c83fef77af7af0198ffb06a002c13d70c Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 24 Mar 2026 13:27:44 +0000 Subject: [PATCH 29/87] docs: 'Rename card template' -> 'Rename card type' Anki uses 'Card Type', even though the string is 'tmpls' --- AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt | 4 ++-- .../{RenameCardTemplateDialog.kt => RenameCardTypeDialog.kt} | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) rename AnkiDroid/src/main/java/com/ichi2/anki/notetype/{RenameCardTemplateDialog.kt => RenameCardTypeDialog.kt} (98%) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt index 6c17a6d67b9b..9c92f80d501c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt @@ -90,7 +90,7 @@ import com.ichi2.anki.libanki.getStockNotetype import com.ichi2.anki.libanki.getStockNotetypeKinds import com.ichi2.anki.libanki.utils.append import com.ichi2.anki.model.SelectableDeck -import com.ichi2.anki.notetype.RenameCardTemplateDialog +import com.ichi2.anki.notetype.RenameCardTypeDialog import com.ichi2.anki.notetype.RepositionCardTemplateDialog import com.ichi2.anki.observability.undoableOp import com.ichi2.anki.previewer.TemplatePreviewerArguments @@ -834,7 +834,7 @@ open class CardTemplateEditor : val ordinal = templateEditor.ord val template = templateEditor.tempNoteType!!.getTemplate(ordinal) - RenameCardTemplateDialog.showInstance( + RenameCardTypeDialog.showInstance( requireContext(), prefill = template.name, ) { newName -> diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/RenameCardTemplateDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/RenameCardTypeDialog.kt similarity index 98% rename from AnkiDroid/src/main/java/com/ichi2/anki/notetype/RenameCardTemplateDialog.kt rename to AnkiDroid/src/main/java/com/ichi2/anki/notetype/RenameCardTypeDialog.kt index 386507cf6243..283e7cfc6170 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/RenameCardTemplateDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/RenameCardTypeDialog.kt @@ -26,7 +26,7 @@ import com.ichi2.utils.positiveButton import com.ichi2.utils.show import com.ichi2.utils.title -class RenameCardTemplateDialog { +class RenameCardTypeDialog { companion object { fun showInstance( context: Context, From 283db1d60f8e915aa39e25fb6be0fd6c090ccfd1 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 24 Mar 2026 13:03:26 +0000 Subject: [PATCH 30/87] fix(rename-card-type): better 'hint' text Removes the ':' suffix Co-authored-by: vinay-singh-dev --- .../main/java/com/ichi2/anki/notetype/RenameCardTypeDialog.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/RenameCardTypeDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/RenameCardTypeDialog.kt index 283e7cfc6170..a2e89ec64b22 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/RenameCardTypeDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/RenameCardTypeDialog.kt @@ -41,7 +41,7 @@ class RenameCardTypeDialog { negativeButton(R.string.dialog_cancel) setView(R.layout.dialog_generic_text_input) }.input( - hint = CollectionManager.TR.actionsNewName(), + hint = CollectionManager.TR.actionsNewName().removeSuffix(":"), displayKeyboard = true, allowEmpty = false, prefill = prefill, From e635d582eef101b5872cc759b91e839408d9c531 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 24 Mar 2026 13:05:55 +0000 Subject: [PATCH 31/87] fix(rename-card-type): trim input Co-authored-by: vinay-singh-dev --- .../main/java/com/ichi2/anki/notetype/RenameCardTypeDialog.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/RenameCardTypeDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/RenameCardTypeDialog.kt index a2e89ec64b22..ef2a3657ce59 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/RenameCardTypeDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/RenameCardTypeDialog.kt @@ -47,7 +47,7 @@ class RenameCardTypeDialog { prefill = prefill, waitForPositiveButton = true, callback = { dialog, result -> - block(result.toString()) + block(result.toString().trim()) dialog.dismiss() }, ) From cd49b402e0cdc1b52164dc0b65be200eaa8dd740 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 24 Mar 2026 13:01:23 +0000 Subject: [PATCH 32/87] feat(dialog): add input validation Add a 'validator' parameter which can display an error Co-authored-by: vinay-singh-dev --- .../java/com/ichi2/utils/AlertDialogFacade.kt | 66 ++++++++++++++----- 1 file changed, 50 insertions(+), 16 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/AlertDialogFacade.kt b/AnkiDroid/src/main/java/com/ichi2/utils/AlertDialogFacade.kt index f60f72ecb42b..b0b0076e25c9 100644 --- a/AnkiDroid/src/main/java/com/ichi2/utils/AlertDialogFacade.kt +++ b/AnkiDroid/src/main/java/com/ichi2/utils/AlertDialogFacade.kt @@ -52,6 +52,30 @@ import timber.log.Timber /** Wraps [DialogInterface.OnClickListener] as we don't need the `which` parameter */ typealias DialogInterfaceListener = (DialogInterface) -> Unit +/** + * - [ValidationResult.VALID] - user may proceed + * - [ValidationResult.REJECTED] - user may not proceed (no error) + * - [ValidationResult.error] - `error` is displayed to the user + */ +@JvmInline +value class ValidationResult private constructor( + val error: String?, +) { + companion object { + /** The user may proceed */ + val VALID = ValidationResult(null) + + /** + * The user may not proceed; no error displayed + * + * Typically for 'obvious' issues, such as not changing a name + */ + val REJECTED = ValidationResult("") + + fun error(message: String) = ValidationResult(message) + } +} + fun DialogInterfaceListener.toClickListener(): OnClickListener = OnClickListener { dialog: DialogInterface, _ -> this(dialog) } /* @@ -301,6 +325,7 @@ fun AlertDialog.Builder.customListAdapterWithDecoration( * @param maxLength if set, the user may not enter more than the supplied number of digits * @param inputType see [EditText.setInputType] * @param waitForPositiveButton MaterialDialog compat: if `false` [callback] is called on input + * @param validator see [ValidationResult]. Valid if `null`, an error is shown if non-null. * if `true` [callback] is called when [positiveButton] is pressed */ fun AlertDialog.input( @@ -311,6 +336,7 @@ fun AlertDialog.input( maxLength: Int? = null, displayKeyboard: Boolean = false, waitForPositiveButton: Boolean = true, + validator: ((String) -> ValidationResult)? = null, callback: (AlertDialog, CharSequence) -> Unit, ): AlertDialog { // Builder.setView() may not be called before show() @@ -325,25 +351,33 @@ fun AlertDialog.input( inputType?.let { this.inputType = it } - if (!waitForPositiveButton) { - doOnTextChanged { text, _, _, _ -> - callback(this@input, text ?: "") + doOnTextChanged { text, _, _, _ -> + val input = text?.toString() ?: "" + + // handle allowEmpty + if (!allowEmpty && input.isEmpty()) { + this@input.getInputTextLayout().error = null + this@input.positiveButton.isEnabled = false + return@doOnTextChanged + } + + // handle validation errors + val validationError = validator?.invoke(input) + this@input.getInputTextLayout().error = validationError?.error + this@input.positiveButton.isEnabled = validationError == null + if (validationError != null) return@doOnTextChanged + + // no errors, see if we should fire the callback on every keypress + // TODO: this was used to perform additional validation, which should be moved to the + // 'validator' parameter, + if (!waitForPositiveButton) { + callback(this@input, input) } - } else { - positiveButton.setOnClickListener { callback(this@input, this.text.toString()) } } - if (!allowEmpty) { - // this is called after callback() so allowEmpty takes priority - doOnTextChanged { text, _, _, _ -> - if (waitForPositiveButton) { - // this is the only validation filter we apply - toggle on or off - this@input.positiveButton.isEnabled = !text.isNullOrEmpty() - } else if (text.isNullOrEmpty()) { - // potentially other filters in `waitForPositiveButton`. - // WARN: this could be buggy as it does not toggle the button back on - this@input.positiveButton.isEnabled = false - } + if (waitForPositiveButton) { + positiveButton.setOnClickListener { + callback(this@input, this.text.toString()) } } From 4633095448ea6fea9baa4965cda40249b8367591 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 15 Mar 2026 14:34:22 +0530 Subject: [PATCH 33/87] feat(rename-card-type): validation * Ensure the name is modified * Ensure an existing name can't be used Co-authored-by: vinay-singh-dev --- .../java/com/ichi2/anki/CardTemplateEditor.kt | 11 ++++- .../com/ichi2/anki/notetype/CardTypeName.kt | 43 +++++++++++++++++++ .../anki/notetype/RenameCardTypeDialog.kt | 22 ++++++++-- 3 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/notetype/CardTypeName.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt index 9c92f80d501c..529c897defa8 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt @@ -90,6 +90,7 @@ import com.ichi2.anki.libanki.getStockNotetype import com.ichi2.anki.libanki.getStockNotetypeKinds import com.ichi2.anki.libanki.utils.append import com.ichi2.anki.model.SelectableDeck +import com.ichi2.anki.notetype.CardTypeName import com.ichi2.anki.notetype.RenameCardTypeDialog import com.ichi2.anki.notetype.RepositionCardTemplateDialog import com.ichi2.anki.observability.undoableOp @@ -834,11 +835,19 @@ open class CardTemplateEditor : val ordinal = templateEditor.ord val template = templateEditor.tempNoteType!!.getTemplate(ordinal) + // obtain the current names (potentially unsaved) + val existingNames = + templateEditor.tempNoteType!! + .notetype.templates + .map { CardTypeName.fromString(it.name) } + RenameCardTypeDialog.showInstance( requireContext(), prefill = template.name, + currentName = CardTypeName.fromString(template.name), + existingNames = existingNames, ) { newName -> - template.name = newName + template.name = newName.value Timber.i("updated card template name") Timber.d("updated name of template %d to '%s'", ordinal, newName) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/CardTypeName.kt b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/CardTypeName.kt new file mode 100644 index 000000000000..8ab8f8e20bce --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/CardTypeName.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026 David Allison + * + * This program is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free Software + * Foundation; either version 3 of the License, or (at your option) any later + * version. + * + * This program is distributed in the hope that it will be useful, but WITHOUT ANY + * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A + * PARTICULAR PURPOSE. See the GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License along with + * this program. If not, see . + */ + +package com.ichi2.anki.notetype + +/** + * The name of a card type + * + * Names are case-insensitive: two card types may not differ just by case. + * A "+" suffix is added to the name if a duplicate occurs + * + * @see com.ichi2.anki.libanki.CardTemplate.name + */ +class CardTypeName private constructor( + val value: String, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is CardTypeName) return false + return value.equals(other.value, ignoreCase = true) + } + + override fun hashCode(): Int = value.lowercase().hashCode() + + override fun toString(): String = value + + companion object { + fun fromString(value: String) = CardTypeName(value.trim()) + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/RenameCardTypeDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/RenameCardTypeDialog.kt index ef2a3657ce59..b192ef0a92de 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/RenameCardTypeDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/RenameCardTypeDialog.kt @@ -20,6 +20,7 @@ import android.content.Context import androidx.appcompat.app.AlertDialog import com.ichi2.anki.CollectionManager import com.ichi2.anki.R +import com.ichi2.utils.ValidationResult import com.ichi2.utils.input import com.ichi2.utils.negativeButton import com.ichi2.utils.positiveButton @@ -28,10 +29,18 @@ import com.ichi2.utils.title class RenameCardTypeDialog { companion object { + /** + * @param prefill The text to initially appear in the EditText + * @param currentName The name of the card type to be renamed + * @param existingNames Unsaved card type names from the currently edited note type, + * used for validation. + */ fun showInstance( context: Context, prefill: String, - block: (result: String) -> Unit, + currentName: CardTypeName, + existingNames: List, + block: (result: CardTypeName) -> Unit, ) { AlertDialog .Builder(context) @@ -45,9 +54,16 @@ class RenameCardTypeDialog { displayKeyboard = true, allowEmpty = false, prefill = prefill, - waitForPositiveButton = true, + validator = { text -> + val name = CardTypeName.fromString(text) + when { + currentName == name -> ValidationResult.REJECTED + !existingNames.contains(name) -> ValidationResult.VALID + else -> ValidationResult.error(context.getString(R.string.error_name_exists)) + } + }, callback = { dialog, result -> - block(result.toString().trim()) + block(CardTypeName.fromString(result.toString())) dialog.dismiss() }, ) From b722b30f49735fb1ac4601c316adf74727569568 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:05:33 +0000 Subject: [PATCH 34/87] refactor: ConfirmationDialog.kt * extract args * document --- .../ichi2/anki/dialogs/ConfirmationDialog.kt | 53 ++++++++++++++----- 1 file changed, 39 insertions(+), 14 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ConfirmationDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ConfirmationDialog.kt index c2d361229b56..29b7df71efc4 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ConfirmationDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ConfirmationDialog.kt @@ -20,6 +20,7 @@ import android.os.Bundle import androidx.appcompat.app.AlertDialog import androidx.fragment.app.DialogFragment import com.ichi2.anki.R +import com.ichi2.anki.utils.ext.ifNullOrEmpty import com.ichi2.utils.create import com.ichi2.utils.message import com.ichi2.utils.negativeButton @@ -31,12 +32,30 @@ import com.ichi2.utils.title * Create a new instance, call setArgs(...), setConfirm(), and setCancel() then show it via the fragment manager as usual. */ class ConfirmationDialog : DialogFragment() { - private val positiveButtonText: String? - get() = requireArguments().getString(ARG_POSITIVE_BUTTON_TEXT) + private val message: String + get() = + requireNotNull(requireArguments().getString(ARG_MESSAGE)) { + ARG_MESSAGE + } + + private val title: String + get() = + requireNotNull(requireArguments().getString(ARG_TITLE)) { + ARG_TITLE + }.ifEmpty { requireActivity().getString(R.string.app_name) } + + private val positiveButtonText: String + get() = + requireArguments() + .getString(ARG_POSITIVE_BUTTON_TEXT) + .ifNullOrEmpty { getString(R.string.dialog_ok) } private var confirm = Runnable {} // Do nothing by default private var cancel = Runnable {} // Do nothing by default + /** + * Sets the message to display. Using [R.string.app_name] as the title. + */ fun setArgs(message: String?) { setArgs( title = "", @@ -52,8 +71,8 @@ class ConfirmationDialog : DialogFragment() { ) { arguments = Bundle().apply { - putString("title", title) - putString("message", message) + putString(ARG_MESSAGE, message) + putString(ARG_TITLE, title) putString(ARG_POSITIVE_BUTTON_TEXT, positiveButtonText) } } @@ -68,21 +87,27 @@ class ConfirmationDialog : DialogFragment() { override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog { super.onCreate(savedInstanceState) - val res = requireActivity().resources - val title = requireArguments().getString("title") + return AlertDialog.Builder(requireContext()).create { - title(text = (if ("" == title) res.getString(R.string.app_name) else title)!!) - message(text = requireArguments().getString("message")!!) - positiveButton(text = positiveButtonText ?: getString(R.string.dialog_ok)) { - confirm.run() - } - negativeButton(R.string.dialog_cancel) { - cancel.run() - } + title(text = title) + message(text = message) + positiveButton(text = positiveButtonText) { confirm.run() } + negativeButton(R.string.dialog_cancel) { cancel.run() } } } companion object { + /** The dialog message (required) */ + private const val ARG_MESSAGE = "message" + + /** + * The dialog title (required) + * + * Use the empty string for the app name (AnkiDroid) + */ + private const val ARG_TITLE = "title" + + /** Optional text for the positive button. Default: "OK" */ private const val ARG_POSITIVE_BUTTON_TEXT = "positiveButtonText" } } From be0c54a25794f10553a3a4b2ba53e5f75b8f42d2 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:07:51 +0000 Subject: [PATCH 35/87] fix: ConfirmationDialog - support a null title `setArgs` supported a null title, but it would not have worked --- .../java/com/ichi2/anki/dialogs/ConfirmationDialog.kt | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ConfirmationDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ConfirmationDialog.kt index 29b7df71efc4..6e8f11682212 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ConfirmationDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ConfirmationDialog.kt @@ -40,9 +40,9 @@ class ConfirmationDialog : DialogFragment() { private val title: String get() = - requireNotNull(requireArguments().getString(ARG_TITLE)) { - ARG_TITLE - }.ifEmpty { requireActivity().getString(R.string.app_name) } + requireArguments() + .getString(ARG_TITLE) + .ifNullOrEmpty { requireActivity().getString(R.string.app_name) } private val positiveButtonText: String get() = @@ -101,9 +101,7 @@ class ConfirmationDialog : DialogFragment() { private const val ARG_MESSAGE = "message" /** - * The dialog title (required) - * - * Use the empty string for the app name (AnkiDroid) + * Optional dialog title. Default: [R.string.app_name] */ private const val ARG_TITLE = "title" From 72f33cfd0279f1e5f8496bda696697b2b0585471 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 24 Mar 2026 14:09:05 +0000 Subject: [PATCH 36/87] refactor: make ConfirmationDialog message non-null --- .../main/java/com/ichi2/anki/dialogs/ConfirmationDialog.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ConfirmationDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ConfirmationDialog.kt index 6e8f11682212..540648e6f024 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ConfirmationDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ConfirmationDialog.kt @@ -56,7 +56,7 @@ class ConfirmationDialog : DialogFragment() { /** * Sets the message to display. Using [R.string.app_name] as the title. */ - fun setArgs(message: String?) { + fun setArgs(message: String) { setArgs( title = "", message = message, @@ -66,7 +66,7 @@ class ConfirmationDialog : DialogFragment() { fun setArgs( title: String?, - message: String?, + message: String, positiveButtonText: String? = null, ) { arguments = From 0c50cca96902e5c4443332b349e751c4f9bc831d Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Mon, 19 Jan 2026 23:27:58 +0000 Subject: [PATCH 37/87] feat(card-browser): add design for 'standard' SearchView This is a new Material 3 based SearchView Supports: * Chips (filters: decks; tags etc...) * Saved Searches * History & saving searches from history * Toggle to an 'Advanced Search' This PR is just an initial design and a sample test, no functionality Issue 18709 --- .../ichi2/anki/browser/CardBrowserFragment.kt | 8 +- .../browser/search/StandardSearchFragment.kt | 104 ++++++++++++ .../java/com/ichi2/anki/settings/Prefs.kt | 3 +- .../drawable/ic_round_favorite_outline.xml | 5 + .../res/drawable/outline_arrow_insert_24.xml | 5 + .../fragment_card_browser_searchview.xml | 13 +- .../res/layout/fragment_standard_search.xml | 159 +++++++++++++++++- .../res/layout/view_saved_search_item.xml | 82 +++++++++ .../res/layout/view_search_history_item.xml | 51 ++++++ .../search/StandardSearchFragmentTest.kt | 84 +++++++++ 10 files changed, 493 insertions(+), 21 deletions(-) create mode 100644 AnkiDroid/src/main/res/drawable/ic_round_favorite_outline.xml create mode 100644 AnkiDroid/src/main/res/drawable/outline_arrow_insert_24.xml create mode 100644 AnkiDroid/src/main/res/layout/view_saved_search_item.xml create mode 100644 AnkiDroid/src/main/res/layout/view_search_history_item.xml create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/browser/search/StandardSearchFragmentTest.kt 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 6d446acfaee2..30d9ecd567cc 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt @@ -180,7 +180,7 @@ class CardBrowserFragment : // only usable if 'useSearchView' is set override var searchBar: SearchBar? = null private var searchView: SearchView? = null - private var deckChip: Chip? = null + private var decksChip: Chip? = null // region legacy menu handling var mySearchesItem: MenuItem? = null @@ -255,8 +255,8 @@ class CardBrowserFragment : progressIndicator = view.findViewById(R.id.browser_progress) - deckChip = - view.findViewById(R.id.chip_decks)?.apply { + decksChip = + view.findViewById(R.id.decks_chip)?.apply { setOnClickListener { viewModel.openDeckSelectionDialog() } } searchBar = @@ -809,7 +809,7 @@ class CardBrowserFragment : } fun onDeckChanged(deck: SelectableDeck?) { - deckChip?.text = deck?.getFullDisplayName(requireContext()) + decksChip?.text = deck?.getFullDisplayName(requireContext()) } fun advancedSearchChanged(inAdvancedSearch: Boolean) { 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 2b8fd56a505f..4f60882ffa98 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 @@ -16,11 +16,115 @@ package com.ichi2.anki.browser.search +import android.content.Context +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ArrayAdapter +import androidx.annotation.VisibleForTesting import androidx.fragment.app.Fragment +import androidx.fragment.app.activityViewModels import com.ichi2.anki.R +import com.ichi2.anki.browser.SearchHistory.SearchHistoryEntry +import com.ichi2.anki.databinding.FragmentStandardSearchBinding +import com.ichi2.anki.databinding.ViewSavedSearchItemBinding +import com.ichi2.anki.databinding.ViewSearchHistoryItemBinding +import dev.androidbroadcast.vbpd.viewBinding class StandardSearchFragment : Fragment(R.layout.fragment_standard_search) { + @VisibleForTesting + val binding by viewBinding(FragmentStandardSearchBinding::bind) + + private val viewModel: CardBrowserSearchViewModel by activityViewModels() + + override fun onViewCreated( + view: View, + savedInstanceState: Bundle?, + ) { + super.onViewCreated(view, savedInstanceState) + + binding.toggleAdvancedSearch.setOnClickListener { viewModel.toggleAdvancedSearch() } + + setupSearchHistory() + setupSavedSearches() + } + + private fun setupSearchHistory() { + class SearchHistoryAdapter( + private val context: Context, + searches: List, + ) : ArrayAdapter(context, 0, searches) { + override fun getView( + position: Int, + convertView: View?, + parent: ViewGroup, + ): View { + val binding = + if (convertView != null) { + ViewSearchHistoryItemBinding.bind(convertView) + } else { + ViewSearchHistoryItemBinding.inflate(LayoutInflater.from(context), parent, false) + } + + val item = getItem(position)!! + + binding.title.text = item.query + return binding.root + } + } + + binding.searchHistory.apply { + adapter = + SearchHistoryAdapter( + context = requireContext(), + searches = + arrayListOf( + SearchHistoryEntry("rated:1"), + SearchHistoryEntry("nid:1764375217155"), + SearchHistoryEntry("is:learn -is:review induction"), + SearchHistoryEntry("deck:* hello AnkiDroid"), + ), + ) + } + } + + private fun setupSavedSearches() { + binding.savedSearches.adapter = + SavedSearchAdapter( + requireContext(), + arrayListOf( + SavedSearch("Red flag", "flag:1"), + SavedSearch("Title", "search query"), + SavedSearch("ya-ya, ya-ya", "blah-blah, blah-blah"), + ), + ) + } + companion object { const val TAG = "STANDARD" } } + +class SavedSearchAdapter( + private val context: Context, + searches: MutableList, +) : ArrayAdapter(context, 0, searches) { + override fun getView( + position: Int, + convertView: View?, + parent: ViewGroup, + ): View { + val binding = + if (convertView != null) { + ViewSavedSearchItemBinding.bind(convertView) + } else { + ViewSavedSearchItemBinding.inflate(LayoutInflater.from(context), parent, false) + } + + binding.title.text = getItem(position)!!.name + binding.content.text = getItem(position)!!.query + + return binding.root + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/settings/Prefs.kt b/AnkiDroid/src/main/java/com/ichi2/anki/settings/Prefs.kt index 0f6cd585e93c..11ca25c49447 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/settings/Prefs.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/settings/Prefs.kt @@ -407,7 +407,8 @@ open class PrefsRepository( val devIsCardBrowserFragmented: Boolean get() = getBoolean(R.string.dev_card_browser_fragmented, false) - val devUsingCardBrowserSearchView: Boolean by booleanPref(R.string.dev_card_browser_search_view, false) + @set:VisibleForTesting + var devUsingCardBrowserSearchView: Boolean by booleanPref(R.string.dev_card_browser_search_view, false) val isWebDebugEnabled: Boolean get() = (getBoolean(R.string.html_javascript_debugging_key, false) || BuildConfig.DEBUG) && !isRunningAsUnitTest diff --git a/AnkiDroid/src/main/res/drawable/ic_round_favorite_outline.xml b/AnkiDroid/src/main/res/drawable/ic_round_favorite_outline.xml new file mode 100644 index 000000000000..4f11b15563f3 --- /dev/null +++ b/AnkiDroid/src/main/res/drawable/ic_round_favorite_outline.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/AnkiDroid/src/main/res/drawable/outline_arrow_insert_24.xml b/AnkiDroid/src/main/res/drawable/outline_arrow_insert_24.xml new file mode 100644 index 000000000000..cc4e2d51d8f1 --- /dev/null +++ b/AnkiDroid/src/main/res/drawable/outline_arrow_insert_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/AnkiDroid/src/main/res/layout/fragment_card_browser_searchview.xml b/AnkiDroid/src/main/res/layout/fragment_card_browser_searchview.xml index d63d399e2724..7de6aa85fb7d 100644 --- a/AnkiDroid/src/main/res/layout/fragment_card_browser_searchview.xml +++ b/AnkiDroid/src/main/res/layout/fragment_card_browser_searchview.xml @@ -74,7 +74,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content"> - -