From 3f7a13d3360aff849fd61fb2c103c5229e9a6083 Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:33:47 +0530 Subject: [PATCH 001/163] fix(recorder): keep recordings out of the cache Android clears an app's cache when storage runs low, which could delete a recording while it was still being made. Recordings now go to noBackupFilesDir, which isn't cleared or backed up. Assisted-by: Claude Opus 5.5 --- .../anki/noteeditor/AudioRecordingViewModel.kt | 2 +- .../com/ichi2/anki/recorder/VoiceRecorder.kt | 3 +++ .../audiorecord/CheckPronunciationViewModel.kt | 2 +- .../noteeditor/AudioRecordingViewModelTest.kt | 18 ++++++++++++++++++ .../CheckPronunciationViewModelTest.kt | 18 ++++++++++++++++++ 5 files changed, 41 insertions(+), 2 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/AudioRecordingViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/AudioRecordingViewModel.kt index cf11c0fd590d..8b1db2d55ee7 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/AudioRecordingViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/AudioRecordingViewModel.kt @@ -26,7 +26,7 @@ import kotlin.time.Duration.Companion.seconds class AudioRecordingViewModel( audioRecorder: AudioRecorder = AudioRecorder(appContext), createPlayer: () -> Player = { VoicePlayer.createExoPlayer(appContext) }, - recordingsDirectory: File = File(appContext.cacheDir, VoiceRecorder.RECORDINGS_DIRECTORY), + recordingsDirectory: File = VoiceRecorder.recordingsDirectory(appContext), ) : ViewModel() { private val recorder = VoiceRecorder(audioRecorder, recordingsDirectory, viewModelScope) private val player = VoicePlayer(viewModelScope, createPlayer) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/recorder/VoiceRecorder.kt b/AnkiDroid/src/main/java/com/ichi2/anki/recorder/VoiceRecorder.kt index 62ec5c768230..5c13e086f341 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/recorder/VoiceRecorder.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/recorder/VoiceRecorder.kt @@ -3,6 +3,7 @@ package com.ichi2.anki.recorder +import android.content.Context import androidx.annotation.MainThread import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.BufferOverflow @@ -183,5 +184,7 @@ class VoiceRecorder( const val RECORDINGS_DIRECTORY = "recordings" private const val FILE_PREFIX = "ankidroid_audiorec" private const val FILE_SUFFIX = ".3gp" + + fun recordingsDirectory(context: Context): File = File(context.noBackupFilesDir, RECORDINGS_DIRECTORY) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/audiorecord/CheckPronunciationViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/audiorecord/CheckPronunciationViewModel.kt index 7e839ba4ac3c..2c3c22724bf3 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/audiorecord/CheckPronunciationViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/audiorecord/CheckPronunciationViewModel.kt @@ -23,7 +23,7 @@ import kotlin.time.Duration class CheckPronunciationViewModel( audioRecorder: AudioRecorder = AudioRecorder(appContext), createPlayer: () -> Player = { VoicePlayer.createExoPlayer(appContext) }, - recordingsDirectory: File = File(appContext.cacheDir, VoiceRecorder.RECORDINGS_DIRECTORY), + recordingsDirectory: File = VoiceRecorder.recordingsDirectory(appContext), ) : ViewModel() { private val recorder = VoiceRecorder(audioRecorder, recordingsDirectory, viewModelScope) private val player = VoicePlayer(viewModelScope, createPlayer) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/noteeditor/AudioRecordingViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/noteeditor/AudioRecordingViewModelTest.kt index dc53d9d81a56..1957ca468aa0 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/noteeditor/AudioRecordingViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/noteeditor/AudioRecordingViewModelTest.kt @@ -3,6 +3,7 @@ package com.ichi2.anki.noteeditor +import android.content.Context import android.net.Uri import androidx.lifecycle.ViewModelStore import androidx.media3.common.MediaItem @@ -14,6 +15,7 @@ import com.ichi2.anki.recorder.MediaRecorderStub import com.ichi2.anki.recorder.PlayerState import com.ichi2.anki.recorder.PlayerStub import com.ichi2.anki.recorder.RecorderState +import com.ichi2.anki.recorder.VoiceRecorder import com.ichi2.testutils.JvmTest import io.mockk.every import io.mockk.verify @@ -333,6 +335,22 @@ class AudioRecordingViewModelTest : JvmTest() { assertEquals(RecorderState.Recorded(recording), viewModel.recorderState.value, "the recording can still be inserted") } + @Test + fun `recordings are stored outside the cache`() = + runTest { + val context = ApplicationProvider.getApplicationContext() + val viewModel = + AudioRecordingViewModel(AudioRecorder(context), playerStub.factory) + .also { viewModelStore.put(VIEW_MODEL_KEY, it) } + try { + val recording = viewModel.record() + + assertEquals(File(context.noBackupFilesDir, VoiceRecorder.RECORDINGS_DIRECTORY), recording.parentFile) + } finally { + viewModelStore.clear() + } + } + private fun runViewModelTest(testBody: suspend TestScope.(AudioRecordingViewModel) -> Unit) = runTest { val viewModel = diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/audiorecord/CheckPronunciationViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/audiorecord/CheckPronunciationViewModelTest.kt index 63a1f89913eb..c92f58b0993e 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/audiorecord/CheckPronunciationViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/audiorecord/CheckPronunciationViewModelTest.kt @@ -2,6 +2,7 @@ package com.ichi2.anki.ui.windows.reviewer.audiorecord +import android.content.Context import android.net.Uri import androidx.lifecycle.ViewModelStore import androidx.media3.common.MediaItem @@ -11,6 +12,7 @@ import app.cash.turbine.test import com.ichi2.anki.recorder.AudioRecorder import com.ichi2.anki.recorder.MediaRecorderStub import com.ichi2.anki.recorder.PlayerStub +import com.ichi2.anki.recorder.VoiceRecorder import com.ichi2.testutils.JvmTest import io.mockk.verify import kotlinx.coroutines.flow.first @@ -287,6 +289,22 @@ class CheckPronunciationViewModelTest : JvmTest() { assertFalse(mediaRecorderStub.outputFiles.single().exists(), "the empty file is deleted") } + @Test + fun `recordings are stored outside the cache`() = + runTest { + val context = ApplicationProvider.getApplicationContext() + val viewModel = + CheckPronunciationViewModel(AudioRecorder(context), playerStub.factory) + .also { viewModelStore.put(VIEW_MODEL_KEY, it) } + try { + val recording = viewModel.record() + + assertEquals(File(context.noBackupFilesDir, VoiceRecorder.RECORDINGS_DIRECTORY), recording.parentFile) + } finally { + viewModelStore.clear() + } + } + private fun runViewModelTest(testBody: suspend TestScope.(CheckPronunciationViewModel) -> Unit) = runTest { val viewModel = From 2ff269ec0d54847356c15039d57a18fe04ce7595 Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Tue, 13 Oct 2026 02:33:54 +0530 Subject: [PATCH 002/163] fix(recorder): clean up old recordings at startup The system no longer clears recordings left behind when the app is killed, so recordings older than 7 days are deleted at startup, like the multimedia argument files. Assisted-by: Claude Opus 5.5 --- .../main/java/com/ichi2/anki/AnkiDroidApp.kt | 2 + .../com/ichi2/anki/recorder/VoiceRecorder.kt | 18 ++++++ .../recorder/RemoveExpiredRecordingsTest.kt | 59 +++++++++++++++++++ 3 files changed, 79 insertions(+) create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/recorder/RemoveExpiredRecordingsTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt index e0936fb42fee..1462c01fcb63 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt @@ -59,6 +59,7 @@ import com.ichi2.anki.multiprofile.isPhoenixProcess import com.ichi2.anki.navigation.initializeNavigator import com.ichi2.anki.observability.ChangeManager import com.ichi2.anki.preferences.SharedPreferencesProvider +import com.ichi2.anki.recorder.VoiceRecorder import com.ichi2.anki.reviewreminders.ReminderLogTree import com.ichi2.anki.servicelayer.DebugInfoService import com.ichi2.anki.servicelayer.ThrowableFilterService @@ -279,6 +280,7 @@ open class AnkiDroidApp : runCatching { measureTime("cacheCleanup") { MultimediaArgsStorage.create(this@AnkiDroidApp).removeExpiredFiles() + VoiceRecorder.removeExpiredRecordings(VoiceRecorder.recordingsDirectory(this@AnkiDroidApp)) } }.onFailure { Timber.w(it, "Unable to clean up expired cache files") } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/recorder/VoiceRecorder.kt b/AnkiDroid/src/main/java/com/ichi2/anki/recorder/VoiceRecorder.kt index 5c13e086f341..3932c04a1537 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/recorder/VoiceRecorder.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/recorder/VoiceRecorder.kt @@ -5,6 +5,7 @@ package com.ichi2.anki.recorder import android.content.Context import androidx.annotation.MainThread +import com.ichi2.anki.common.time.TimeManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.flow.MutableSharedFlow @@ -16,6 +17,7 @@ import java.io.Closeable import java.io.File import java.io.IOException import kotlin.time.Duration +import kotlin.time.Duration.Companion.days import kotlin.time.TimeSource sealed interface RecorderState { @@ -184,7 +186,23 @@ class VoiceRecorder( const val RECORDINGS_DIRECTORY = "recordings" private const val FILE_PREFIX = "ankidroid_audiorec" private const val FILE_SUFFIX = ".3gp" + private val RECORDING_RETENTION = 7.days fun recordingsDirectory(context: Context): File = File(context.noBackupFilesDir, RECORDINGS_DIRECTORY) + + fun removeExpiredRecordings( + directory: File, + now: Long = TimeManager.time.intTimeMS(), + ) { + val cutoff = now - RECORDING_RETENTION.inWholeMilliseconds + directory + .listFiles { _, name -> name.startsWith(FILE_PREFIX) && name.endsWith(FILE_SUFFIX) } + ?.forEach { file -> + val modified = file.lastModified() + if (file.isFile && modified > 0 && modified < cutoff && !file.delete()) { + Timber.w("Could not delete %s", file) + } + } + } } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/recorder/RemoveExpiredRecordingsTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/recorder/RemoveExpiredRecordingsTest.kt new file mode 100644 index 000000000000..f431a2c42583 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/recorder/RemoveExpiredRecordingsTest.kt @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 Ashish Yadav + +package com.ichi2.anki.recorder + +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import kotlin.test.assertFalse +import kotlin.test.assertTrue +import kotlin.time.Duration +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.seconds + +class RemoveExpiredRecordingsTest { + @get:Rule + val temporaryFolder = TemporaryFolder() + + private val now = 1_800_000_000_000L + + @Test + fun `cleanup removes only recordings older than seven days`() { + val expired = createFile("ankidroid_audiorec1.3gp", 7.days + 1.seconds) + val atCutoff = createFile("ankidroid_audiorec2.3gp", 7.days) + val recent = createFile("ankidroid_audiorec3.3gp", 1.days) + + VoiceRecorder.removeExpiredRecordings(temporaryFolder.root, now) + + assertFalse(expired.exists(), "expired recording") + assertTrue(atCutoff.exists(), "recording at the cutoff") + assertTrue(recent.exists(), "recent recording") + } + + @Test + fun `cleanup keeps other files and folders`() { + val unrelated = createFile("notes.txt", 8.days) + val folder = temporaryFolder.newFolder("ankidroid_audiorec_folder.3gp") + assertTrue(folder.setLastModified(now - 8.days.inWholeMilliseconds)) + + VoiceRecorder.removeExpiredRecordings(temporaryFolder.root, now) + + assertTrue(unrelated.exists(), "unrelated file") + assertTrue(folder.exists(), "folder") + } + + @Test + fun `cleanup ignores a missing recordings folder`() { + VoiceRecorder.removeExpiredRecordings(File(temporaryFolder.root, "missing"), now) + } + + private fun createFile( + name: String, + age: Duration, + ): File = + temporaryFolder.newFile(name).apply { + assertTrue(setLastModified(now - age.inWholeMilliseconds)) + } +} From 675ee6d5dd1e8113f33ac2ed7de5a912409c9cbe Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:52:41 +0100 Subject: [PATCH 003/163] refactor(card-browser): deduplicate card state icons Fixes 22420 Assisted-by: GPT-6-Astra default --- .../ichi2/anki/browser/CardBrowserFragment.kt | 11 ++----- .../search/CardStateBottomSheetFragment.kt | 31 ++++++++++++------- .../browser/search/StandardSearchFragment.kt | 10 +----- ...ard_state_buried.xml => ic_card_state.xml} | 2 +- .../res/drawable/ic_card_state_learning.xml | 11 ------- .../main/res/drawable/ic_card_state_new.xml | 11 ------- .../res/drawable/ic_card_state_review.xml | 11 ------- .../res/drawable/ic_card_state_suspended.xml | 11 ------- 8 files changed, 24 insertions(+), 74 deletions(-) rename AnkiDroid/src/main/res/drawable/{ic_card_state_buried.xml => ic_card_state.xml} (75%) delete mode 100644 AnkiDroid/src/main/res/drawable/ic_card_state_learning.xml delete mode 100644 AnkiDroid/src/main/res/drawable/ic_card_state_new.xml delete mode 100644 AnkiDroid/src/main/res/drawable/ic_card_state_review.xml delete mode 100644 AnkiDroid/src/main/res/drawable/ic_card_state_suspended.xml 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 dc837e4f137e..922c35f6c2d4 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt @@ -99,6 +99,7 @@ import com.ichi2.anki.browser.search.SearchString import com.ichi2.anki.browser.search.SortOrderBottomSheetFragment import com.ichi2.anki.browser.search.StandardSearchFragment import com.ichi2.anki.browser.search.formatChipDescription +import com.ichi2.anki.browser.search.getIcon import com.ichi2.anki.browser.search.iconRes import com.ichi2.anki.browser.search.savedFilters import com.ichi2.anki.common.ALL_DECKS_ID @@ -1198,15 +1199,7 @@ class CardBrowserFragment : tagsChip?.hasCheckedBackground = filters.tags.any() cardStateChip?.text = formatChipDescription(filters.cardStates.map { it.label }, emptyValue = "Card state") - cardStateChip?.chipIcon = - ContextCompat.getDrawable(requireContext(), filters.cardStates.firstOrNull().iconRes)?.also { - if (filters.cardStates.isEmpty()) { - DrawableCompat.setTint( - it.mutate(), - MaterialColors.getColor(requireContext(), androidx.appcompat.R.attr.colorPrimary, 0), - ) - } - } + cardStateChip?.chipIcon = filters.cardStates.firstOrNull().getIcon(requireContext()) cardStateChip?.hasCheckedBackground = filters.cardStates.any() // Flags: Use the icon/checked state to explain what the filter is. diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/CardStateBottomSheetFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/CardStateBottomSheetFragment.kt index 680551fecfae..57ac08ad292a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/CardStateBottomSheetFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/CardStateBottomSheetFragment.kt @@ -2,15 +2,19 @@ package com.ichi2.anki.browser.search +import android.content.Context +import android.graphics.drawable.Drawable import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.annotation.DrawableRes +import androidx.core.content.ContextCompat +import androidx.core.graphics.drawable.DrawableCompat import androidx.fragment.app.activityViewModels import androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.google.android.material.color.MaterialColors import com.ichi2.anki.R import com.ichi2.anki.databinding.FragmentBottomSheetListBinding import com.ichi2.anki.databinding.ItemBrowserFilterBottomSheetBinding @@ -92,7 +96,7 @@ class CardStateBottomSheetFragment : BottomSheetDialogFragment(R.layout.fragment val state = this.states[position] holder.binding.text.text = state.label holder.binding.checkbox.contentDescription = state.label - holder.binding.icon.setImageResource(state.iconRes) + holder.binding.icon.setImageDrawable(state.getIcon(holder.itemView.context)) holder.binding.root.setOnClickListener { onItemClickedListener(state) } holder.binding.checkbox.apply { this.isChecked = checkedItems.contains(state) @@ -121,14 +125,19 @@ class CardStateBottomSheetFragment : BottomSheetDialogFragment(R.layout.fragment } } -@get:DrawableRes -val CardState?.iconRes - get() = +/** Returns the card-state icon tinted with the current theme's state color. */ +fun CardState?.getIcon(context: Context): Drawable? { + val iconRes = if (this == null) R.drawable.ic_card_state_default else R.drawable.ic_card_state + val colorAttr = when (this) { - null -> R.drawable.ic_card_state_default - CardState.New -> R.drawable.ic_card_state_new - CardState.Learning -> R.drawable.ic_card_state_learning - CardState.Review -> R.drawable.ic_card_state_review - CardState.Buried -> R.drawable.ic_card_state_buried - CardState.Suspended -> R.drawable.ic_card_state_suspended + null -> androidx.appcompat.R.attr.colorPrimary + CardState.New -> R.attr.newCountColor + CardState.Learning -> R.attr.learnCountColor + CardState.Review -> R.attr.reviewCountColor + CardState.Buried -> R.attr.buriedColor + CardState.Suspended -> R.attr.suspendedColor } + return ContextCompat.getDrawable(context, iconRes)?.mutate()?.also { + DrawableCompat.setTint(it, MaterialColors.getColor(context, colorAttr, 0)) + } +} 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 bbfbf70c7e57..1a131aa6ed40 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 @@ -132,15 +132,7 @@ class StandardSearchFragment : binding.decksChip.text = it.decks.firstOrNull()?.name ?: TR.sentenceCase.allDecks binding.tagsChip.text = formatChipDescription(it.tags, emptyValue = "Tags") binding.cardStateChip.text = formatChipDescription(it.cardStates.map { it.label }, emptyValue = "Card state") - binding.cardStateChip.chipIcon = - ContextCompat.getDrawable(requireContext(), it.cardStates.firstOrNull().iconRes)?.also { drawable -> - if (it.cardStates.isEmpty()) { - DrawableCompat.setTint( - drawable.mutate(), - MaterialColors.getColor(requireContext(), androidx.appcompat.R.attr.colorPrimary, 0), - ) - } - } + binding.cardStateChip.chipIcon = it.cardStates.firstOrNull().getIcon(requireContext()) binding.flagsChip.text = formatChipDescription( diff --git a/AnkiDroid/src/main/res/drawable/ic_card_state_buried.xml b/AnkiDroid/src/main/res/drawable/ic_card_state.xml similarity index 75% rename from AnkiDroid/src/main/res/drawable/ic_card_state_buried.xml rename to AnkiDroid/src/main/res/drawable/ic_card_state.xml index f0164c0333b4..13df71f57488 100644 --- a/AnkiDroid/src/main/res/drawable/ic_card_state_buried.xml +++ b/AnkiDroid/src/main/res/drawable/ic_card_state.xml @@ -4,7 +4,7 @@ SPDX-FileComment: Style: Filled Source: https://fonts.gstatic.com/s/i/materialicons/circle/v11/24px.xml --> - + diff --git a/AnkiDroid/src/main/res/drawable/ic_card_state_learning.xml b/AnkiDroid/src/main/res/drawable/ic_card_state_learning.xml deleted file mode 100644 index 175cb40916ca..000000000000 --- a/AnkiDroid/src/main/res/drawable/ic_card_state_learning.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/AnkiDroid/src/main/res/drawable/ic_card_state_new.xml b/AnkiDroid/src/main/res/drawable/ic_card_state_new.xml deleted file mode 100644 index 419a80c930b4..000000000000 --- a/AnkiDroid/src/main/res/drawable/ic_card_state_new.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/AnkiDroid/src/main/res/drawable/ic_card_state_review.xml b/AnkiDroid/src/main/res/drawable/ic_card_state_review.xml deleted file mode 100644 index 0a48a8c49228..000000000000 --- a/AnkiDroid/src/main/res/drawable/ic_card_state_review.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/AnkiDroid/src/main/res/drawable/ic_card_state_suspended.xml b/AnkiDroid/src/main/res/drawable/ic_card_state_suspended.xml deleted file mode 100644 index 7179f5c0f8f7..000000000000 --- a/AnkiDroid/src/main/res/drawable/ic_card_state_suspended.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - - \ No newline at end of file From 93078f35fdc8fce08029df136d56cd265ed40bbe Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:09:17 +0100 Subject: [PATCH 004/163] test: support disabling backend loading Workaround before we fix 14796 Assisted-by: GPT-6-Astra xhigh --- .../src/test/java/com/ichi2/anki/RobolectricTest.kt | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt index 0a7d9c6c3d6f..ce178cca9958 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt @@ -81,8 +81,13 @@ import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeSource import com.ichi2.testutils.Robolectric as RobolectricActivities -open class RobolectricTest : - AnkiTest, +/** + * @param loadBackend Disable for tests using custom sandboxes which do not need the backend. + * Loading the native library into one sandbox can prevent later sandboxes from using it (Issue 14796). + */ +open class RobolectricTest( + private val loadBackend: Boolean = true, +) : AnkiTest, AndroidTest { @Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN") private fun Any.wait(timeMs: Long) = (this as Object).wait(timeMs) @@ -168,7 +173,7 @@ open class RobolectricTest : CollectionManager.closeCollectionBlocking() - maybeSetupBackend() + if (loadBackend) maybeSetupBackend() // Reset static variable for custom tabs failure. CustomTabActivityHelper.resetFailed() From 4b0be9c4633d4c89424218baa258b338ead29451 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Mon, 12 Oct 2026 15:21:44 +0100 Subject: [PATCH 005/163] test: Windows support for FileProvider host paths FileProvider under Robolectric/AndroidX was Unix-only. This makes it work for Windows Use openInputStreamSafe for both reads to satisfy UnsafeOpenInputStream lint. Prep for 14222 Assisted-by: GPT-6-Astra xhigh Assisted-by: GPT-6.1-Sol high --- .../testutils/FileProviderHostPathsTest.kt | 75 +++++++++++++++++++ .../ShadowFileProviderPathStrategy.kt | 21 ++++++ 2 files changed, 96 insertions(+) create mode 100644 AnkiDroid/src/test/java/com/ichi2/testutils/FileProviderHostPathsTest.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/testutils/ShadowFileProviderPathStrategy.kt diff --git a/AnkiDroid/src/test/java/com/ichi2/testutils/FileProviderHostPathsTest.kt b/AnkiDroid/src/test/java/com/ichi2/testutils/FileProviderHostPathsTest.kt new file mode 100644 index 000000000000..5b59ba741901 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/testutils/FileProviderHostPathsTest.kt @@ -0,0 +1,75 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.testutils + +import android.content.Context +import androidx.core.content.FileProvider +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.EmptyApplicationCategory +import com.ichi2.utils.openInputStreamSafe +import org.junit.Before +import org.junit.Test +import org.junit.experimental.categories.Category +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import org.robolectric.shadow.api.Shadow +import org.robolectric.util.ReflectionHelpers +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertIs + +@RunWith(AndroidJUnit4::class) +@Config(application = EmptyApplication::class, shadows = [ShadowFileProviderPathStrategy::class]) +@Category(EmptyApplicationCategory::class) +class FileProviderHostPathsTest { + private val context: Context get() = ApplicationProvider.getApplicationContext() + private val authority: String get() = "${context.packageName}.apkgfileprovider" + + @Before + fun setUp() { + // Robolectric gives each test a new cache directory, but AndroidX retains the old roots. + ReflectionHelpers.getStaticField>(FileProvider::class.java, "sCache").clear() + } + + @Test + fun `nested cache files can be shared and opened`() { + val directory = File(context.cacheDir, "nested images").apply { mkdirs() } + val file = File(directory, "image #1.png").apply { writeText("original") } + val uri = FileProvider.getUriForFile(context, authority, file) + + val strategies = ReflectionHelpers.getStaticField>(FileProvider::class.java, "sCache") + assertIs(Shadow.extract(strategies.getValue(authority))) + val contents = + context.contentResolver + .openInputStreamSafe(uri)!! + .bufferedReader() + .use { it.readText() } + assertEquals("original", contents) + context.contentResolver.openOutputStream(uri, "wt")!!.use { it.write("edited".toByteArray()) } + assertEquals("edited", file.readText()) + } + + @Test + fun `the root itself and a directory with the same prefix cannot be shared`() { + val root = context.cacheDir + val sibling = File(root.parentFile, "${root.name}-private/image.png") + for (file in listOf(root, sibling)) { + assertFailsWith { FileProvider.getUriForFile(context, authority, file) } + } + } + + @Test + fun `a URI cannot traverse outside its configured root`() { + val file = File(context.cacheDir, "image.png").apply { writeText("original") } + val uri = FileProvider.getUriForFile(context, authority, file) + val escaped = + uri + .buildUpon() + .encodedPath("/${uri.pathSegments.first()}/%2E%2E/${context.cacheDir.name}-private/image.png") + .build() + + assertFailsWith { context.contentResolver.openInputStreamSafe(escaped) } + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/testutils/ShadowFileProviderPathStrategy.kt b/AnkiDroid/src/test/java/com/ichi2/testutils/ShadowFileProviderPathStrategy.kt new file mode 100644 index 000000000000..0dbe0ee25161 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/testutils/ShadowFileProviderPathStrategy.kt @@ -0,0 +1,21 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.testutils + +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import java.io.File + +/** Adapts AndroidX's Unix-only containment check to Robolectric's host filesystem. */ +@Implements(className = "androidx.core.content.FileProvider\$SimplePathStrategy", isInAndroidSdk = false) +class ShadowFileProviderPathStrategy { + @Implementation + protected fun belongsToRoot( + filePath: String, + rootPath: String, + ): Boolean { + val file = File(filePath).toPath() + val root = File(rootPath).toPath() + return file != root && file.startsWith(root) + } +} From e363704c819fba02fe6fd3da5961243e44e0fac3 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:05:34 +0100 Subject: [PATCH 006/163] refactor(note-editor): extract `launchEditor` So the Gallery selection can reuse it Part of 22499 Assisted-by: GPT-6-Astra xhigh --- .../NoteEditorMultimediaController.kt | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/NoteEditorMultimediaController.kt b/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/NoteEditorMultimediaController.kt index 9ee364fcbf1f..217386918bbf 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/NoteEditorMultimediaController.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/NoteEditorMultimediaController.kt @@ -24,6 +24,7 @@ import com.ichi2.anki.multimedia.MultimediaActivityExtra import com.ichi2.anki.multimedia.MultimediaResult import com.ichi2.anki.multimediacard.fields.EFieldType import com.ichi2.anki.multimediacard.fields.IField +import com.ichi2.anki.multimediacard.impl.MultimediaEditableNote import com.ichi2.anki.servicelayer.NoteService import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.utils.ContentResolverUtil @@ -64,18 +65,26 @@ internal class NoteEditorMultimediaController( fragment.multimediaViewModel.multimediaAction.first { action -> Timber.i("Selected multimedia action: %s", action) val handler = MultimediaActionHandler.forAction(action) - val field = handler.createField().also { note.setField(fieldIndex, it) } - val intent = - handler.buildIntent( - fragment.requireContext(), - MultimediaActivityExtra(fieldIndex, field, note), - ) - launcher.launch(intent) + launchEditor(handler, fieldIndex, note) true } } } + private fun launchEditor( + handler: MultimediaActionHandler, + fieldIndex: Int, + note: MultimediaEditableNote, + ) { + val field = handler.createField().also { note.setField(fieldIndex, it) } + val intent = + handler.buildIntent( + fragment.requireContext(), + MultimediaActivityExtra(fieldIndex, field, note), + ) + launcher.launch(intent) + } + /** * Handles an image-share intent: extracts the source [Uri], persists URI permission, * copies it into the app's cache, and launches the image picker seeded with the From 90ca3225b292b11040d028ae11cc67f1d7d42147 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:09:36 +0100 Subject: [PATCH 007/163] fix(note-editor): stop flicker on cancelling 'Gallery' Open the Gallery before the image editor, so cancellation goes directly back to the Note Editor. `NoteEditorGalleryPicker` maintains state and results, passing on the URI to the editor. Fixes 22499 Assisted-by: GPT-6-Astra xhigh --- .../java/com/ichi2/anki/NoteEditorFragment.kt | 4 +- .../multimedia/MultimediaImageFragment.kt | 8 +- .../noteeditor/NoteEditorGalleryPicker.kt | 65 ++++++++++ .../NoteEditorMultimediaController.kt | 31 ++++- .../anki/multimedia/ImageEditorTestHelper.kt | 110 +++++++++++++++++ .../MultimediaImageRecreationTest.kt | 87 ++++++++++++++ .../NoteEditorMultimediaControllerTest.kt | 111 ++++++++++++++++++ 7 files changed, 407 insertions(+), 9 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/NoteEditorGalleryPicker.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/multimedia/ImageEditorTestHelper.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/multimedia/MultimediaImageRecreationTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt index baee819a2a3a..db13efd8ac6f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt @@ -351,9 +351,7 @@ class NoteEditorFragment : } @VisibleForTesting - internal val multimediaController: NoteEditorMultimediaController by lazy { - NoteEditorMultimediaController(this, multimediaFragmentLauncher) - } + internal val multimediaController: NoteEditorMultimediaController = NoteEditorMultimediaController(this, multimediaFragmentLauncher) private val requestTemplateEditLauncher = registerForActivityResult( diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaImageFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaImageFragment.kt index 8a6b54c8cc0c..7deecae1b1a6 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaImageFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaImageFragment.kt @@ -14,6 +14,7 @@ import android.graphics.drawable.BitmapDrawable import android.net.Uri import android.os.Bundle import android.provider.MediaStore +import android.text.format.Formatter import android.view.View import android.webkit.WebView import androidx.activity.result.contract.ActivityResultContracts @@ -284,7 +285,12 @@ class MultimediaImageFragment : } private fun handleImageUri() { - if (imageUri != null) { + val currentImageUri = viewModel.currentMultimediaUri.value + if (currentImageUri != null) { + // The launch URI is only the initial image; cropping or replacement may have changed it. + previewImage(currentImageUri) + binding.imageFileSize.text = Formatter.formatFileSize(requireContext(), viewModel.selectedMediaFileSize) + } else if (imageUri != null) { // a content:// or a file:// already in our cache; resolveUriToFile handles both handleSelectImageIntent(imageUri) } else { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/NoteEditorGalleryPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/NoteEditorGalleryPicker.kt new file mode 100644 index 000000000000..07163f372680 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/NoteEditorGalleryPicker.kt @@ -0,0 +1,65 @@ +// SPDX-FileCopyrightText: 2026 David Allison <62114487+david-allison@users.noreply.github.com> +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.noteeditor + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.provider.MediaStore +import androidx.activity.result.contract.ActivityResultContracts +import androidx.fragment.app.Fragment +import com.ichi2.anki.CommonString +import com.ichi2.anki.multimedia.PickedImage +import com.ichi2.anki.snackbar.showSnackbar +import timber.log.Timber + +/** + * Picks an image before opening the image editor, retaining the target note field across recreation. + * Must be constructed during fragment initialization to register its activity result launcher. + */ +internal class NoteEditorGalleryPicker( + private val fragment: Fragment, + onImagePicked: (fieldIndex: Int, imageUri: Uri) -> Unit, +) { + private var pendingFieldIndex: Int? = null + + private val launcher = + fragment.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val fieldIndex = pendingFieldIndex ?: return@registerForActivityResult + pendingFieldIndex = null + if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult + + val imageUri = result.data?.let { PickedImage(it).trustedUri } + if (imageUri == null) { + fragment.showSnackbar(CommonString.select_image_failed) + return@registerForActivityResult + } + onImagePicked(fieldIndex, imageUri) + } + + fun launch(fieldIndex: Int) { + pendingFieldIndex = fieldIndex + try { + launcher.launch(Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI)) + } catch (e: ActivityNotFoundException) { + pendingFieldIndex = null + Timber.w(e, "No app found to select image") + fragment.showSnackbar(CommonString.activity_start_failed) + } + } + + fun onSaveInstanceState(outState: Bundle) { + pendingFieldIndex?.let { outState.putInt(STATE_KEY_FIELD_INDEX, it) } + } + + fun onRestoreInstanceState(state: Bundle) { + pendingFieldIndex = state.getInt(STATE_KEY_FIELD_INDEX, -1).takeIf { it >= 0 } + } + + companion object { + private const val STATE_KEY_FIELD_INDEX = "galleryFieldIndex" + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/NoteEditorMultimediaController.kt b/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/NoteEditorMultimediaController.kt index 217386918bbf..d927485fb0fc 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/NoteEditorMultimediaController.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/NoteEditorMultimediaController.kt @@ -37,9 +37,8 @@ import java.io.File /** * Owns the multimedia capture lifecycle for a [NoteEditorFragment]. * - * The [launcher] must already be registered on the fragment (an - * `ActivityResultLauncher` can only be created during fragment initialisation); - * its callback forwards to [handleActions] / [handleResult]. + * Must be created during fragment initialization to register the Gallery picker. + * The [launcher] must already be registered; its callback forwards to [handleActions] / [handleResult]. */ internal class NoteEditorMultimediaController( private val fragment: NoteEditorFragment, @@ -47,6 +46,15 @@ internal class NoteEditorMultimediaController( ) { private var actionJob: Job? = null + private val galleryPicker = + NoteEditorGalleryPicker(fragment) { fieldIndex, imageUri -> + fragment.launchCatchingTask { + val note = fragment.getCurrentMultimediaEditableNote() + if (note.isEmpty) return@launchCatchingTask + launchEditor(MultimediaActionHandler.ImageFile, fieldIndex, note, imageUri) + } + } + // Use the same HTML if the same image is pasted multiple times. private var pastedImageCache: HashMap = HashMap() @@ -65,7 +73,12 @@ internal class NoteEditorMultimediaController( fragment.multimediaViewModel.multimediaAction.first { action -> Timber.i("Selected multimedia action: %s", action) val handler = MultimediaActionHandler.forAction(action) - launchEditor(handler, fieldIndex, note) + if (handler == MultimediaActionHandler.ImageFile) { + // An empty image editor would be exposed during the picker's back animation. + galleryPicker.launch(fieldIndex) + } else { + launchEditor(handler, fieldIndex, note) + } true } } @@ -75,13 +88,19 @@ internal class NoteEditorMultimediaController( handler: MultimediaActionHandler, fieldIndex: Int, note: MultimediaEditableNote, + imageUri: Uri? = null, ) { val field = handler.createField().also { note.setField(fieldIndex, it) } val intent = handler.buildIntent( fragment.requireContext(), - MultimediaActivityExtra(fieldIndex, field, note), + MultimediaActivityExtra(fieldIndex, field, note, imageUri?.toString()), ) + if (imageUri != null) { + // The URI in the serialized arguments does not carry the picker's read grant. + intent.data = imageUri + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + } launcher.launch(intent) } @@ -192,12 +211,14 @@ internal class NoteEditorMultimediaController( fun onSaveInstanceState(outState: Bundle) { outState.putSerializable(STATE_KEY_IMAGE_CACHE, pastedImageCache) + galleryPicker.onSaveInstanceState(outState) } fun onRestoreInstanceState(state: Bundle) { pastedImageCache = state.getSerializableCompat>(STATE_KEY_IMAGE_CACHE) ?: HashMap() + galleryPicker.onRestoreInstanceState(state) } /** diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/multimedia/ImageEditorTestHelper.kt b/AnkiDroid/src/test/java/com/ichi2/anki/multimedia/ImageEditorTestHelper.kt new file mode 100644 index 000000000000..1d77fe3be05f --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/multimedia/ImageEditorTestHelper.kt @@ -0,0 +1,110 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.multimedia + +import android.app.Activity +import android.content.Intent +import android.graphics.Bitmap +import android.net.Uri +import androidx.annotation.StringRes +import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.core.content.FileProvider +import androidx.core.graphics.createBitmap +import com.ichi2.anki.CommonString +import com.ichi2.anki.R +import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.RobolectricTest.Companion.advanceRobolectricLooper +import com.ichi2.anki.multimediacard.fields.ImageField +import com.ichi2.anki.multimediacard.impl.MultimediaEditableNote +import com.ichi2.imagecropper.ImageCropper +import org.junit.rules.ExternalResource +import org.robolectric.Shadows.shadowOf +import org.robolectric.android.controller.ActivityController +import org.robolectric.util.ReflectionHelpers +import java.io.File +import kotlin.test.assertIs +import kotlin.test.assertNotNull + +/** Launches the image editor and supplies results from its cropper and picker. */ +class ImageEditorTestHelper( + private val test: RobolectricTest, + private val compose: SemanticsNodeInteractionsProvider, +) : ExternalResource() { + private val context get() = test.targetContext + private lateinit var controller: ActivityController + + val fragment: MultimediaImageFragment + get() = controller.get().supportFragmentManager.findFragmentById(R.id.fragment_container) as MultimediaImageFragment + + override fun before() = clearFileProviderRoots() + + override fun after() = clearFileProviderRoots() + + private fun clearFileProviderRoots() { + // Each Robolectric test has its own cache directory; discard AndroidX's cached roots. + ReflectionHelpers.getStaticField>(FileProvider::class.java, "sCache").clear() + } + + fun open(uri: Uri) { + val field = ImageField() + val note = + MultimediaEditableNote().apply { + setNumFields(1) + setField(0, field) + freezeInitialFieldValues() + } + val intent = + MultimediaImageFragment.getIntent( + context, + MultimediaActivityExtra(0, field, note, uri.toString()), + MultimediaImageFragment.ImageOptions.GALLERY, + ) + controller = test.startActivityControllerNormallyOpenCollectionWithIntent(MultimediaActivity::class.java, intent) + } + + fun cropTo(image: File) { + val result = Intent().putExtra(ImageCropper.CROP_IMAGE_RESULT, ImageCropper.CropResultData(contentUri(image), image.absolutePath)) + returnImage(CommonString.crop_button, result) + } + + fun replaceWith(image: File) = returnImage(CommonString.dialog_positive_replace, Intent().setData(contentUri(image))) + + private fun returnImage( + @StringRes button: Int, + result: Intent, + ) { + compose.onNodeWithText(context.getString(button)).performClick() + val activity = shadowOf(controller.get()) + val request = assertNotNull(activity.nextStartedActivityForResult) + activity.receiveResult(request.intent, Activity.RESULT_OK, result) + advanceRobolectricLooper() + } + + fun recreate() { + controller.recreate() + advanceRobolectricLooper() + } + + fun finish(): File { + fragment.onDone() + val activity = shadowOf(controller.get()) + val result = assertIs(MultimediaResultContract().parseResult(activity.resultCode, activity.resultIntent)) + return assertNotNull(result.field.mediaFile) + } + + fun createImage( + name: String, + color: Int, + ): File { + val image = File(context.cacheDir, name) + val bitmap = createBitmap(8, 8) + bitmap.eraseColor(color) + image.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) } + bitmap.recycle() + return image + } + + fun contentUri(file: File): Uri = FileProvider.getUriForFile(context, context.packageName + ".apkgfileprovider", file) +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/multimedia/MultimediaImageRecreationTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/multimedia/MultimediaImageRecreationTest.kt new file mode 100644 index 000000000000..9ff627a33d23 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/multimedia/MultimediaImageRecreationTest.kt @@ -0,0 +1,87 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.multimedia + +import android.graphics.Color +import android.net.Uri +import android.text.format.Formatter +import android.webkit.WebView +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.RobolectricTest +import com.ichi2.testutils.ShadowFileProviderPathStrategy +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import java.io.File +import kotlin.test.assertContains +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +@RunWith(AndroidJUnit4::class) +@Config(shadows = [ShadowFileProviderPathStrategy::class]) +class MultimediaImageRecreationTest : RobolectricTest(loadBackend = false) { + @get:Rule + val composeRule = createComposeRule() + + @get:Rule + val editor = ImageEditorTestHelper(this, composeRule) + + @Test + fun `cropped gallery image survives recreation`() { + val original = editor.createImage("original.png", Color.RED) + assertCropSurvivesRecreation(editor.contentUri(original)) + } + + @Test + fun `cropped shared image survives recreation`() { + val original = editor.createImage("original.png", Color.RED) + assertCropSurvivesRecreation(Uri.fromFile(original)) + } + + @Test + fun `replacement image survives recreation`() { + editor.open(editor.contentUri(editor.createImage("original.png", Color.RED))) + val replacement = editor.createImage("replacement.png", Color.BLUE) + editor.replaceWith(replacement) + + assertEditSurvivesRecreation(replacement) + } + + private fun assertCropSurvivesRecreation(original: Uri) { + editor.open(original) + val cropped = editor.createImage("cropped.png", Color.GREEN) + editor.cropTo(cropped) + + assertEditSurvivesRecreation(cropped) + } + + private fun assertEditSurvivesRecreation(expectedImage: File) { + val editedFile = assertNotNull(editor.fragment.viewModel.currentMultimediaPath.value) + val expectedUri = editor.contentUri(expectedImage) + assertEquals(expectedUri, editor.fragment.viewModel.currentMultimediaUri.value) + + editor.recreate() + + val fragment = editor.fragment + assertEquals(editedFile, fragment.viewModel.currentMultimediaPath.value) + assertEquals(expectedUri, fragment.viewModel.currentMultimediaUri.value) + assertEquals(expectedImage.length(), fragment.viewModel.selectedMediaFileSize) + assertEquals( + Formatter.formatFileSize(targetContext, expectedImage.length()), + fragment.binding.imageFileSize.text + .toString(), + ) + val webView = fragment.binding.multimediaWebView.getChildAt(0) as WebView + assertContains(shadowOf(webView).lastLoadDataWithBaseURL.data, editedFile.name) + assertNull(shadowOf(fragment.requireActivity()).nextStartedActivityForResult) + + val result = editor.finish() + assertEquals(editedFile, result) + assertContentEquals(expectedImage.readBytes(), result.readBytes()) + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/noteeditor/NoteEditorMultimediaControllerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/noteeditor/NoteEditorMultimediaControllerTest.kt index d867beaf7c11..21b09bc0149c 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/noteeditor/NoteEditorMultimediaControllerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/noteeditor/NoteEditorMultimediaControllerTest.kt @@ -3,11 +3,19 @@ package com.ichi2.anki.noteeditor +import android.app.Activity +import android.content.Intent +import android.net.Uri import android.os.Bundle +import android.provider.MediaStore +import androidx.core.net.toUri import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.NoteEditorActivity import com.ichi2.anki.NoteEditorFragment import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.common.destinations.NoteEditorDestination import com.ichi2.anki.compat.CompatHelper.Companion.getSerializableCompat +import com.ichi2.anki.multimedia.MultimediaActivity import com.ichi2.anki.multimedia.MultimediaArgsStorage import com.ichi2.anki.multimedia.MultimediaBottomSheet import com.ichi2.anki.multimedia.MultimediaResult @@ -21,6 +29,7 @@ import kotlinx.coroutines.test.advanceUntilIdle import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.equalTo import org.hamcrest.Matchers.notNullValue +import org.hamcrest.Matchers.nullValue import org.junit.Test import org.junit.runner.RunWith import org.robolectric.Shadows.shadowOf @@ -30,6 +39,84 @@ import java.io.IOException @RunWith(AndroidJUnit4::class) class NoteEditorMultimediaControllerTest : RobolectricTest() { + @Test + fun `cancelling gallery returns directly to the unchanged note editor`() { + val noteEditor = openNoteEditor() + val field = noteEditor.getFieldForTest(1) + field.setText("back") + field.requestFocus() + field.setSelection(1, 3) + selectGallery(noteEditor, 1) + val activity = shadowOf(noteEditor.requireActivity()) + val picker = activity.nextStartedActivityForResult.intent + + assertThat(picker.action, equalTo(Intent.ACTION_PICK)) + assertThat(picker.data, equalTo(MediaStore.Images.Media.EXTERNAL_CONTENT_URI)) + assertThat("no intermediate editor", picker.component, nullValue()) + activity.receiveResult(picker, Activity.RESULT_CANCELED, null) + advanceRobolectricLooper() + + assertThat(activity.nextStartedActivityForResult, nullValue()) + assertThat(noteEditor.requireActivity().isFinishing, equalTo(false)) + assertThat(field.text.toString(), equalTo("back")) + assertThat(field.selectionStart, equalTo(1)) + assertThat(field.selectionEnd, equalTo(3)) + + selectGallery(noteEditor, 1) + assertThat(activity.nextStartedActivityForResult.intent.action, equalTo(Intent.ACTION_PICK)) + } + + @Test + fun `gallery selection opens the image editor for the selected field with read access`() { + val noteEditor = openNoteEditor() + noteEditor.setField(0, "front") + noteEditor.setField(1, "back") + selectGallery(noteEditor, 1) + val activity = shadowOf(noteEditor.requireActivity()) + val picker = activity.nextStartedActivityForResult.intent + val imageUri = "content://media/external/images/media/1".toUri() + + activity.receiveResult(picker, Activity.RESULT_OK, Intent().setData(imageUri)) + advanceRobolectricLooper() + + assertGalleryEditor(activity.nextStartedActivityForResult.intent, imageUri, 1) + assertThat(noteEditor.editFieldAt(0)!!.text.toString(), equalTo("front")) + assertThat(noteEditor.editFieldAt(1)!!.text.toString(), equalTo("back")) + } + + @Test + fun `gallery result retains its target field after note editor recreation`() { + ensureCollectionLoadIsSynchronous() + val controller = + startActivityControllerNormallyOpenCollectionWithIntent( + NoteEditorActivity::class.java, + NoteEditorDestination.PassArguments(NoteEditorFragment.addNoteArgs()).toIntent(targetContext), + ) + selectGallery(controller.get().getNoteEditorFragment(), 1) + val picker = shadowOf(controller.get()).nextStartedActivityForResult + + controller.recreate() + advanceRobolectricLooper() + val imageUri = "content://media/external/images/media/2".toUri() + controller.get().activityResultRegistry.dispatchResult(picker.requestCode, Activity.RESULT_OK, Intent().setData(imageUri)) + advanceRobolectricLooper() + + assertGalleryEditor(shadowOf(controller.get()).nextStartedActivityForResult.intent, imageUri, 1) + } + + @Test + fun `gallery file uri result cannot open the image editor`() { + val noteEditor = openNoteEditor() + selectGallery(noteEditor, 0) + val activity = shadowOf(noteEditor.requireActivity()) + val picker = activity.nextStartedActivityForResult.intent + + activity.receiveResult(picker, Activity.RESULT_OK, Intent().setData(Uri.fromFile(File("/private/image.png")))) + advanceRobolectricLooper() + + assertThat(activity.nextStartedActivityForResult, nullValue()) + } + @Test fun `controller is reachable from the hosted fragment`() { val noteEditor = openNoteEditor() @@ -129,6 +216,30 @@ class NoteEditorMultimediaControllerTest : RobolectricTest() { assertThat(editText.selectionEnd, equalTo(5)) } + private fun selectGallery( + noteEditor: NoteEditorFragment, + fieldIndex: Int, + ) { + noteEditor.multimediaController.handleActions(fieldIndex) + advanceRobolectricLooper() + noteEditor.multimediaViewModel.setMultimediaAction(MultimediaBottomSheet.MultimediaAction.SELECT_IMAGE_FILE) + advanceRobolectricLooper() + } + + private fun assertGalleryEditor( + intent: Intent, + imageUri: Uri, + fieldIndex: Int, + ) { + assertThat(intent.component!!.className, equalTo(MultimediaActivity::class.java.name)) + assertThat(intent.data, equalTo(imageUri)) + assertThat(intent.flags and Intent.FLAG_GRANT_READ_URI_PERMISSION, equalTo(Intent.FLAG_GRANT_READ_URI_PERMISSION)) + val argsFile = intent.extras!!.getSerializableCompat(MultimediaActivity.EXTRA_FRAGMENT_ARGS)!! + val args = MultimediaArgsStorage.create(targetContext).read(argsFile) + assertThat(args.index, equalTo(fieldIndex)) + assertThat(args.imageUri, equalTo(imageUri.toString())) + } + private fun openNoteEditor(): NoteEditorFragment { ensureCollectionLoadIsSynchronous() return openNoteEditorWithArgs(NoteEditorFragment.addNoteArgs()) From 77052f0915644b46032ace3d59ca4eb4fa22e3f2 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 13 Oct 2026 23:31:11 +0100 Subject: [PATCH 008/163] test(reviewer): wait for autofocus before changing fields The non-nosuggest field could have been focused first, before autofocus occurred asynchronously Part of 22060 Partially fixes 22483 Assisted-by: GPT-6-Astra default --- .../com/ichi2/anki/cardviewer/HtmlTypeAnswerReviewerTest.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/cardviewer/HtmlTypeAnswerReviewerTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/cardviewer/HtmlTypeAnswerReviewerTest.kt index 8dd588fb39a0..731015c2d2a0 100644 --- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/cardviewer/HtmlTypeAnswerReviewerTest.kt +++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/cardviewer/HtmlTypeAnswerReviewerTest.kt @@ -134,6 +134,8 @@ class HtmlTypeAnswerReviewerTest : InstrumentedTest() { @Test fun onlyTheMarkedAnswerDisablesSuggestions() = withReviewer { webView -> + // Let reviewer autofocus finish before moving to another field. + focusAnswer(webView, noSuggest = true) webView.focusInput("ordinary") val baseline = keyboard.awaitEditor().inputType webView.focusInput("typeans") From 4f0c5743dc8b4ce856edadb089aabfa32f902c1c Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Fri, 2 Oct 2026 21:02:41 +0100 Subject: [PATCH 009/163] feat(study-screen): support HTML `nosuggest` In the same style as the legacy reviewer Part of 22060 Assisted-by: GPT-6-Astra default  Conflicts:  AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragmentTest.kt --- .../cardviewer/HtmlTypeAnswerReviewerTest.kt | 28 ++++++++--- .../com/ichi2/anki/cardviewer/TypeAnswer.kt | 9 +--- .../anki/cardviewer/TypeAnswerKeyboard.kt | 11 +++++ .../anki/cardviewer/TypeAnswerWebView.kt | 10 ++++ .../ui/windows/reviewer/ReviewerFragment.kt | 2 +- .../ui/windows/reviewer/ReviewerViewModel.kt | 7 +++ .../src/main/res/layout/fragment_reviewer.xml | 4 +- .../windows/reviewer/ReviewerFragmentTest.kt | 15 ++++++ .../windows/reviewer/ReviewerViewModelTest.kt | 47 +++++++++++++++++++ 9 files changed, 115 insertions(+), 18 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswerKeyboard.kt diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/cardviewer/HtmlTypeAnswerReviewerTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/cardviewer/HtmlTypeAnswerReviewerTest.kt index 731015c2d2a0..23f26fae8e1f 100644 --- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/cardviewer/HtmlTypeAnswerReviewerTest.kt +++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/cardviewer/HtmlTypeAnswerReviewerTest.kt @@ -2,6 +2,7 @@ package com.ichi2.anki.cardviewer +import android.app.Activity import android.text.InputType import android.view.inputmethod.EditorInfo import android.webkit.WebView @@ -17,6 +18,7 @@ import com.ichi2.anki.Reviewer import com.ichi2.anki.common.preferences.sharedPrefs import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.libanki.NoteTypeId +import com.ichi2.anki.previewer.CardViewerActivity import com.ichi2.anki.tests.InstrumentedTest import com.ichi2.anki.tests.checkWithTimeout import com.ichi2.anki.testutil.AvoidDayRolloverRule @@ -27,6 +29,7 @@ import com.ichi2.anki.testutil.ensureWebViewIsSupported import com.ichi2.anki.testutil.focusInput import com.ichi2.anki.testutil.grantPermissions import com.ichi2.anki.testutil.notificationPermission +import com.ichi2.anki.ui.windows.reviewer.ReviewerFragment import org.junit.After import org.junit.Before import org.junit.Rule @@ -34,13 +37,18 @@ import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.Parameterized import java.util.UUID +import kotlin.test.assertIs import kotlin.test.assertNotNull -/** Checks the legacy reviewer's HTML, script loading, and WebView keyboard support together. */ +/** Checks the real reviewers' HTML, script loading, and WebView keyboard support together. */ @RunWith(Parameterized::class) class HtmlTypeAnswerReviewerTest : InstrumentedTest() { @JvmField // required for Parameter @Parameterized.Parameter + var newReviewer = false + + @JvmField // required for Parameter + @Parameterized.Parameter(1) var autoFocus = false @get:Rule @@ -72,7 +80,7 @@ class HtmlTypeAnswerReviewerTest : InstrumentedTest() { originalPreferences = listOf(newReviewerKey, "useInputTag", "autoFocusTypeInAnswer", doubleTapKey).associateWith { preferences.all[it] } preferences.edit { - putBoolean(newReviewerKey, false) + putBoolean(newReviewerKey, newReviewer) putBoolean("useInputTag", true) putBoolean("autoFocusTypeInAnswer", autoFocus) // These tests intentionally grade immediately after Done. @@ -167,7 +175,7 @@ class HtmlTypeAnswerReviewerTest : InstrumentedTest() { keyboard.perform("done") webView.awaitJavascript("document.querySelector('#typeans .typeGood')?.textContent === 'été'") if (question != "Third") { - onView(withId(R.id.flashcard_layout_ease3)) + onView(withId(if (newReviewer) R.id.good_button else R.id.flashcard_layout_ease3)) .also { it.checkWithTimeout(matches(isDisplayed())) } .perform(click()) } @@ -192,10 +200,16 @@ class HtmlTypeAnswerReviewerTest : InstrumentedTest() { } private fun withReviewer(block: (WebView) -> Unit) { - ActivityScenario.launch(Reviewer.getIntent(testContext)).use { scenario -> + ActivityScenario.launch(Reviewer.getIntent(testContext)).use { scenario -> lateinit var webView: WebView scenario.onActivity { activity -> - webView = assertNotNull(activity.webView) + webView = + if (newReviewer) { + val fragment = assertIs(assertIs(activity).fragment) + assertIs(fragment.binding.webViewLayout.getChildAt(0)) + } else { + assertNotNull(assertIs(activity).webView) + } } webView.awaitJavascript("document.getElementById('typeans') !== null") block(webView) @@ -204,7 +218,7 @@ class HtmlTypeAnswerReviewerTest : InstrumentedTest() { companion object { @JvmStatic // required for Parameters - @Parameterized.Parameters(name = "autoFocus={0}") - fun focusModes() = listOf(false, true) + @Parameterized.Parameters(name = "newReviewer={0}, autoFocus={1}") + fun reviewers() = listOf(arrayOf(false, false), arrayOf(false, true), arrayOf(true, false), arrayOf(true, true)) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswer.kt index 074ae75fa06d..89a6bd612cb4 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswer.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswer.kt @@ -153,14 +153,7 @@ class TypeAnswer( """
0) { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswerKeyboard.kt b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswerKeyboard.kt new file mode 100644 index 000000000000..8787ef1676bf --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswerKeyboard.kt @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.cardviewer + +/** Appends the HTML answer's keyboard attributes, including a trailing space. */ +internal fun StringBuilder.appendNoSuggestAttributes() { + append("data-ankidroid-nosuggest=\"true\"") + append(' ') + append("enterkeyhint=\"done\"") + append(' ') +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswerWebView.kt b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswerWebView.kt index 42327e1e81c5..9de1937c4ff9 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswerWebView.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/TypeAnswerWebView.kt @@ -3,12 +3,14 @@ package com.ichi2.anki.cardviewer import android.content.Context +import android.util.AttributeSet import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputConnection import android.view.inputmethod.InputMethodManager import android.webkit.JavascriptInterface import android.webkit.WebView import androidx.core.content.getSystemService +import com.ichi2.anki.workarounds.SafeWebViewLayout import com.ichi2.ui.applyNoSuggest /** Applies `nosuggest` to the focused HTML answer field's keyboard connection. */ @@ -47,3 +49,11 @@ open class TypeAnswerWebView( super.destroy() } } + +/** Uses the same keyboard support when the new reviewer recreates its WebView after a crash. */ +class TypeAnswerWebViewLayout( + context: Context, + attrs: AttributeSet?, +) : SafeWebViewLayout(context, attrs) { + override fun createWebView(): WebView = TypeAnswerWebView(context) +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt index 690fd7f110a5..1b2bc75109af 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragment.kt @@ -133,7 +133,7 @@ class ReviewerFragment : override fun onLoadInitialHtml(): String = stdHtml( context = requireContext(), - extraJsAssets = listOf("scripts/ankidroid-reviewer.js"), + extraJsAssets = listOf("scripts/ankidroid-reviewer.js", "scripts/type-answer.js"), nightMode = Themes.isNightTheme, ) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModel.kt index 3329fd0eda9f..49f2295a632c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModel.kt @@ -15,6 +15,7 @@ import com.ichi2.anki.Flag import com.ichi2.anki.Reviewer import com.ichi2.anki.asyncIO import com.ichi2.anki.cardviewer.SingleCardSide +import com.ichi2.anki.cardviewer.appendNoSuggestAttributes import com.ichi2.anki.common.annotations.NeedsTest import com.ichi2.anki.common.destinations.BrowserDestination import com.ichi2.anki.common.destinations.CardInfoDestination @@ -631,12 +632,18 @@ class ReviewerViewModel( text: String, typeAnswer: TypeAnswer, ): String { + val keyboardAttributes = + buildString { + if (typeAnswer.noSuggest) appendNoSuggestAttributes() + } + @Suppress("XmlDeprecatedElement") // upstream uses `
` @Language("HTML") val repl = """
""".trimIndent() diff --git a/AnkiDroid/src/main/res/layout/fragment_reviewer.xml b/AnkiDroid/src/main/res/layout/fragment_reviewer.xml index a13c0bd006c2..1cae4d926f45 100644 --- a/AnkiDroid/src/main/res/layout/fragment_reviewer.xml +++ b/AnkiDroid/src/main/res/layout/fragment_reviewer.xml @@ -91,7 +91,7 @@ android:layout_weight="1" android:layout_marginHorizontal="@dimen/reviewer_side_margin"> - - \ No newline at end of file + diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragmentTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragmentTest.kt index 05d6893edf62..d57e6e32a5d8 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragmentTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/ReviewerFragmentTest.kt @@ -17,6 +17,7 @@ import anki.scheduler.CardAnswer.Rating import com.ichi2.anki.R import com.ichi2.anki.RobolectricTest import com.ichi2.anki.cardviewer.Gesture +import com.ichi2.anki.cardviewer.TypeAnswerWebView import com.ichi2.anki.common.preferences.sharedPrefs import com.ichi2.anki.dialogs.tags.TagsDialog import com.ichi2.anki.preferences.reviewer.ViewerAction @@ -71,6 +72,20 @@ class ReviewerFragmentTest : RobolectricTest() { } } + @Test + fun `recreated WebView retains keyboard support`() = + runTest { + addBasicWithTypingNote("front", "back") + withReviewer { + val layout = binding.webViewLayout + val original = assertIs(layout.getChildAt(0)) + layout.onRenderProcessGone(original) + val replacement = assertIs(layout.getChildAt(0)) + assertNotSame(original, replacement) + assertNotNull(shadowOf(replacement).getJavascriptInterface("AnkiDroidKeyboard")) + } + } + @Test fun `software rendering is applied to the study screen when enabled`() = runTest { diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModelTest.kt index 690cc09c60ba..f3539c330271 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ui/windows/reviewer/ReviewerViewModelTest.kt @@ -2,20 +2,29 @@ package com.ichi2.anki.ui.windows.reviewer +import androidx.core.content.edit import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModelStore import androidx.test.ext.junit.runners.AndroidJUnit4 import anki.scheduler.CardAnswer.Rating import app.cash.turbine.test import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.common.preferences.sharedPrefs import com.ichi2.anki.common.utils.ext.cardStateCustomizer +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle +import org.json.JSONTokener +import org.jsoup.Jsoup +import org.jsoup.nodes.Element import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) @@ -35,6 +44,44 @@ class ReviewerViewModelTest : RobolectricTest() { viewModelStore.clear() } + @Test + fun `HTML answer marks nosuggest fields and requests a Done key`() = + runTest { + val field = htmlAnswerField("nosuggest:type") + + assertEquals("true", field.attr("data-ankidroid-nosuggest")) + assertEquals("done", field.attr("enterkeyhint")) + } + + @Test + fun `ordinary HTML answers keep their keyboard defaults`() = + runTest { + val field = htmlAnswerField("type") + + assertFalse(field.hasAttr("data-ankidroid-nosuggest")) + assertFalse(field.hasAttr("enterkeyhint")) + } + + private suspend fun TestScope.htmlAnswerField(filter: String): Element { + targetContext.sharedPrefs().edit { putBoolean("useInputTag", true) } + val card = addBasicWithTypingNote("front", "back").firstCard() + val noteType = card.noteType(col) + noteType.templates[0].qfmt = "{{Front}}{{$filter:Back}}" + col.notetypes.save(noteType) + val viewModel = ReviewerViewModel(SavedStateHandle()).also { viewModelStore.put("reviewer", it) } + lateinit var html: String + viewModel.eval + .filter { it.startsWith("_showQuestion(") } + .test { + viewModel.onPageFinished(false) + advanceUntilIdle() + // The first argument is a JSON string containing the question HTML. + html = assertIs(JSONTokener(awaitItem().removePrefix("_showQuestion(")).nextValue()) + cancelAndIgnoreRemainingEvents() + } + return assertNotNull(Jsoup.parseBodyFragment(html).selectFirst("input#typeans")) + } + @Test fun `answering waits for the custom scheduler of the first card`() = runTest { From 99b6f9b3d99c0a32621b9987f6c0207ccd2d43fb Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:56:55 +0100 Subject: [PATCH 010/163] fix(editor): use backend filenames when pasting media We picked a name, inserted into the collection, then used the name in the HTML. This could be wrong, as the backend could normalize/rename a media file Assisted-by: GPT-6-Astra default --- .../java/com/ichi2/anki/MediaRegistration.kt | 23 +++--- .../com/ichi2/anki/MediaRegistrationTest.kt | 76 +++++++++++++++++++ 2 files changed, 86 insertions(+), 13 deletions(-) create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/MediaRegistrationTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/MediaRegistration.kt b/AnkiDroid/src/main/java/com/ichi2/anki/MediaRegistration.kt index 39889d4771f3..07d23332199a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/MediaRegistration.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/MediaRegistration.kt @@ -197,7 +197,8 @@ object MediaRegistration { } // register media for webView - if (!registerMediaForWebView(tempFilePath)) { + val importedFile = registerMediaForWebView(tempFilePath) + if (importedFile == null) { clipCopy.delete() return null } @@ -205,8 +206,7 @@ object MediaRegistration { val field = if (isImage) ImageField() else MediaClipField() - field.hasTemporaryMedia = true - field.mediaFile = tempFilePath + field.mediaFile = importedFile return field.formattedValue } @@ -231,25 +231,22 @@ object MediaRegistration { } /** + * @return the imported file, including any filename changes made by the backend, or null on failure + * * @throws OutOfMemoryError if the file could not be copied to a contiguous block of memory (or is >= 2GB) */ @CheckResult - fun registerMediaForWebView(mediaPath: File?): Boolean { - if (mediaPath == null) { - // Nothing to register - continue with execution. - return true - } + private fun registerMediaForWebView(mediaPath: File): File? { Timber.i("Adding media to collection: %s", mediaPath) - val f = mediaPath return try { - CollectionManager.getColUnsafe().media.addFile(f) - true + val media = CollectionManager.getColUnsafe().media + File(media.dir, media.addFile(mediaPath)) } catch (e: IOException) { Timber.w(e, "Failed to add file") - false + null } catch (e: EmptyMediaException) { Timber.w(e, "Failed to add file") - false + null } } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/MediaRegistrationTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/MediaRegistrationTest.kt new file mode 100644 index 000000000000..7cd991b386d8 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/MediaRegistrationTest.kt @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki + +import android.content.ClipDescription +import android.content.ContentProvider +import android.database.MatrixCursor +import android.net.Uri +import android.provider.OpenableColumns +import androidx.core.net.toUri +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import org.robolectric.Shadows.shadowOf +import org.robolectric.shadows.ShadowContentResolver +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +class MediaRegistrationTest : RobolectricTest() { + override fun getCollectionStorageMode() = CollectionStorageMode.ON_DISK + + @Test + fun `pasted image references the normalized media filename`() { + assertPastedMedia("image.png", "image/png", """""") + } + + @Test + fun `pasted audio references the normalized media filename`() { + assertPastedMedia("audio.mp3", "audio/mpeg", "[sound:%s]") + } + + private fun assertPastedMedia( + filename: String, + mimeType: String, + expectedTag: String, + ) { + val data = "pasted media".toByteArray() + // Exceed the backend's filename length limit using characters valid on every OS. + val originalFilename = "a".repeat(120) + filename + val uri = stubClipboardMedia(originalFilename, data) + + val html = + MediaRegistration.loadMediaIntoCollection( + targetContext, + uri, + ClipDescription("media", arrayOf(mimeType)), + pasteAsPng = false, + showError = { error("Unexpected paste error: $it") }, + ) + + val importedFile = + col.media.dir + .listFiles()!! + .single() + assertTrue(importedFile.name.length < originalFilename.length, "Expected the backend to shorten the filename") + assertContentEquals(data, importedFile.readBytes()) + assertEquals(expectedTag.format(importedFile.name), html) + } + + private fun stubClipboardMedia( + filename: String, + data: ByteArray, + ): Uri { + val uri = "content://clipboard/media".toUri() + val cursor = MatrixCursor(arrayOf(OpenableColumns.DISPLAY_NAME)).apply { addRow(arrayOf(filename)) } + val provider = mock() + whenever(provider.query(uri, null, null, null, null)).thenReturn(cursor) + ShadowContentResolver.registerProviderInternal("clipboard", provider) + shadowOf(targetContext.contentResolver).registerInputStreamSupplier(uri) { data.inputStream() } + return uri + } +} From 84f4ad120f0a04ba3d510f261af7c3e48ff0f86e Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Thu, 15 Oct 2026 02:03:35 +0530 Subject: [PATCH 011/163] perf(deck-picker): release the background image when stopped The Deck Picker kept its full-size background bitmap for as long as the activity lived, even while reviewing or in the background. Load it when the screen starts and drop it when it stops, still reporting a broken image only once. Fixes 22514 Co-authored-by: Mike Hardy Assisted-by: Claude Opus 5.5 --- .../main/java/com/ichi2/anki/DeckPicker.kt | 24 ++++++- .../deckpicker/DeckPickerBackgroundTest.kt | 67 +++++++++++++++++++ 2 files changed, 88 insertions(+), 3 deletions(-) create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/DeckPickerBackgroundTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt index 7f8194ae4104..53f03539d287 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt @@ -65,6 +65,7 @@ import androidx.fragment.app.commitNow import androidx.lifecycle.Lifecycle import androidx.lifecycle.flowWithLifecycle import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.LinearLayoutManager import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.work.NetworkType @@ -221,6 +222,7 @@ import com.ichi2.utils.showDialogIfWebViewOutdated import com.ichi2.utils.title import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.launch @@ -301,6 +303,7 @@ open class DeckPicker : private lateinit var decksLayoutManager: LinearLayoutManager private lateinit var deckListAdapter: DeckAdapter + private var backgroundImageErrorShown = false private lateinit var pullToSyncWrapper: SwipeRefreshLayout @VisibleForTesting @@ -588,7 +591,16 @@ open class DeckPicker : ) } - lifecycleScope.launch { applyDeckPickerBackground() } + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + applyDeckPickerBackground() + try { + awaitCancellation() + } finally { + deckPickerBinding.background.setImageDrawable(null) + } + } + } setupPullToSync() // Setup the FloatingActionButtons @@ -1212,17 +1224,23 @@ open class DeckPicker : private suspend fun applyDeckPickerBackground() { val result = BackgroundImage.resolve(this) if (result is BackgroundImage.ResolveResult.Failure) { - showThemedToast(this, result.message(this), shortLength = false) + showBackgroundImageError(result.message(this)) } val drawable = (result as? BackgroundImage.ResolveResult.Ready)?.drawable val applied = deckPickerBinding.background.setImageDrawableSafe(drawable) { - showThemedToast(this, getString(CommonString.background_image_too_large), shortLength = false) + showBackgroundImageError(getString(CommonString.background_image_too_large)) } // activityHasBackground calls notifyDataSetChanged, ensure only 1 call deckListAdapter.activityHasBackground = applied && drawable != null } + private fun showBackgroundImageError(message: String) { + if (backgroundImageErrorShown) return + backgroundImageErrorShown = true + showThemedToast(this, message, shortLength = false) + } + override fun onCreateOptionsMenu(menu: Menu): Boolean { if (viewModel.flowOfStartupResponse.value is StartupResponse.FatalError) { return false diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/DeckPickerBackgroundTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/DeckPickerBackgroundTest.kt new file mode 100644 index 000000000000..7f0528cc1bd0 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/DeckPickerBackgroundTest.kt @@ -0,0 +1,67 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.deckpicker + +import android.content.Intent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.CommonString +import com.ichi2.anki.DeckPicker +import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.common.storage.CollectionHelper +import com.ichi2.anki.setIntroductionSlidesShown +import com.ichi2.anki.settings.Prefs +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo +import org.hamcrest.Matchers.nullValue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.android.controller.ActivityController +import org.robolectric.shadows.ShadowToast +import java.awt.image.BufferedImage +import java.io.File +import javax.imageio.ImageIO + +@RunWith(AndroidJUnit4::class) +class DeckPickerBackgroundTest : RobolectricTest() { + @Test + fun `background is released while the deck picker is stopped`() { + saveBackgroundImage(width = 10, height = 10) + val controller = startDeckPicker() + val background = controller.get().deckPickerBinding.background + advanceRobolectricLooperUntil { background.drawable != null } + + controller.pause().stop() + assertThat(background.drawable, nullValue()) + + controller.start().resume() + advanceRobolectricLooperUntil { background.drawable != null } + } + + @Test + fun `a background that is too large is reported once`() { + saveBackgroundImage(width = 6_000, height = 5_000) + val controller = startDeckPicker() + advanceRobolectricLooperUntil { ShadowToast.shownToastCount() > 0 } + + controller.pause().stop() + controller.start().resume() + advanceRobolectricLooper() + + assertThat(ShadowToast.shownToastCount(), equalTo(1)) + assertThat(ShadowToast.getTextOfLatestToast(), equalTo(targetContext.getString(CommonString.background_image_too_large))) + } + + private fun saveBackgroundImage( + width: Int, + height: Int, + ) { + val directory = CollectionHelper.getCurrentAnkiDroidDirectory(targetContext).apply { mkdirs() } + ImageIO.write(BufferedImage(width, height, BufferedImage.TYPE_BYTE_BINARY), "png", File(directory, BackgroundImage.FILENAME)) + Prefs.isBackgroundEnabled = true + } + + private fun startDeckPicker(): ActivityController { + setIntroductionSlidesShown(true) + return startActivityControllerNormallyOpenCollectionWithIntent(DeckPicker::class.java, Intent()) + } +} From 2d10b790076a2b518174fbe2c5e28556e74b1ad7 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:56:00 +0100 Subject: [PATCH 012/163] refactor: use REQUEST_ for Fragment Result API request keys Rename request key constants while preserving their string values. Part of 22409 Assisted-by: GPT-6-Astra default --- .../main/java/com/ichi2/anki/IntroductionActivity.kt | 4 ++-- .../anki/dialogs/customstudy/CustomStudyDialog.kt | 4 ++-- .../com/ichi2/anki/dialogs/tags/TagsDialogListener.kt | 6 +++--- .../ichi2/anki/introduction/SetupCollectionFragment.kt | 4 ++-- .../java/com/ichi2/anki/scheduling/SetDueDateDialog.kt | 10 +++++----- .../LegacyNotificationsPermissionFragment.kt | 2 +- .../permissions/NotificationsPermissionFragment.kt | 2 +- .../anki/ui/windows/permissions/PermissionsActivity.kt | 10 +++++----- .../ui/windows/permissions/PermissionsBottomSheet.kt | 4 ++-- .../anki/ui/windows/permissions/PermissionsFragment.kt | 4 ++-- .../permissions/PermissionsStartingAt30Fragment.kt | 2 +- .../java/com/ichi2/anki/IntroductionActivityTest.kt | 4 ++-- .../com/ichi2/anki/dialogs/CustomStudyDialogTest.kt | 4 ++-- 13 files changed, 30 insertions(+), 30 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/IntroductionActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/IntroductionActivity.kt index c7e8bd5cc6ae..4c043d28c09d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/IntroductionActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/IntroductionActivity.kt @@ -20,7 +20,7 @@ import com.ichi2.anki.common.preferences.sharedPrefs import com.ichi2.anki.introduction.CollectionPermissionScreenLauncher import com.ichi2.anki.introduction.SetupCollectionFragment import com.ichi2.anki.introduction.SetupCollectionFragment.CollectionSetupOption -import com.ichi2.anki.introduction.SetupCollectionFragment.Companion.FRAGMENT_KEY +import com.ichi2.anki.introduction.SetupCollectionFragment.Companion.REQUEST_COLLECTION_SETUP import com.ichi2.anki.introduction.SetupCollectionFragment.Companion.RESULT_KEY import com.ichi2.anki.introduction.hasCollectionStoragePermissions import com.ichi2.anki.utils.ext.setFragmentResultListener @@ -63,7 +63,7 @@ class IntroductionActivity : super.onCreate(savedInstanceState) enableEdgeToEdge(statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT)) - setFragmentResultListener(FRAGMENT_KEY) { _, bundle -> + setFragmentResultListener(REQUEST_COLLECTION_SETUP) { _, bundle -> val option = BundleCompat.getParcelable(bundle, RESULT_KEY, CollectionSetupOption::class.java) ?: error("Missing introduction option!") when (option) { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/CustomStudyDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/CustomStudyDialog.kt index b8a2216b11c3..9484aec6adb9 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/CustomStudyDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/CustomStudyDialog.kt @@ -55,8 +55,8 @@ import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog.ContextMenuOption.ST import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog.ContextMenuOption.STUDY_TAGS import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog.CustomStudyDefaults.Companion.toDomainModel import com.ichi2.anki.dialogs.tags.TagsDialog -import com.ichi2.anki.dialogs.tags.TagsDialogListener.Companion.ON_SELECTED_TAGS_KEY import com.ichi2.anki.dialogs.tags.TagsDialogListener.Companion.ON_SELECTED_TAGS__SELECTED_TAGS +import com.ichi2.anki.dialogs.tags.TagsDialogListener.Companion.REQUEST_SELECT_TAGS import com.ichi2.anki.launchCatchingTask import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.observability.undoableOp @@ -137,7 +137,7 @@ class CustomStudyDialog : AnalyticsDialogFragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - parentFragmentManager.setFragmentResultListener(ON_SELECTED_TAGS_KEY, this) { _, bundle -> + parentFragmentManager.setFragmentResultListener(REQUEST_SELECT_TAGS, this) { _, bundle -> val tagsToInclude = bundle.getStringArrayList(ON_SELECTED_TAGS__SELECTED_TAGS) ?: emptyList() val option = selectedSubDialog ?: return@setFragmentResultListener val selectedCardStateIndex = viewModel.selectedCardStateIndex diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialogListener.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialogListener.kt index 3de706901c52..d8b9d1a07cc7 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialogListener.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialogListener.kt @@ -30,7 +30,7 @@ interface TagsDialogListener { fun F.registerFragmentResultReceiver() where F : Fragment, F : TagsDialogListener { parentFragmentManager.setFragmentResultListener( - ON_SELECTED_TAGS_KEY, + REQUEST_SELECT_TAGS, this, ) { _: String?, bundle: Bundle -> val selectedTags: List = @@ -56,11 +56,11 @@ interface TagsDialogListener { putStringArrayList(ON_SELECTED_TAGS__INDETERMINATE_TAGS, ArrayList(indeterminateTags)) putSerializable(ON_SELECTED_TAGS__OPTION, stateFilter) } - fragmentManager.setFragmentResult(ON_SELECTED_TAGS_KEY, bundle) + fragmentManager.setFragmentResult(REQUEST_SELECT_TAGS, bundle) } } - const val ON_SELECTED_TAGS_KEY = "ON_SELECTED_TAGS_KEY" + const val REQUEST_SELECT_TAGS = "ON_SELECTED_TAGS_KEY" const val ON_SELECTED_TAGS__SELECTED_TAGS = "SELECTED_TAGS" const val ON_SELECTED_TAGS__INDETERMINATE_TAGS = "INDETERMINATE_TAGS" const val ON_SELECTED_TAGS__OPTION = "OPTION" diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/introduction/SetupCollectionFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/introduction/SetupCollectionFragment.kt index 1398ab3a170e..61f491a2b583 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/introduction/SetupCollectionFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/introduction/SetupCollectionFragment.kt @@ -64,7 +64,7 @@ class SetupCollectionFragment : Fragment(R.layout.fragment_introduction) { } private fun setResult(option: CollectionSetupOption) { - setFragmentResult(FRAGMENT_KEY, Bundle().apply { putParcelable(RESULT_KEY, option) }) + setFragmentResult(REQUEST_COLLECTION_SETUP, Bundle().apply { putParcelable(RESULT_KEY, option) }) } @Parcelize @@ -77,7 +77,7 @@ class SetupCollectionFragment : Fragment(R.layout.fragment_introduction) { } companion object { - const val FRAGMENT_KEY = "collectionSetup" + const val REQUEST_COLLECTION_SETUP = "collectionSetup" const val RESULT_KEY = "result" } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/SetDueDateDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/SetDueDateDialog.kt index 4298b0897805..bbe751971a45 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/SetDueDateDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/SetDueDateDialog.kt @@ -113,8 +113,8 @@ class SetDueDateDialog : AnalyticsDialogFragment() { Timber.d("Set due date dialog: %d card(s)", cardIds.size) this.initialRotation = getScreenRotation() - childFragmentManager.setFragmentResultListener(RESULT_SUBMIT_DUE_DATE, this) { _, _ -> - Timber.i(RESULT_SUBMIT_DUE_DATE) + childFragmentManager.setFragmentResultListener(REQUEST_SUBMIT_DUE_DATE, this) { _, _ -> + Timber.i(REQUEST_SUBMIT_DUE_DATE) requireActivity().launchCatchingTask { if (launchUpdateDueDate(showError = false).await() == null) { Timber.w("unsuccessful updating dates; not dismissing dialog") @@ -266,7 +266,7 @@ class SetDueDateDialog : AnalyticsDialogFragment() { const val ARG_IDS_FILE = "ARGS_IDS_FILE" const val MAX_WIDTH_DP = 450f - private const val RESULT_SUBMIT_DUE_DATE = "SubmitDueDate" + private const val REQUEST_SUBMIT_DUE_DATE = "SubmitDueDate" /** Keeps the existing dialog and its input when another request arrives. */ @MainThread @@ -348,7 +348,7 @@ class SetDueDateDialog : AnalyticsDialogFragment() { event?.keyCode == KeyEvent.KEYCODE_ENTER ) { parentFragmentManager.setFragmentResult( - RESULT_SUBMIT_DUE_DATE, + REQUEST_SUBMIT_DUE_DATE, Bundle(), ) true @@ -423,7 +423,7 @@ class SetDueDateDialog : AnalyticsDialogFragment() { event?.keyCode == KeyEvent.KEYCODE_ENTER ) { parentFragmentManager.setFragmentResult( - RESULT_SUBMIT_DUE_DATE, + REQUEST_SUBMIT_DUE_DATE, Bundle(), ) true diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/LegacyNotificationsPermissionFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/LegacyNotificationsPermissionFragment.kt index b036fa9f1a4c..4cd2d9c86e4c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/LegacyNotificationsPermissionFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/LegacyNotificationsPermissionFragment.kt @@ -35,7 +35,7 @@ class LegacyNotificationsPermissionFragment : PermissionsFragment(R.layout.fragm // onResume is called after returning from the OS settings if (canPostNotifications(requireContext())) { // Post a fragment result to indicate that the bottom sheet can be dismissed - setFragmentResult(PermissionsBottomSheet.RESULT_DISMISS, Bundle()) + setFragmentResult(PermissionsBottomSheet.REQUEST_DISMISS_PERMISSIONS_SHEET, Bundle()) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/NotificationsPermissionFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/NotificationsPermissionFragment.kt index b92018e72a56..de783617e6de 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/NotificationsPermissionFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/NotificationsPermissionFragment.kt @@ -45,7 +45,7 @@ class NotificationsPermissionFragment : PermissionsFragment(R.layout.fragment_no // onResume is called after returning from both the OS settings and the OS permission request dialog if (canPostNotifications(requireContext())) { // Post a fragment result to indicate that the bottom sheet can be dismissed - setFragmentResult(PermissionsBottomSheet.RESULT_DISMISS, Bundle()) + setFragmentResult(PermissionsBottomSheet.REQUEST_DISMISS_PERMISSIONS_SHEET, Bundle()) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsActivity.kt index af0be0f60549..d03a787b4b44 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsActivity.kt @@ -18,7 +18,7 @@ import com.ichi2.anki.common.utils.android.showThemedToast import com.ichi2.anki.common.utils.ext.getParcelableExtraCompat import com.ichi2.anki.databinding.ActivityPermissionsBinding import com.ichi2.anki.ui.windows.permissions.PermissionsFragment.Companion.HAS_ALL_PERMISSIONS_KEY -import com.ichi2.anki.ui.windows.permissions.PermissionsFragment.Companion.PERMISSIONS_FRAGMENT_RESULT_KEY +import com.ichi2.anki.ui.windows.permissions.PermissionsFragment.Companion.REQUEST_PERMISSIONS import com.ichi2.anki.utils.ext.setFragmentResultListener import dev.androidbroadcast.vbpd.viewBinding import timber.log.Timber @@ -59,11 +59,11 @@ class PermissionsActivity : AnkiActivity(R.layout.activity_permissions) { return } val permissionsFragment = permissionSet.permissionsFragment.getDeclaredConstructor().newInstance() - setFragmentResultListener(PERMISSIONS_FRAGMENT_RESULT_KEY) { _, bundle -> + setFragmentResultListener(REQUEST_PERMISSIONS) { _, bundle -> val hasAllPermissions = bundle.getBoolean(HAS_ALL_PERMISSIONS_KEY) setContinueButtonEnabled(hasAllPermissions) } - setFragmentResultListener(RESULT_COMPLETE) { _, _ -> decideStorageAndFinish() } + setFragmentResultListener(REQUEST_PERMISSIONS_GRANTED) { _, _ -> decideStorageAndFinish() } supportFragmentManager.commit { replace(R.id.fragment_container, permissionsFragment) @@ -80,7 +80,7 @@ class PermissionsActivity : AnkiActivity(R.layout.activity_permissions) { * Records the storage decision and closes the screen: the granted permissions * determine the default collection path. * - * Called on 'Continue', or when the fragment reports [RESULT_COMPLETE]. + * Called on 'Continue', or when the fragment reports [REQUEST_PERMISSIONS_GRANTED]. * Preserves an existing collection path. * * @see InitialActivity.decideStorageIfUndecided @@ -99,7 +99,7 @@ class PermissionsActivity : AnkiActivity(R.layout.activity_permissions) { * Signals the activity to persist the * [storage decision][com.ichi2.anki.common.storage.StorageDecision], and then close. */ - const val RESULT_COMPLETE = "result_complete" + const val REQUEST_PERMISSIONS_GRANTED = "result_complete" fun getIntent( context: Context, diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsBottomSheet.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsBottomSheet.kt index cdeb5a437f67..0c281fd7e468 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsBottomSheet.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsBottomSheet.kt @@ -47,7 +47,7 @@ class PermissionsBottomSheet : BottomSheetDialogFragment() { } binding.closeButton.setOnClickListener { dismiss() } - childFragmentManager.setFragmentResultListener(RESULT_DISMISS, this) { _, _ -> + childFragmentManager.setFragmentResultListener(REQUEST_DISMISS_PERMISSIONS_SHEET, this) { _, _ -> dismiss() } @@ -75,7 +75,7 @@ class PermissionsBottomSheet : BottomSheetDialogFragment() { * Fragment result request key for dismissing this BottomSheet. * Public so that child fragments can set it. */ - const val RESULT_DISMISS = "result_dismiss" + const val REQUEST_DISMISS_PERMISSIONS_SHEET = "result_dismiss" /** * Starts this BottomSheet with the provided [OptionalPermissionSet]. diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsFragment.kt index bb3cade2921f..89cbac31b83c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsFragment.kt @@ -61,7 +61,7 @@ abstract class PermissionsFragment( super.onResume() permissionsItems.forEach { it.updateSwitchCheckedStatus() } setFragmentResult( - PERMISSIONS_FRAGMENT_RESULT_KEY, + REQUEST_PERMISSIONS, Bundle().apply { putBoolean(HAS_ALL_PERMISSIONS_KEY, hasAllPermissions()) }, ) } @@ -135,7 +135,7 @@ abstract class PermissionsFragment( } companion object { - const val PERMISSIONS_FRAGMENT_RESULT_KEY = "PERMISSION_FRAGMENT_RESULT" + const val REQUEST_PERMISSIONS = "PERMISSION_FRAGMENT_RESULT" const val HAS_ALL_PERMISSIONS_KEY = "HAS_ALL_PERMISSIONS" } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsStartingAt30Fragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsStartingAt30Fragment.kt index 53b1c927c064..8b8700b373f3 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsStartingAt30Fragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsStartingAt30Fragment.kt @@ -31,7 +31,7 @@ class PermissionsStartingAt30Fragment : PermissionsFragment(R.layout.fragment_pe ) { if (hasAllPermissions()) { // the activity records the storage decision and finishes - setFragmentResult(PermissionsActivity.RESULT_COMPLETE, Bundle()) + setFragmentResult(PermissionsActivity.REQUEST_PERMISSIONS_GRANTED, Bundle()) } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/IntroductionActivityTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/IntroductionActivityTest.kt index 80e798d484de..d2a9a8310d81 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/IntroductionActivityTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/IntroductionActivityTest.kt @@ -72,7 +72,7 @@ class IntroductionActivityTest : RobolectricTest() { val activity = startRegularActivity(Intent()) activity.supportFragmentManager.setFragmentResult( - SetupCollectionFragment.FRAGMENT_KEY, + SetupCollectionFragment.REQUEST_COLLECTION_SETUP, Bundle().apply { putParcelable(SetupCollectionFragment.RESULT_KEY, DeckPickerWithNewCollection) }, ) @@ -95,7 +95,7 @@ class IntroductionActivityTest : RobolectricTest() { private fun IntroductionActivity.clickSync() { supportFragmentManager.setFragmentResult( - SetupCollectionFragment.FRAGMENT_KEY, + SetupCollectionFragment.REQUEST_COLLECTION_SETUP, Bundle().apply { putParcelable(SetupCollectionFragment.RESULT_KEY, SyncFromExistingAccount) }, ) } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/CustomStudyDialogTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/CustomStudyDialogTest.kt index e5d245ec0356..b3c98d782f30 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/CustomStudyDialogTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/CustomStudyDialogTest.kt @@ -27,8 +27,8 @@ import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog.ContextMenuOption import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog.CustomStudyDefaults.Companion.toDomainModel import com.ichi2.anki.dialogs.customstudy.CustomStudyViewModel -import com.ichi2.anki.dialogs.tags.TagsDialogListener.Companion.ON_SELECTED_TAGS_KEY import com.ichi2.anki.dialogs.tags.TagsDialogListener.Companion.ON_SELECTED_TAGS__SELECTED_TAGS +import com.ichi2.anki.dialogs.tags.TagsDialogListener.Companion.REQUEST_SELECT_TAGS import com.ichi2.anki.dialogs.utils.performPositiveClick import com.ichi2.anki.libanki.CardType import com.ichi2.anki.libanki.Collection @@ -224,7 +224,7 @@ class CustomStudyDialogTest : RobolectricTest() { val selectedTags = ArrayList(1).apply { add("testTag") } // simulate tag selection studyDialog.parentFragmentManager.setFragmentResult( - ON_SELECTED_TAGS_KEY, + REQUEST_SELECT_TAGS, Bundle().apply { putStringArrayList(ON_SELECTED_TAGS__SELECTED_TAGS, selectedTags) }, ) val customStudyDeck = col.decks.customStudySession From 3241819b391595d4bf7359eed85b3c6b9c5f54f8 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:56:14 +0100 Subject: [PATCH 013/163] lint: enforce REQUEST_ for Fragment Result API request keys Fixes 22409 Assisted-by: GPT-6-Astra default --- .../java/com/ichi2/anki/lint/IssueRegistry.kt | 2 + .../rules/FragmentRequestKeyNamingDetector.kt | 70 ++++++ .../ichi2/anki/lint/utils/ext/UExpression.kt | 28 +++ .../FragmentRequestKeyNamingDetectorTest.kt | 199 ++++++++++++++++++ 4 files changed, 299 insertions(+) create mode 100644 lint-rules/src/main/java/com/ichi2/anki/lint/rules/FragmentRequestKeyNamingDetector.kt create mode 100644 lint-rules/src/main/java/com/ichi2/anki/lint/utils/ext/UExpression.kt create mode 100644 lint-rules/src/test/java/com/ichi2/anki/lint/rules/FragmentRequestKeyNamingDetectorTest.kt diff --git a/lint-rules/src/main/java/com/ichi2/anki/lint/IssueRegistry.kt b/lint-rules/src/main/java/com/ichi2/anki/lint/IssueRegistry.kt index fcaed73b2a05..d9cdd0f682d5 100644 --- a/lint-rules/src/main/java/com/ichi2/anki/lint/IssueRegistry.kt +++ b/lint-rules/src/main/java/com/ichi2/anki/lint/IssueRegistry.kt @@ -16,6 +16,7 @@ import com.ichi2.anki.lint.rules.DirectSystemCurrentTimeMillisUsage import com.ichi2.anki.lint.rules.DirectSystemTimeInstantiation import com.ichi2.anki.lint.rules.DirectToastMakeTextUsage import com.ichi2.anki.lint.rules.DuplicateCrowdInStrings +import com.ichi2.anki.lint.rules.FragmentRequestKeyNamingDetector import com.ichi2.anki.lint.rules.HardcodedPreferenceKey import com.ichi2.anki.lint.rules.InvalidStringFormatDetector import com.ichi2.anki.lint.rules.JUnitNullAssertionDetector @@ -45,6 +46,7 @@ class IssueRegistry : IssueRegistry() { DirectSystemTimeInstantiation.ISSUE, DirectToastMakeTextUsage.ISSUE, DuplicateCrowdInStrings.ISSUE, + FragmentRequestKeyNamingDetector.ISSUE, HardcodedPreferenceKey.ISSUE, JUnitNullAssertionDetector.ISSUE, LicenseHeaderExists.ISSUE, diff --git a/lint-rules/src/main/java/com/ichi2/anki/lint/rules/FragmentRequestKeyNamingDetector.kt b/lint-rules/src/main/java/com/ichi2/anki/lint/rules/FragmentRequestKeyNamingDetector.kt new file mode 100644 index 000000000000..a17722dc4971 --- /dev/null +++ b/lint-rules/src/main/java/com/ichi2/anki/lint/rules/FragmentRequestKeyNamingDetector.kt @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.lint.rules + +import com.android.tools.lint.detector.api.ConstantEvaluator +import com.android.tools.lint.detector.api.Detector +import com.android.tools.lint.detector.api.Implementation +import com.android.tools.lint.detector.api.Issue +import com.android.tools.lint.detector.api.JavaContext +import com.android.tools.lint.detector.api.Scope +import com.android.tools.lint.detector.api.SourceCodeScanner +import com.ichi2.anki.lint.utils.Constants +import com.ichi2.anki.lint.utils.ext.resolveToField +import com.intellij.psi.PsiMethod +import com.intellij.psi.PsiModifier +import org.jetbrains.uast.UCallExpression +import org.jetbrains.uast.skipParenthesizedExprDown + +/** Checks constant request keys, leaving runtime-generated keys and forwarded parameters alone. */ +class FragmentRequestKeyNamingDetector : + Detector(), + SourceCodeScanner { + override fun getApplicableMethodNames() = + listOf("setFragmentResult", "setFragmentResultListener", "clearFragmentResult", "clearFragmentResultListener") + + override fun visitMethodCall( + context: JavaContext, + node: UCallExpression, + method: PsiMethod, + ) { + val owner = method.containingClass?.qualifiedName + if (owner != "androidx.fragment.app.FragmentManager" && + owner != "androidx.fragment.app.FragmentKt" && + owner != "com.ichi2.anki.utils.ext.FragmentResultKt" + ) { + return + } + // Extension methods have a receiver parameter; named arguments may also be reordered. + val index = method.parameterList.parameters.indexOfFirst { it.name == "requestKey" } + if (index < 0) return + val argument = node.getArgumentForParameter(index)?.skipParenthesizedExprDown() ?: return + val field = argument.resolveToField() ?: return + if (!field.hasModifierProperty(PsiModifier.STATIC) || !field.hasModifierProperty(PsiModifier.FINAL)) return + if (field.name.startsWith("REQUEST_")) return + if (ConstantEvaluator.evaluate(context, argument) !is String) return + context.report( + ISSUE, + argument, + context.getLocation(argument), + "Fragment Result API request key constants must start with REQUEST_: ${field.name}", + ) + } + + companion object { + val ISSUE = + Issue.create( + id = "FragmentRequestKeyNaming", + briefDescription = "Fragment request key constants must start with REQUEST_", + explanation = + """ + Fragment Result API request key constants should be named: REQUEST_. + The name should be an action to represent the request. e.g. REQUEST_SELECT_TAGS. + """.trimIndent(), + category = Constants.ANKI_CODE_STYLE_CATEGORY, + priority = Constants.ANKI_CODE_STYLE_PRIORITY, + severity = Constants.ANKI_CODE_STYLE_SEVERITY, + implementation = Implementation(FragmentRequestKeyNamingDetector::class.java, Scope.JAVA_FILE_SCOPE), + ) + } +} diff --git a/lint-rules/src/main/java/com/ichi2/anki/lint/utils/ext/UExpression.kt b/lint-rules/src/main/java/com/ichi2/anki/lint/utils/ext/UExpression.kt new file mode 100644 index 000000000000..a0e4c9240f83 --- /dev/null +++ b/lint-rules/src/main/java/com/ichi2/anki/lint/utils/ext/UExpression.kt @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.lint.utils.ext + +import com.intellij.psi.PsiField +import org.jetbrains.kotlin.asJava.unwrapped +import org.jetbrains.kotlin.psi.KtProperty +import org.jetbrains.uast.UExpression +import org.jetbrains.uast.UField +import org.jetbrains.uast.UQualifiedReferenceExpression +import org.jetbrains.uast.UReferenceExpression +import org.jetbrains.uast.toUElementOfType + +/** + * Resolves a field reference, including a Kotlin property accessed through its getter. + * + * [UReferenceExpression.resolve] can return a getter method for an ordinary Kotlin `val`. + * + * This unwraps it, so it works the same as a `const val`. + */ +internal fun UExpression.resolveToField(): PsiField? { + val reference = (this as? UQualifiedReferenceExpression)?.selector ?: this + val resolved = (reference as? UReferenceExpression)?.resolve() ?: return null + if (resolved is PsiField) return resolved + + val property = resolved.unwrapped as? KtProperty ?: return null + return property.toUElementOfType()?.javaPsi as? PsiField +} diff --git a/lint-rules/src/test/java/com/ichi2/anki/lint/rules/FragmentRequestKeyNamingDetectorTest.kt b/lint-rules/src/test/java/com/ichi2/anki/lint/rules/FragmentRequestKeyNamingDetectorTest.kt new file mode 100644 index 000000000000..23db1d60985c --- /dev/null +++ b/lint-rules/src/test/java/com/ichi2/anki/lint/rules/FragmentRequestKeyNamingDetectorTest.kt @@ -0,0 +1,199 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.lint.rules + +import com.android.tools.lint.checks.infrastructure.LintDetectorTest.java +import com.android.tools.lint.checks.infrastructure.LintDetectorTest.kotlin +import com.android.tools.lint.checks.infrastructure.TestLintTask.lint +import org.junit.Test + +class FragmentRequestKeyNamingDetectorTest { + @Test + fun `reports constants in manager and extension calls`() { + lint() + .allowMissingSdk() + .files( + manager, + fragment, + activity, + fragmentExtensions, + activityExtensions, + kotlin( + """ + package test + import androidx.fragment.app.* + import com.ichi2.anki.utils.ext.setFragmentResultListener + + const val RESULT_KEY = "result" + class Screen : Fragment() { + companion object { + const val FRAGMENT_KEY = "fragment" + } + fun send(manager: FragmentManager, activity: FragmentActivity) { + manager.setFragmentResult(RESULT_KEY, "payload") + manager.setFragmentResult(Screen.FRAGMENT_KEY, "payload") + manager.setFragmentResultListener(RESULT_KEY) {} + manager.clearFragmentResult(RESULT_KEY) + manager.clearFragmentResultListener(RESULT_KEY) + setFragmentResult(result = "payload", requestKey = RESULT_KEY) + setFragmentResultListener(RESULT_KEY) {} + activity.setFragmentResultListener(RESULT_KEY) {} + } + } + """, + ).indented(), + ).issues(FragmentRequestKeyNamingDetector.ISSUE) + .run() + .expectErrorCount(8) + .expectContains("Fragment Result API request key constants must start with REQUEST_: RESULT_KEY") + } + + @Test + fun `reports request key properties accessed through Kotlin getters`() { + lint() + .allowMissingSdk() + .files( + manager, + fragment, + fragmentExtensions, + kotlin( + """ + package test + import androidx.fragment.app.* + + val RESULT_KEY = "result" + object Keys { + val FRAGMENT_KEY = "fragment" + } + class Screen : Fragment() { + companion object { + val REQUE_ = "dfjdsjkfdsjfds" + } + fun listen(manager: FragmentManager) { + setFragmentResultListener(REQUE_) {} + manager.setFragmentResult(Screen.REQUE_, "payload") + manager.setFragmentResult(RESULT_KEY, "payload") + manager.setFragmentResult(Keys.FRAGMENT_KEY, "payload") + } + } + """, + ).indented(), + ).issues(FragmentRequestKeyNamingDetector.ISSUE) + .run() + .expectErrorCount(4) + .expectContains("Fragment Result API request key constants must start with REQUEST_: REQUE_") + .expectContains("Fragment Result API request key constants must start with REQUEST_: RESULT_KEY") + .expectContains("Fragment Result API request key constants must start with REQUEST_: FRAGMENT_KEY") + } + + @Test + fun `allows compliant and nonconstant properties accessed through Kotlin getters`() { + lint() + .allowMissingSdk() + .files( + manager, + fragment, + fragmentExtensions, + kotlin( + """ + package test + import androidx.fragment.app.* + + val REQUEST_TOP_LEVEL = "topLevel" + object Keys { + val REQUEST_OBJECT = "object" + } + class Screen : Fragment() { + companion object { + val REQUEST_LISTEN = "listen" + val dynamicKey = "request_" + System.nanoTime() + var mutableKey = "mutable" + val computedKey get() = "request_" + System.nanoTime() + val delegatedKey by lazy { "request_" + System.nanoTime() } + } + val instanceKey = "instance" + fun listen(manager: FragmentManager) { + setFragmentResultListener(REQUEST_LISTEN) {} + manager.setFragmentResult(REQUEST_TOP_LEVEL, "payload") + manager.setFragmentResult(Keys.REQUEST_OBJECT, "payload") + manager.setFragmentResult(dynamicKey, "payload") + manager.setFragmentResult(mutableKey, "payload") + manager.setFragmentResult(computedKey, "payload") + manager.setFragmentResult(delegatedKey, "payload") + manager.setFragmentResult(instanceKey, "payload") + } + } + """, + ).indented(), + ).issues(FragmentRequestKeyNamingDetector.ISSUE) + .run() + .expectClean() + } + + @Test + fun `allows request constants payload keys and dynamic keys`() { + lint() + .allowMissingSdk() + .files( + manager, + kotlin( + """ + package test + import androidx.fragment.app.FragmentManager + + object Keys { + const val REQUEST_TEST = "test" + const val RESULT_VALUE = "value" + } + fun send(manager: FragmentManager, requestKey: String) { + manager.setFragmentResult(Keys.REQUEST_TEST, Keys.RESULT_VALUE) + manager.setFragmentResult(requestKey, "payload") + val dynamicKey = "request_" + System.nanoTime() + manager.setFragmentResult(dynamicKey, "payload") + } + class Unrelated { + fun setFragmentResult(requestKey: String, result: String) {} + fun send() = setFragmentResult(Keys.RESULT_VALUE, "payload") + } + """, + ).indented(), + ).issues(FragmentRequestKeyNamingDetector.ISSUE) + .run() + .expectClean() + } + + companion object { + private val manager = + java( + """ + package androidx.fragment.app; + public class FragmentManager { + public void setFragmentResult(String requestKey, String result) {} + public void setFragmentResultListener(String requestKey, Runnable listener) {} + public void clearFragmentResult(String requestKey) {} + public void clearFragmentResultListener(String requestKey) {} + } + """, + ).indented() + private val fragment = java("package androidx.fragment.app; public class Fragment {}") + private val activity = java("package androidx.fragment.app; public class FragmentActivity {}") + private val fragmentExtensions = + kotlin( + """ + @file:JvmName("FragmentKt") + package androidx.fragment.app + fun Fragment.setFragmentResult(requestKey: String, result: String) {} + fun Fragment.setFragmentResultListener(requestKey: String, listener: () -> Unit) {} + """, + ).indented() + private val activityExtensions = + kotlin( + """ + @file:JvmName("FragmentResultKt") + package com.ichi2.anki.utils.ext + import androidx.fragment.app.FragmentActivity + fun FragmentActivity.setFragmentResultListener(requestKey: String, listener: () -> Unit) {} + """, + ).indented() + } +} From 43387904ff2e00a3e70ed00568b753e01304a371 Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Wed, 14 Oct 2026 05:48:05 +0530 Subject: [PATCH 014/163] build(ci): store screenshot baselines for dependency-updates Dependency PRs compared their screenshots against an old baseline, because one was only stored when the "Dependency updates" PR ran. Store one on every push to dependency-updates, so each bump only shows its own changes. Compare now only takes a baseline from a push, as that PR's own run screenshots its merge into main. Fixes 21071 Assisted-by: Claude Opus 5.5 --- .github/workflows/screenshot_compare.yml | 1 + .github/workflows/screenshot_store.yml | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/screenshot_compare.yml b/.github/workflows/screenshot_compare.yml index 0d9599f012d4..963f72813ed3 100644 --- a/.github/workflows/screenshot_compare.yml +++ b/.github/workflows/screenshot_compare.yml @@ -72,6 +72,7 @@ jobs: name: screenshot workflow: screenshot_store.yml branch: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.ref || github.event.repository.default_branch }} + event: push - name: Compare screenshots id: compare-screenshot-test diff --git a/.github/workflows/screenshot_store.yml b/.github/workflows/screenshot_store.yml index dc53a73d5829..fab55ea32b01 100644 --- a/.github/workflows/screenshot_store.yml +++ b/.github/workflows/screenshot_store.yml @@ -26,6 +26,7 @@ on: push: branches: - main + - dependency-updates # On PRs, only run this when structural changes occur, to be sure 'main' can still store baselines pull_request: paths: @@ -41,7 +42,7 @@ permissions: {} jobs: store-screenshot-test: # On PRs, this verifies the workflow runs on `main`, it's not a store - name: ${{ github.ref == 'refs/heads/main' && 'store' || 'test' }} + name: ${{ github.event_name == 'push' && 'store' || 'test' }} runs-on: ubuntu-latest timeout-minutes: 20 From e1adc094deb9990024b5c4168b41137a269e2fd5 Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Thu, 15 Oct 2026 17:43:04 +0530 Subject: [PATCH 015/163] build(release): add the release to the publish run summary Publish runs didn't say which release they made, so finding the latest alpha, beta or production release meant opening the logs. Add the release name, a link to it and whether it was public to the summary. Fixes 22519 Assisted-by: Claude Opus 5.5 --- tools/release.sh | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tools/release.sh b/tools/release.sh index ba68288a79a6..e2b0e14eb8df 100755 --- a/tools/release.sh +++ b/tools/release.sh @@ -199,8 +199,10 @@ export GITHUB_REPO="Anki-Android" if [ "$PUBLIC" = "public" ]; then RELEASE_TYPE="--latest" + RELEASE_KIND="Public release" else RELEASE_TYPE="--prerelease" + RELEASE_KIND="Pre-release" fi # Get the directory of the current script @@ -276,3 +278,9 @@ then exit 1 fi echo "Release published successfully" + +cat <> "${GITHUB_STEP_SUMMARY:-/dev/null}" +### [AnkiDroid $VERSION](https://github.com/ankidroid/Anki-Android/releases/tag/v$VERSION) + +$RELEASE_KIND +EOF From e59c39e95ea7a5844313db581623fb06268201a0 Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:34:49 +0530 Subject: [PATCH 016/163] test(profiles): fix flaky SwitchProfilesFragmentTest The test mocked the whole app to hide the profile manager. ACRA calls the app from a background thread at startup, so a call landing while MockK swapped the mock failed with "can't find stub". Set the profile manager directly instead. Fixes 22527 Assisted-by: Claude Opus 5.5 --- .../src/main/java/com/ichi2/anki/AnkiDroidApp.kt | 2 +- .../profiles/SwitchProfilesFragmentTest.kt | 14 +++++++------- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt index 1462c01fcb63..a244962e9e31 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt @@ -130,8 +130,8 @@ open class AnkiDroidApp : } } + @set:VisibleForTesting var profileManager: ProfileManager? = null - private set private val profileContext: Context? get() = profileManager?.activeProfileContext diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/preferences/profiles/SwitchProfilesFragmentTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/preferences/profiles/SwitchProfilesFragmentTest.kt index a091cd36d9eb..2b1fc478194c 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/preferences/profiles/SwitchProfilesFragmentTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/preferences/profiles/SwitchProfilesFragmentTest.kt @@ -13,8 +13,6 @@ import com.ichi2.anki.preferences.HeaderFragment import com.ichi2.anki.preferences.PreferencesActivity import com.ichi2.anki.preferences.PreferencesFragment import com.ichi2.anki.preferences.requirePreference -import io.mockk.every -import io.mockk.mockkObject import org.junit.Test import org.junit.runner.RunWith import org.robolectric.annotation.Config @@ -44,18 +42,20 @@ class SwitchProfilesFragmentTest : RobolectricTest() { private fun openSwitchProfilesWhenProfilesFailedToLoad(): PreferencesActivity { val app = AnkiDroidApp.instance - lateinit var activity: PreferencesActivity - mockkObject(app) { - every { app.profileManager } returns null - activity = startRegularActivity(PreferencesActivity.getIntent(targetContext)) + val profileManager = app.profileManager + app.profileManager = null + try { + val activity = startRegularActivity(PreferencesActivity.getIntent(targetContext)) activity.settings.fragments .filterIsInstance() .single() .requirePreference(R.string.pref_switch_profile_screen_key) .performClick() advanceRobolectricLooper() + return activity + } finally { + app.profileManager = profileManager } - return activity } private val PreferencesActivity.settings From 57eb96f6a3b89e13650de79d15d452ecd5197fa0 Mon Sep 17 00:00:00 2001 From: Ratnesh5 Date: Thu, 15 Oct 2026 19:02:47 +0530 Subject: [PATCH 017/163] fix(mediacheck): handle onRenderProcessGone with SafeWebViewLayout Part of #22091 --- .../anki/mediacheck/MediaCheckFragment.kt | 24 ++++++++-- .../main/res/layout/fragment_media_check.xml | 2 +- .../anki/mediacheck/MediaCheckFragmentTest.kt | 48 +++++++++++++++++++ 3 files changed, 70 insertions(+), 4 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckFragment.kt index c1f8e8a9fab6..d9d18f6fbe4a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/mediacheck/MediaCheckFragment.kt @@ -10,7 +10,8 @@ import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import android.view.View -import android.webkit.WebViewClient +import android.webkit.WebView +import androidx.annotation.CallSuper import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatActivity @@ -32,6 +33,8 @@ import com.ichi2.anki.progress.observeProgress import com.ichi2.anki.ui.internationalization.sentenceCase import com.ichi2.anki.utils.observeMessages import com.ichi2.anki.utils.showAndAwaitDismissal +import com.ichi2.anki.workarounds.OnWebViewRecreatedListener +import com.ichi2.anki.workarounds.SafeWebViewClient import com.ichi2.utils.cancelable import com.ichi2.utils.message import com.ichi2.utils.negativeButton @@ -47,11 +50,15 @@ import kotlinx.coroutines.launch * MediaCheckFragment for displaying a list of media files that are either unused or missing. * It allows users to tag missing media files or delete unused ones. **/ -class MediaCheckFragment : Fragment(R.layout.fragment_media_check) { +class MediaCheckFragment : + Fragment(R.layout.fragment_media_check), + OnWebViewRecreatedListener { private val viewModel: MediaCheckViewModel by viewModels() private val binding by viewBinding(FragmentMediaCheckBinding::bind) + private var lastReport: String? = null + override fun onViewCreated( view: View, savedInstanceState: Bundle?, @@ -123,6 +130,7 @@ class MediaCheckFragment : Fragment(R.layout.fragment_media_check) { } private fun updateWebView(report: String) { + lastReport = report val backgroundColor = getColorFromAttr(requireContext(), android.R.attr.colorBackground) val textColor = getColorFromAttr(requireContext(), android.R.attr.textColorPrimary) @@ -142,10 +150,20 @@ class MediaCheckFragment : Fragment(R.layout.fragment_media_check) { """.trimIndent() - binding.webView.webViewClient = WebViewClient() + binding.webView.setWebViewClient(object : SafeWebViewClient() {}) binding.webView.loadDataWithBaseURL(null, html, "text/html", "UTF-8", null) } + override fun onWebViewRecreated(webView: WebView) { + lastReport?.let { updateWebView(it) } + } + + @CallSuper + override fun onDestroyView() { + binding.webView.safeDestroy() + super.onDestroyView() + } + private fun setupButtonListeners() { binding.tagMissingMediaButton.apply { // mediaCheckAddTag => "Tag Missing" diff --git a/AnkiDroid/src/main/res/layout/fragment_media_check.xml b/AnkiDroid/src/main/res/layout/fragment_media_check.xml index 81612f3c0697..20d3a28bf029 100644 --- a/AnkiDroid/src/main/res/layout/fragment_media_check.xml +++ b/AnkiDroid/src/main/res/layout/fragment_media_check.xml @@ -18,7 +18,7 @@ app:navigationContentDescription="@string/abc_action_bar_up_description" app:navigationIcon="?attr/homeAsUpIndicator" /> - package com.ichi2.anki.mediacheck +import android.webkit.WebView import android.widget.Button +import androidx.fragment.app.commitNow import androidx.test.ext.junit.runners.AndroidJUnit4 import anki.backend.BackendError import anki.backend.backendError @@ -12,6 +14,7 @@ import com.ichi2.anki.CommonPlurals import com.ichi2.anki.R import com.ichi2.anki.RobolectricTest import com.ichi2.anki.SingleFragmentActivity +import com.ichi2.anki.workarounds.SafeWebViewLayout import com.ichi2.testutils.rules.BackendOverrideRule import com.ichi2.utils.positiveButton import kotlinx.coroutines.test.StandardTestDispatcher @@ -22,10 +25,13 @@ import net.ankiweb.rsdroid.exceptions.BackendIoException import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf import org.robolectric.android.controller.ActivityController import org.robolectric.shadows.ShadowDialog import java.io.File import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull import kotlin.test.assertNotSame import kotlin.test.assertNull import kotlin.test.assertSame @@ -61,6 +67,48 @@ class MediaCheckFragmentTest : RobolectricTest() { assertEquals(failure.localizedMessage, getAlertDialogText(checkDismissed = true)) } + @Test + fun `onRenderProcessGone recovers webview and reloads content`() = + runTest { + val mediaCheck = startMediaCheck() + advanceUntilIdle() + advanceRobolectricLooper() + + val layout = mediaCheck.get().findViewById(R.id.web_view) + val initialWebView = assertNotNull(layout.getChildAt(0) as? WebView) + + layout.onRenderProcessGone(initialWebView) + advanceRobolectricLooper() + + assertTrue(shadowOf(initialWebView).wasDestroyCalled(), "Dead WebView should be destroyed") + assertNull(initialWebView.parent, "Dead WebView should be removed from parent") + + val recoveredWebView = assertNotNull(layout.getChildAt(0) as? WebView) + assertNotSame(initialWebView, recoveredWebView, "A new WebView instance should be created") + assertFalse(shadowOf(recoveredWebView).wasDestroyCalled(), "Recovered WebView should not be destroyed") + } + + @Test + fun `onRenderProcessGone after fragment view is destroyed cleans up dead webview without recreating`() = + runTest { + val mediaCheck = startMediaCheck() + advanceUntilIdle() + advanceRobolectricLooper() + + val layout = mediaCheck.get().findViewById(R.id.web_view) + val initialWebView = assertNotNull(layout.getChildAt(0) as? WebView) + + val fragment = mediaCheck.get().supportFragmentManager.findFragmentById(R.id.fragment_container) as MediaCheckFragment + mediaCheck.get().supportFragmentManager.commitNow { detach(fragment) } + assertNull(fragment.view, "Fragment view should be null after detach") + + layout.onRenderProcessGone(initialWebView) + + assertTrue(shadowOf(initialWebView).wasDestroyCalled(), "Dead WebView should be destroyed") + assertNull(initialWebView.parent, "Dead WebView should be removed from parent") + assertEquals(0, layout.childCount, "No new WebView should be created when fragment view is gone") + } + @Test fun `deleting unused media shows the result`() = runTest { From 44485066ee6188234ab2b8b0d1cc7d174e8b5a61 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:19:45 +0100 Subject: [PATCH 018/163] fix(card-viewer): allow HTTP/HTTPS card links HTTP(S) links were caught in the GHSL-2026-085 check for external app opening. A user should not need to relax genuine security measures to open links. Fixes 22490 66efa711cd428e36c17cfe3b60e55c1330fc03b2 Assisted-by: GPT-6-Astra default Assisted-by: GPT-6-Astra xhigh --- .../com/ichi2/anki/AbstractFlashcardViewer.kt | 4 +- .../anki/previewer/CardViewerFragment.kt | 15 +- .../com/ichi2/anki/security/AppPermissions.kt | 16 ++ .../anki/previewer/CardViewerFragmentTest.kt | 45 ++++- .../ichi2/anki/security/AppPermissionsTest.kt | 44 +++++ .../anki/security/CardLinkNavigationTest.kt | 171 ++++++++++++++++++ 6 files changed, 289 insertions(+), 6 deletions(-) create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/security/CardLinkNavigationTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt index d3f2effea34e..6dd9819a49d4 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt @@ -2627,7 +2627,8 @@ abstract class AbstractFlashcardViewer : } if (intent == null) { val uri = url.toUri() - if (!appPermission.checkCanLaunchExternalApps()) { + intent = appPermission.createUrlIntent(uri) + if (intent == null) { Timber.w("URL blocked: canLaunchExternalApps") return true } @@ -2636,7 +2637,6 @@ abstract class AbstractFlashcardViewer : return true } Timber.d("Opening external link \"%s\" with an Intent", url) - intent = Intent(Intent.ACTION_VIEW, uri) } else { Timber.d("Opening resolved external link \"%s\" with an Intent: %s", url, intent) } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerFragment.kt index b6b381339832..8ebafd5bfde1 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerFragment.kt @@ -30,6 +30,7 @@ import androidx.lifecycle.lifecycleScope import com.ichi2.anki.CommonString import com.ichi2.anki.R import com.ichi2.anki.ViewerResourceHandler +import com.ichi2.anki.common.android.AdaptionUtil import com.ichi2.anki.compat.CompatHelper.Companion.resolveActivityCompat import com.ichi2.anki.dialogs.TtsVoicesDialogFragment import com.ichi2.anki.localizedErrorMessage @@ -312,12 +313,22 @@ abstract class CardViewerFragment( Timber.w("URL blocked") return true } - if (!appPermission.checkCanLaunchExternalApps()) { + val intent = appPermission.createUrlIntent(url) + if (intent == null) { Timber.w("URL blocked: canLaunchExternalApps") return true } try { - openUrl(url) + if (intent.hasCategory(Intent.CATEGORY_BROWSABLE)) { + if (!AdaptionUtil.hasWebBrowser(requireContext())) { + showSnackbar(getString(CommonString.no_browser_msg, url.toString())) + return true + } + startActivity(intent) + } else { + // Preserve the existing browser check for custom schemes. + openUrl(url) + } } catch (_: Throwable) { Timber.w("Could not open url") return false diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/security/AppPermissions.kt b/AnkiDroid/src/main/java/com/ichi2/anki/security/AppPermissions.kt index 2d44b55f6866..aa865a99ac75 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/security/AppPermissions.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/security/AppPermissions.kt @@ -3,6 +3,8 @@ package com.ichi2.anki.security import android.content.Context +import android.content.Intent +import android.net.Uri import androidx.annotation.VisibleForTesting import com.ichi2.anki.CommonString import com.ichi2.anki.R @@ -34,6 +36,20 @@ class AppPermissions( private val context: Context, private val showSnackbar: (String) -> Unit = {}, ) { + /** + * Creates a new URL navigation intent, or `null` when permission is denied. + * + * HTTP(S) links do not require external app launches to be enabled and are returned with + * `CATEGORY_BROWSABLE`. + * + * Other URL schemes require [external app launches][checkCanLaunchExternalApps] to be enabled. + */ + fun createUrlIntent(url: Uri): Intent? = + when (url.scheme) { + "http", "https" -> Intent(Intent.ACTION_VIEW, url).addCategory(Intent.CATEGORY_BROWSABLE) + else -> if (checkCanLaunchExternalApps()) Intent(Intent.ACTION_VIEW, url) else null + } + /** * Returns true if cards may launch external apps; * shows a rate-limited snackbar on denial. diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/previewer/CardViewerFragmentTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/previewer/CardViewerFragmentTest.kt index 583a4a0e1a41..7db4941185d0 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/previewer/CardViewerFragmentTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/previewer/CardViewerFragmentTest.kt @@ -7,6 +7,7 @@ import android.view.ViewGroup import android.webkit.PermissionRequest import android.webkit.WebChromeClient import android.webkit.WebView +import androidx.core.net.toUri import androidx.lifecycle.Lifecycle import androidx.test.core.app.ActivityScenario import androidx.test.ext.junit.runners.AndroidJUnit4 @@ -15,7 +16,11 @@ import com.ichi2.anki.R import com.ichi2.anki.RobolectricTest import com.ichi2.anki.browser.IdsFile import com.ichi2.anki.settings.Prefs +import com.ichi2.testutils.assertNoActivityStarted +import com.ichi2.testutils.assertOpenedUrl import com.ichi2.testutils.createTransientDirectory +import com.ichi2.testutils.mockWebResourceRequest +import com.ichi2.testutils.registerWebBrowser import com.ichi2.utils.negativeButton import com.ichi2.utils.positiveButton import org.hamcrest.MatcherAssert.assertThat @@ -34,6 +39,37 @@ import org.robolectric.shadows.ShadowDialog @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) // WebChromeClient @RunWith(AndroidJUnit4::class) class CardViewerFragmentTest : RobolectricTest() { + @Test + fun `web links open with external app permission disabled`() { + editPreferences { putBoolean(targetContext.getString(R.string.pref_allow_card_external_launch_key), false) } + targetContext.registerWebBrowser() + withCardViewerWebView { webView -> + for (scheme in listOf("http", "https")) { + val url = "$scheme://github.com/ankidroid/Anki-Android/releases" + shadowOf(webView).webViewClient.shouldOverrideUrlLoading(webView, mockWebResourceRequest(url)) + targetContext.assertOpenedUrl(url.toUri()) + } + } + } + + @Test + fun `custom schemes and parsed intents still require external app permission`() { + editPreferences { putBoolean(targetContext.getString(R.string.pref_allow_card_external_launch_key), false) } + targetContext.registerWebBrowser() + withCardViewerWebView { webView -> + val urls = + listOf( + "market://details?id=com.example", + "intent://example.org/#Intent;scheme=https;end", + "android-app://com.example/https/example.org", + ) + for (url in urls) { + shadowOf(webView).webViewClient.shouldOverrideUrlLoading(webView, mockWebResourceRequest(url)) + targetContext.assertNoActivityStarted(url) + } + } + } + @Test fun `dismissing the opt-in dialog denies the audio capture request`() { grantRecordAudioPermission() @@ -115,7 +151,12 @@ class CardViewerFragmentTest : RobolectricTest() { } /** Runs [block] on the [WebChromeClient] attached to an open previewer's WebView */ - private fun withCardViewerChromeClient(block: (WebChromeClient) -> Unit) { + private fun withCardViewerChromeClient(block: (WebChromeClient) -> Unit) = + withCardViewerWebView { webView -> + block(requireNotNull(webView.webChromeClient) { "no WebChromeClient attached" }) + } + + private fun withCardViewerWebView(block: (WebView) -> Unit) { val note = addBasicAndReversedNote() val intent = PreviewerFragment.getIntent( @@ -133,7 +174,7 @@ class CardViewerFragmentTest : RobolectricTest() { .map { webViewLayout.getChildAt(it) } .filterIsInstance() .single() - block(requireNotNull(webView.webChromeClient) { "no WebChromeClient attached" }) + block(webView) } } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/security/AppPermissionsTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/security/AppPermissionsTest.kt index 9a37eeca09b9..d3c8fb5c9908 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/security/AppPermissionsTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/security/AppPermissionsTest.kt @@ -2,7 +2,9 @@ package com.ichi2.anki.security +import android.content.Intent import androidx.core.content.edit +import androidx.core.net.toUri import androidx.test.ext.junit.runners.AndroidJUnit4 import com.ichi2.anki.R import com.ichi2.anki.RobolectricTest @@ -18,6 +20,8 @@ import org.junit.jupiter.api.assertDoesNotThrow import org.junit.runner.RunWith import org.robolectric.annotation.Config import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertNull @RunWith(AndroidJUnit4::class) @Config(application = EmptyApplication::class) @@ -77,6 +81,46 @@ class AppPermissionsTest : RobolectricTest() { assertThat(snackbars.size, equalTo(2)) } + @Test + fun `web URLs are allowed without external app permission or a warning`() { + allowCardExternalLaunch = false + for (scheme in listOf("http", "https")) { + val url = "$scheme://example.org/path?query=value#fragment".toUri() + val intent = assertNotNull(permissions.createUrlIntent(url)) + assertThat(intent.action, equalTo(Intent.ACTION_VIEW)) + assertThat(intent.data, equalTo(url)) + assertThat(intent.categories, equalTo(setOf(Intent.CATEGORY_BROWSABLE))) + assertNull(intent.selector) + } + assertThat(snackbars.size, equalTo(0)) + } + + @Test + fun `non-web URLs are denied without external app permission`() { + allowCardExternalLaunch = false + val urls = + listOf( + "market://details?id=com.example", + "intent://example.org/#Intent;scheme=https;end", + "android-app://com.example/https/example.org", + ) + for (url in urls) { + assertNull(permissions.createUrlIntent(url.toUri()), url) + } + assertThat(snackbars.size, equalTo(2)) + } + + @Test + fun `non-web URLs are allowed with external app permission`() { + allowCardExternalLaunch = true + val url = "market://details?id=com.example".toUri() + val intent = assertNotNull(permissions.createUrlIntent(url)) + assertThat(intent.action, equalTo(Intent.ACTION_VIEW)) + assertThat(intent.data, equalTo(url)) + assertNull(intent.categories) + assertThat(snackbars.size, equalTo(0)) + } + @Test fun launchExternalApps_defaultsToDenied() { assertThat(permissions.checkCanLaunchExternalApps(), equalTo(false)) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/security/CardLinkNavigationTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/security/CardLinkNavigationTest.kt new file mode 100644 index 000000000000..970d98d8b537 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/security/CardLinkNavigationTest.kt @@ -0,0 +1,171 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.security + +import android.app.Application +import android.content.ComponentName +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageManager +import android.webkit.WebView +import androidx.core.net.toUri +import androidx.test.core.app.ActivityScenario +import com.ichi2.anki.AbstractFlashcardViewerTest.NonAbstractFlashcardViewer +import com.ichi2.anki.R +import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.browser.IdsFile +import com.ichi2.anki.loadInitialCard +import com.ichi2.anki.previewer.CardViewerActivity +import com.ichi2.anki.previewer.PreviewerFragment +import com.ichi2.anki.ui.windows.reviewer.ReviewerFragment +import com.ichi2.anki.workarounds.SafeWebViewLayout +import com.ichi2.testutils.assertNoActivityStarted +import com.ichi2.testutils.createTransientDirectory +import com.ichi2.testutils.mockWebResourceRequest +import com.ichi2.testutils.registerWebBrowser +import com.ichi2.testutils.webView +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner +import org.robolectric.Robolectric +import org.robolectric.Shadows.shadowOf +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@RunWith(ParameterizedRobolectricTestRunner::class) +class CardLinkNavigationTest( + private val viewerType: ViewerType, +) : RobolectricTest() { + override fun getCollectionStorageMode() = CollectionStorageMode.IN_MEMORY_WITH_MEDIA + + @Before + fun disableExternalApps() { + editPreferences { putBoolean(targetContext.getString(R.string.pref_allow_card_external_launch_key), false) } + } + + @Test + fun `web links resolve to browsers and browsable native apps without external app permission`() { + targetContext.registerWebBrowser() + val nativeHandler = registerNativeHandler(browsable = true) + registerNativeHandler(browsable = false) + withWebView { webView -> + val requests = listOf("http" to true, "https" to true, "http" to false, "https" to false) + for ((scheme, mainFrame) in requests) { + val url = "$scheme://native.example.org/action?value=1#fragment" + val request = mockWebResourceRequest(url, mainFrame = mainFrame) + assertTrue(shadowOf(webView).webViewClient.shouldOverrideUrlLoading(webView, request)) + val intent = assertNotNull(shadowOf(targetContext.applicationContext as Application).nextStartedActivity) + assertEquals(Intent.ACTION_VIEW, intent.action) + assertEquals(url.toUri(), intent.data) + assertNull(intent.selector) + val handlers = + targetContext.packageManager.queryIntentActivities( + intent, + PackageManager.MATCH_DEFAULT_ONLY, + ) + assertEquals( + setOf(ComponentName("com.ichi2.testutils.browser", "BrowserActivity"), nativeHandler), + handlers.map { ComponentName(it.activityInfo.packageName, it.activityInfo.name) }.toSet(), + ) + targetContext.assertNoActivityStarted() + } + } + } + + @Test + fun `parsed intents with web data still require permission`() { + targetContext.registerWebBrowser() + withWebView { webView -> + for (url in listOf( + "intent://example.org/#Intent;scheme=https;end", + "android-app://com.ichi2.testutils.browser/https/example.org", + )) { + val request = mockWebResourceRequest(url) + assertTrue(shadowOf(webView).webViewClient.shouldOverrideUrlLoading(webView, request)) + targetContext.assertNoActivityStarted(url) + } + } + } + + @Test + fun `opted-in intents still reject dangerous data and strip URI grants`() { + editPreferences { putBoolean(targetContext.getString(R.string.pref_allow_card_external_launch_key), true) } + targetContext.registerWebBrowser() + withWebView { webView -> + val client = shadowOf(webView).webViewClient + for (scheme in listOf("file", "content", "javascript", "data")) { + val request = mockWebResourceRequest("intent://example.org/#Intent;scheme=$scheme;end") + assertTrue(client.shouldOverrideUrlLoading(webView, request)) + targetContext.assertNoActivityStarted() + } + val request = mockWebResourceRequest("intent://example.org/#Intent;scheme=https;launchFlags=0xc3;end") + assertTrue(client.shouldOverrideUrlLoading(webView, request)) + val intent = assertNotNull(shadowOf(targetContext.applicationContext as Application).nextStartedActivity) + assertEquals("https://example.org/".toUri(), intent.data) + val grantFlags = + Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION or + Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION or Intent.FLAG_GRANT_PREFIX_URI_PERMISSION + assertEquals(0, intent.flags and grantFlags) + targetContext.assertNoActivityStarted() + } + } + + private fun registerNativeHandler(browsable: Boolean): ComponentName { + val component = ComponentName("com.example.native", "Handler$browsable") + shadowOf(targetContext.packageManager).apply { + addActivityIfNotPresent(component).exported = true + addIntentFilterForActivity( + component, + IntentFilter(Intent.ACTION_VIEW).apply { + addCategory(Intent.CATEGORY_DEFAULT) + if (browsable) addCategory(Intent.CATEGORY_BROWSABLE) + addDataScheme("http") + addDataScheme("https") + if (browsable) addDataAuthority("native.example.org", null) + }, + ) + } + return component + } + + private fun withWebView(block: (WebView) -> Unit) { + val note = addBasicAndReversedNote() + if (viewerType != ViewerType.LEGACY) { + val intent = + if (viewerType == ViewerType.PREVIEWER) { + PreviewerFragment.getIntent( + targetContext, + idsFile = IdsFile(createTransientDirectory(), note.cardIds(col), IdsFile.Purpose.PREVIEW), + currentIndex = 0, + ) + } else { + ReviewerFragment.getIntent(targetContext) + } + ActivityScenario.launch(intent).use { scenario -> + scenario.onActivity { activity -> + block(activity.findViewById(R.id.web_view_layout).webView) + } + } + } else { + val controller = Robolectric.buildActivity(NonAbstractFlashcardViewer::class.java).setup() + saveControllerForCleanup(controller) + val viewer = controller.get() + viewer.onCollectionLoaded(col) + viewer.loadInitialCard() + advanceRobolectricLooper() + advanceRobolectricLooper() + block(assertNotNull(viewer.webView)) + } + } + + companion object { + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters(name = "{0}") + fun viewers() = ViewerType.entries.map { arrayOf(it) } + } + + enum class ViewerType { LEGACY, PREVIEWER, STUDY } +} From 78fa2130237e56fc35c23a65675030203ee35f20 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:56:21 +0100 Subject: [PATCH 019/163] fix(card-viewer): allow deep links if a browser isn't installed Pass HTTP(S) links to a native App Link handler, if one is available. This will work if no browser is installed. Assisted-by: GPT-6-Astra xhigh --- .../com/ichi2/anki/previewer/CardViewerFragment.kt | 5 ----- .../ichi2/anki/security/CardLinkNavigationTest.kt | 12 ++++++++++++ 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerFragment.kt index 8ebafd5bfde1..6efd74ff6d96 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerFragment.kt @@ -30,7 +30,6 @@ import androidx.lifecycle.lifecycleScope import com.ichi2.anki.CommonString import com.ichi2.anki.R import com.ichi2.anki.ViewerResourceHandler -import com.ichi2.anki.common.android.AdaptionUtil import com.ichi2.anki.compat.CompatHelper.Companion.resolveActivityCompat import com.ichi2.anki.dialogs.TtsVoicesDialogFragment import com.ichi2.anki.localizedErrorMessage @@ -320,10 +319,6 @@ abstract class CardViewerFragment( } try { if (intent.hasCategory(Intent.CATEGORY_BROWSABLE)) { - if (!AdaptionUtil.hasWebBrowser(requireContext())) { - showSnackbar(getString(CommonString.no_browser_msg, url.toString())) - return true - } startActivity(intent) } else { // Preserve the existing browser check for custom schemes. diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/security/CardLinkNavigationTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/security/CardLinkNavigationTest.kt index 970d98d8b537..9c42f76b7471 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/security/CardLinkNavigationTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/security/CardLinkNavigationTest.kt @@ -20,6 +20,7 @@ import com.ichi2.anki.previewer.PreviewerFragment import com.ichi2.anki.ui.windows.reviewer.ReviewerFragment import com.ichi2.anki.workarounds.SafeWebViewLayout import com.ichi2.testutils.assertNoActivityStarted +import com.ichi2.testutils.assertOpenedUrl import com.ichi2.testutils.createTransientDirectory import com.ichi2.testutils.mockWebResourceRequest import com.ichi2.testutils.registerWebBrowser @@ -75,6 +76,17 @@ class CardLinkNavigationTest( } } + @Test + fun `web links can open a native app without a browser or external app permission`() { + registerNativeHandler(browsable = true) + withWebView { webView -> + shadowOf(targetContext.applicationContext as Application).checkActivities(true) + val url = "https://native.example.org/action?value=1#fragment" + assertTrue(shadowOf(webView).webViewClient.shouldOverrideUrlLoading(webView, mockWebResourceRequest(url))) + targetContext.assertOpenedUrl(url.toUri()) + } + } + @Test fun `parsed intents with web data still require permission`() { targetContext.registerWebBrowser() From 7548b9937ee34fca1dc49acc5d2441d9e88e3ac1 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 13 Oct 2026 21:58:56 +0100 Subject: [PATCH 020/163] fix(card-viewer): suppress failed card link launches Show an error message, rather than falling back to the WebView to handle the navigation. Assisted-by: GPT-6-Astra xhigh --- .../com/ichi2/anki/previewer/CardViewerFragment.kt | 7 ++++--- .../com/ichi2/anki/security/CardLinkNavigationTest.kt | 11 +++++++++++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerFragment.kt index 6efd74ff6d96..3e1e74f61c76 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerFragment.kt @@ -324,9 +324,10 @@ abstract class CardViewerFragment( // Preserve the existing browser check for custom schemes. openUrl(url) } - } catch (_: Throwable) { - Timber.w("Could not open url") - return false + } catch (e: Throwable) { + Timber.w(e, "Could not open url") + showSnackbar(CommonString.activity_start_failed) + // Consume failed launches: external pages must not load inside the card. } } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/security/CardLinkNavigationTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/security/CardLinkNavigationTest.kt index 9c42f76b7471..89a1cb345f6d 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/security/CardLinkNavigationTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/security/CardLinkNavigationTest.kt @@ -87,6 +87,17 @@ class CardLinkNavigationTest( } } + @Test + fun `missing browsable handler does not load an external page in the card`() { + registerNativeHandler(browsable = false) + withWebView { webView -> + shadowOf(targetContext.applicationContext as Application).checkActivities(true) + val request = mockWebResourceRequest("https://native.example.org/action") + assertTrue(shadowOf(webView).webViewClient.shouldOverrideUrlLoading(webView, request)) + targetContext.assertNoActivityStarted() + } + } + @Test fun `parsed intents with web data still require permission`() { targetContext.registerWebBrowser() From b399981ac4f2919cf545eac5d7f52b239ea33ee6 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:19:29 +0100 Subject: [PATCH 021/163] fix(card-viewer): avoid catching `Error` during link launches Assisted-by: GPT-6-Astra xhigh --- .../main/java/com/ichi2/anki/previewer/CardViewerFragment.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerFragment.kt index 3e1e74f61c76..a5255a09f487 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerFragment.kt @@ -324,7 +324,7 @@ abstract class CardViewerFragment( // Preserve the existing browser check for custom schemes. openUrl(url) } - } catch (e: Throwable) { + } catch (e: Exception) { Timber.w(e, "Could not open url") showSnackbar(CommonString.activity_start_failed) // Consume failed launches: external pages must not load inside the card. From 85901804ce9e9db45c76da43f9422bc51714debe Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:33:33 +0100 Subject: [PATCH 022/163] fix(reviewer): handle rejected external link launches Catch `Exception`, matching the new study screen. Assisted-by: GPT-6-Astra xhigh --- .../com/ichi2/anki/AbstractFlashcardViewer.kt | 5 ++- .../ichi2/anki/AbstractFlashcardViewerTest.kt | 32 +++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt index 6dd9819a49d4..f9dd109ff94e 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt @@ -9,7 +9,6 @@ package com.ichi2.anki import android.annotation.SuppressLint -import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.content.SharedPreferences @@ -2642,8 +2641,8 @@ abstract class AbstractFlashcardViewer : } try { startActivity(intent) - } catch (_: ActivityNotFoundException) { - Timber.w("No app found to handle open external url from AbstractFlashcardViewer") + } catch (e: Exception) { + Timber.w(e, "Could not open url from AbstractFlashcardViewer") showSnackbar(CommonString.activity_start_failed) } return true diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/AbstractFlashcardViewerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/AbstractFlashcardViewerTest.kt index e539bf9654a7..832e60f0480b 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/AbstractFlashcardViewerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/AbstractFlashcardViewerTest.kt @@ -31,8 +31,12 @@ import com.ichi2.anki.reviewer.AutomaticAnswer import com.ichi2.anki.reviewer.AutomaticAnswerAction import com.ichi2.anki.reviewer.AutomaticAnswerSettings import com.ichi2.anki.servicelayer.LanguageHintService +import com.ichi2.testutils.assertNoActivityStarted import com.ichi2.testutils.common.Flaky import com.ichi2.testutils.common.OS +import com.ichi2.testutils.ext.snackbarText +import com.ichi2.testutils.mockWebResourceRequest +import com.ichi2.testutils.registerWebBrowser import kotlinx.coroutines.runBlocking import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.containsString @@ -49,10 +53,13 @@ import org.junit.jupiter.params.provider.MethodSource import org.junit.runner.RunWith import org.mockito.Mockito.mock import org.robolectric.Robolectric +import org.robolectric.Shadows.shadowOf import org.robolectric.android.controller.ActivityController import timber.log.Timber import java.util.Locale import java.util.stream.Stream +import kotlin.test.assertNotNull +import kotlin.test.assertTrue @Suppress("SameParameterValue") @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O) // getImeHintLocales, toLanguageTags, onRenderProcessGone, RenderProcessGoneDetail @@ -62,8 +69,14 @@ class AbstractFlashcardViewerTest : RobolectricTest() { class NonAbstractFlashcardViewer : AbstractFlashcardViewer() { var answered: Rating? = null + var startActivityFailure: Exception? = null private var lastTime = 0 + override fun startActivity(intent: Intent) { + startActivityFailure?.let { throw it } + super.startActivity(intent) + } + override fun performReload() { // intentionally blank } @@ -123,6 +136,25 @@ class AbstractFlashcardViewerTest : RobolectricTest() { } } + @Test + fun `rejected web link launch shows an error and consumes navigation`() { + editPreferences { putBoolean(targetContext.getString(R.string.pref_allow_card_external_launch_key), false) } + targetContext.registerWebBrowser() + val viewer = getViewer(addCard = true) + viewer.startActivityFailure = SecurityException("The handler requires a permission") + val webView = assertNotNull(viewer.webView) + + assertTrue( + shadowOf(webView).webViewClient.shouldOverrideUrlLoading( + webView, + mockWebResourceRequest("https://example.org"), + ), + ) + advanceRobolectricLooper() + targetContext.assertNoActivityStarted() + assertEquals(viewer.getString(CommonString.activity_start_failed), viewer.snackbarText) + } + @ParameterizedTest @MethodSource("getSignalFromUrlTest_args") fun getSignalFromUrlTest( From 0a117f7da418b96b245f032c66cc15b35f667b74 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 15 Oct 2026 01:36:00 +0100 Subject: [PATCH 023/163] build(buildSrc): add Compose convention plugin Assisted-by: GPT-6-Astra xhigh Assisted-by: GPT-6-Astra default --- AnkiDroid/build.gradle.kts | 20 +-------- anki-common/build.gradle.kts | 14 +----- build.gradle.kts | 4 +- buildSrc/build.gradle.kts | 1 + .../ankidroid.android.compose.gradle.kts | 43 +++++++++++++++++++ 5 files changed, 48 insertions(+), 34 deletions(-) create mode 100644 buildSrc/src/main/kotlin/ankidroid.android.compose.gradle.kts diff --git a/AnkiDroid/build.gradle.kts b/AnkiDroid/build.gradle.kts index ebcf51b3d8e0..504c23229937 100644 --- a/AnkiDroid/build.gradle.kts +++ b/AnkiDroid/build.gradle.kts @@ -12,11 +12,11 @@ plugins { // Gradle plugin portal alias(libs.plugins.tripletPlay) id("ankidroid.android.app") + id("ankidroid.android.compose") id("ankidroid.plugins.jacoco") id("ankidroid.plugins.robolectric-downloader") alias(libs.plugins.androidx.baselineprofile) alias(libs.plugins.kotlin.serialization) - alias(libs.plugins.kotlin.compose) alias(libs.plugins.keeper) alias(libs.plugins.roborazzi) id("idea") @@ -89,7 +89,6 @@ android { aidl = true viewBinding = true resValues = true - compose = true } if (testReleaseBuild) { @@ -758,17 +757,9 @@ dependencies { implementation(libs.google.analytics.kt) implementation(libs.androidx.activity) - // TODO: hoist the Compose enablement (kotlin.compose plugin, buildFeatures.compose, - // and the dependencies below) into a convention plugin under `buildSrc/` once the - // Kotlin-DSL migration of this build script lands implementation(libs.androidx.activity.compose) - implementation(platform(libs.androidx.compose.bom)) - implementation(libs.androidx.compose.ui) - implementation(libs.androidx.compose.ui.tooling.preview) - implementation(libs.androidx.compose.material3) implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.runtime.compose) - debugImplementation(libs.androidx.compose.ui.tooling) implementation(libs.androidx.annotation) implementation(libs.androidx.appcompat) implementation(libs.androidx.browser) @@ -855,9 +846,6 @@ dependencies { // ankidroid.plugins.robolectric-downloader may need a new SDK jar entry // when Robolectric or targetSdk changes. See the instructions in that plugin. testImplementation(libs.robolectric) - testImplementation(libs.androidx.compose.ui.test.junit4) - // Needed for createComposeRule(), but not for createAndroidComposeRule() - debugImplementation(libs.androidx.compose.ui.test.manifest) testImplementation(libs.androidx.test.core) testImplementation(libs.androidx.test.junit) testImplementation(libs.kotlin.reflect) @@ -908,10 +896,4 @@ dependencies { // Required so the ExperimentalCoroutinesApi opt-in (applied globally) doesn't cause // an "unresolved" warning, which is treated as an error due to allWarningsAsErrors testFixturesImplementation(libs.kotlinx.coroutines.core) - // The Kotlin Compose Compiler plugin attaches to every Kotlin compilation in the - // module including testFixtures, which has no @Composable code and refuses to - // run unless the Compose Runtime is on the classpath. compileOnly satisfies the - // plugin's version check without shipping the runtime in the testFixtures output. - testFixturesCompileOnly(platform(libs.androidx.compose.bom)) - testFixturesCompileOnly(libs.androidx.compose.runtime) } diff --git a/anki-common/build.gradle.kts b/anki-common/build.gradle.kts index 4a3c52c377c7..d028a5d222b2 100644 --- a/anki-common/build.gradle.kts +++ b/anki-common/build.gradle.kts @@ -5,8 +5,8 @@ import com.ichi2.anki.gradle.addAnkiBackendDependencies plugins { id("ankidroid.android.library") + id("ankidroid.android.compose") id("org.jetbrains.kotlin.plugin.parcelize") - alias(libs.plugins.kotlin.compose) } configure { @@ -14,7 +14,6 @@ configure { // namespace must be unique for resources generation. namespace = "com.ichi2.anki.ankicommon" buildFeatures.buildConfig = false - buildFeatures.compose = true testFixtures.enable = true } @@ -23,11 +22,6 @@ dependencies { implementation(project(":common:android")) implementation(project(":libanki")) implementation(project(":compat")) - implementation(platform(libs.androidx.compose.bom)) - implementation(libs.androidx.compose.ui) - implementation(libs.androidx.compose.ui.tooling.preview) - implementation(libs.androidx.compose.material3) - debugImplementation(libs.androidx.compose.ui.tooling) addAnkiBackendDependencies(project) @@ -57,10 +51,4 @@ dependencies { testFixturesImplementation(libs.androidx.test.core) testFixturesImplementation(libs.jakewharton.timber) testFixturesImplementation(libs.kotlin.test) - // The Kotlin Compose Compiler plugin attaches to every Kotlin compilation in the - // module including testFixtures, which has no @Composable code and refuses to - // run unless the Compose Runtime is on the classpath. compileOnly satisfies the - // plugin's version check without shipping the runtime in the testFixtures output. - testFixturesCompileOnly(platform(libs.androidx.compose.bom)) - testFixturesCompileOnly(libs.androidx.compose.runtime) } diff --git a/build.gradle.kts b/build.gradle.kts index ff850664a8d4..ee62e08b3275 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -30,8 +30,8 @@ plugins { alias(libs.plugins.androidx.baselineprofile) apply false // Serialization is a separate artifact, not pinned transitively by AGP. alias(libs.plugins.kotlin.serialization) apply false - // Compose Compiler plugin (required since Kotlin 2.0); applied per-module that opts in. - alias(libs.plugins.kotlin.compose) apply false + // Pinned by buildSrc; applied by the Compose convention plugin. + id("org.jetbrains.kotlin.plugin.compose") apply false alias(libs.plugins.ktlint.gradle.plugin) apply false alias(libs.plugins.keeper) apply false } diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 02e06c222126..d4ab4c15c98d 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -19,6 +19,7 @@ dependencies { implementation("com.android.tools.build:gradle:${libs.versions.androidGradlePlugin.get()}") // Force the catalog version of KGP, otherwise it's overridden by AGP. implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:${libs.versions.kotlin.get()}") + implementation("org.jetbrains.kotlin:compose-compiler-gradle-plugin:${libs.versions.kotlin.get()}") testImplementation(gradleTestKit()) testImplementation(libs.junit.jupiter) testRuntimeOnly(libs.junit.platform.launcher) diff --git a/buildSrc/src/main/kotlin/ankidroid.android.compose.gradle.kts b/buildSrc/src/main/kotlin/ankidroid.android.compose.gradle.kts new file mode 100644 index 000000000000..ca87c6cba625 --- /dev/null +++ b/buildSrc/src/main/kotlin/ankidroid.android.compose.gradle.kts @@ -0,0 +1,43 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +// Apply after the Android app or library plugin: +// plugins { +// id("ankidroid.android.library") // or "ankidroid.android.app" +// id("ankidroid.android.compose") +// } + +import com.android.build.api.dsl.CommonExtension +import com.android.build.api.dsl.TestedExtension +import com.android.build.api.variant.AndroidComponentsExtension +import com.ichi2.anki.gradle.libsLibrary + +plugins { + id("org.jetbrains.kotlin.plugin.compose") +} + +extensions.configure { + buildFeatures.compose = true +} + +dependencies { + "implementation"(platform(libsLibrary("androidx-compose-bom"))) + "implementation"(libsLibrary("androidx-compose-ui")) + "implementation"(libsLibrary("androidx-compose-ui-tooling-preview")) + "implementation"(libsLibrary("androidx-compose-material3")) + "debugImplementation"(libsLibrary("androidx-compose-ui-tooling")) + "testImplementation"(libsLibrary("androidx-compose-ui-test-junit4")) + // Needed for createComposeRule(), but not for createAndroidComposeRule(). + "debugImplementation"(libsLibrary("androidx-compose-ui-test-manifest")) +} + +// The Compose compiler also runs on test fixtures without @Composable code. +// Wait until the module has configured whether it enables test fixtures, and keep +// the runtime compile-only so it is not shipped in the test fixtures output. +extensions.getByType(AndroidComponentsExtension::class.java).finalizeDsl { androidDsl -> + if (androidDsl is TestedExtension && androidDsl.testFixtures.enable) { + dependencies { + "testFixturesCompileOnly"(platform(libsLibrary("androidx-compose-bom"))) + "testFixturesCompileOnly"(libsLibrary("androidx-compose-runtime")) + } + } +} From 2e764b0baa525a6874ff9f4200fb64e1b3af8c01 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 15 Oct 2026 00:37:13 +0100 Subject: [PATCH 024/163] build: add `:note-editor` module Prep for a new Note Editor: * Jetpack Compose * Wrapping a custom 'fields' component from the AnkiDroid backend (based on Anki's Svelte fields) Part of 1377 Part of 21353 Assisted-by: GPT-6-Astra default Assisted-by: GPT-6-Astra xhigh --- AnkiDroid/build.gradle.kts | 1 + note-editor/build.gradle.kts | 20 ++++++++++++++++++++ settings.gradle.kts | 1 + 3 files changed, 22 insertions(+) create mode 100644 note-editor/build.gradle.kts diff --git a/AnkiDroid/build.gradle.kts b/AnkiDroid/build.gradle.kts index 504c23229937..f7112b097371 100644 --- a/AnkiDroid/build.gradle.kts +++ b/AnkiDroid/build.gradle.kts @@ -750,6 +750,7 @@ dependencies { implementation(project(":common:android")) implementation(project(":compat")) implementation(project(":libanki")) + implementation(project(":note-editor")) implementation(project(":vbpd")) "baselineProfile"(project(":baselineprofile")) implementation(project(":widgets")) diff --git a/note-editor/build.gradle.kts b/note-editor/build.gradle.kts new file mode 100644 index 000000000000..84da634b648b --- /dev/null +++ b/note-editor/build.gradle.kts @@ -0,0 +1,20 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +import com.android.build.api.dsl.LibraryExtension + +plugins { + id("ankidroid.android.library") + id("ankidroid.android.compose") + alias(libs.plugins.roborazzi) +} + +configure { + namespace = "com.ichi2.anki.noteeditor" +} + +dependencies { + testImplementation(libs.robolectric) + testImplementation(libs.roborazzi) + testImplementation(libs.junit.vintage.engine) + testRuntimeOnly(libs.junit.platform.launcher) +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 9aebd49b0d1b..a180c5218baf 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -37,6 +37,7 @@ include( ":compat", ":libanki", ":lint-rules", + ":note-editor", ":vbpd", ":widgets", ) \ No newline at end of file From 8c1e4a07571edf203842f6d8228e7fe5becc77be Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 15 Oct 2026 03:45:17 +0100 Subject: [PATCH 025/163] refactor(note-editor): extract toolbar icons to modules * `:note-editor` * `:common:android` - chevron Assisted-by: GPT-6-Astra xhigh --- AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt | 3 ++- .../main/java/com/ichi2/anki/dialogs/tags/TagsArrayAdapter.kt | 3 ++- .../com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt | 3 ++- .../src/main/res/drawable/ic_baseline_chevron_right_24.xml | 0 note-editor/build.gradle.kts | 2 ++ .../src/main/res/drawable/ic_add_equation_black_24dp.xml | 0 .../src/main/res/drawable/ic_add_toolbar_icon.xml | 0 .../src/main/res/drawable/ic_format_bold_black_24dp.xml | 0 .../src/main/res/drawable/ic_format_font_size_24dp.xml | 0 .../src/main/res/drawable/ic_format_italic_black_24dp.xml | 0 .../src/main/res/drawable/ic_format_title_black_24dp.xml | 0 .../src/main/res/drawable/ic_format_underlined_black_24dp.xml | 0 .../src/main/res/drawable/ic_horizontal_rule_black_24dp.xml | 0 13 files changed, 8 insertions(+), 3 deletions(-) rename {AnkiDroid => common/android}/src/main/res/drawable/ic_baseline_chevron_right_24.xml (100%) rename {AnkiDroid => note-editor}/src/main/res/drawable/ic_add_equation_black_24dp.xml (100%) rename {AnkiDroid => note-editor}/src/main/res/drawable/ic_add_toolbar_icon.xml (100%) rename {AnkiDroid => note-editor}/src/main/res/drawable/ic_format_bold_black_24dp.xml (100%) rename {AnkiDroid => note-editor}/src/main/res/drawable/ic_format_font_size_24dp.xml (100%) rename {AnkiDroid => note-editor}/src/main/res/drawable/ic_format_italic_black_24dp.xml (100%) rename {AnkiDroid => note-editor}/src/main/res/drawable/ic_format_title_black_24dp.xml (100%) rename {AnkiDroid => note-editor}/src/main/res/drawable/ic_format_underlined_black_24dp.xml (100%) rename {AnkiDroid => note-editor}/src/main/res/drawable/ic_horizontal_rule_black_24dp.xml (100%) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt index db13efd8ac6f..a540552c2f2d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt @@ -199,6 +199,7 @@ import kotlin.math.max import kotlin.math.min import kotlin.math.roundToInt import com.ichi2.anki.common.android.R as CommonR +import com.ichi2.anki.noteeditor.R as NoteEditorR const val CALLER_KEY = "caller" @@ -2396,7 +2397,7 @@ class NoteEditorFragment : // Let the user add more buttons (always at the end). // Sets the add custom tag icon color. - val drawable = ResourcesCompat.getDrawable(resources, R.drawable.ic_add_toolbar_icon, null) + val drawable = ResourcesCompat.getDrawable(resources, NoteEditorR.drawable.ic_add_toolbar_icon, null) drawable!!.setTint(MaterialColors.getColor(requireContext(), R.attr.toolbarIconColor, 0)) val addButton = toolbar.insertItem(0, drawable) { displayAddToolbarDialog() } addButton.contentDescription = resources.getString(CommonString.add_toolbar_item) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsArrayAdapter.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsArrayAdapter.kt index 83e4d4ada92e..263bc8f55dd1 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsArrayAdapter.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsArrayAdapter.kt @@ -25,6 +25,7 @@ import com.ichi2.utils.TypedFilter import java.util.Locale import java.util.Stack import java.util.TreeSet +import com.ichi2.anki.common.android.R as CommonR /** * @param tags A reference to the [TagsList] @@ -412,7 +413,7 @@ class TagsArrayAdapter( // For example, display some icon if the node is a leaf? (assets required) when (node.isExpanded) { true -> button.setBackgroundResource(R.drawable.ic_expand_more_black_24dp_xml) - false -> button.setBackgroundResource(R.drawable.ic_baseline_chevron_right_24) + false -> button.setBackgroundResource(CommonR.drawable.ic_baseline_chevron_right_24) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt index e5a5c558642a..2ba88f8ad62a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt @@ -47,6 +47,7 @@ import com.ichi2.utils.neutralButton import com.ichi2.utils.positiveButton import kotlinx.parcelize.Parcelize import timber.log.Timber +import com.ichi2.anki.common.android.R as CommonR class AddEditReminderDialog : DialogFragment() { /** @@ -359,7 +360,7 @@ class AddEditReminderDialog : DialogFragment() { /** * Icon that shows next to the advanced settings section when the dropdown is closed. */ - private val DROPDOWN_COLLAPSED_CHEVRON = R.drawable.ic_baseline_chevron_right_24 + private val DROPDOWN_COLLAPSED_CHEVRON = CommonR.drawable.ic_baseline_chevron_right_24 /** * Arguments key for the dialog mode to open this dialog in. diff --git a/AnkiDroid/src/main/res/drawable/ic_baseline_chevron_right_24.xml b/common/android/src/main/res/drawable/ic_baseline_chevron_right_24.xml similarity index 100% rename from AnkiDroid/src/main/res/drawable/ic_baseline_chevron_right_24.xml rename to common/android/src/main/res/drawable/ic_baseline_chevron_right_24.xml diff --git a/note-editor/build.gradle.kts b/note-editor/build.gradle.kts index 84da634b648b..0ae0522543e6 100644 --- a/note-editor/build.gradle.kts +++ b/note-editor/build.gradle.kts @@ -13,6 +13,8 @@ configure { } dependencies { + implementation(project(":common:android")) + testImplementation(libs.robolectric) testImplementation(libs.roborazzi) testImplementation(libs.junit.vintage.engine) diff --git a/AnkiDroid/src/main/res/drawable/ic_add_equation_black_24dp.xml b/note-editor/src/main/res/drawable/ic_add_equation_black_24dp.xml similarity index 100% rename from AnkiDroid/src/main/res/drawable/ic_add_equation_black_24dp.xml rename to note-editor/src/main/res/drawable/ic_add_equation_black_24dp.xml diff --git a/AnkiDroid/src/main/res/drawable/ic_add_toolbar_icon.xml b/note-editor/src/main/res/drawable/ic_add_toolbar_icon.xml similarity index 100% rename from AnkiDroid/src/main/res/drawable/ic_add_toolbar_icon.xml rename to note-editor/src/main/res/drawable/ic_add_toolbar_icon.xml diff --git a/AnkiDroid/src/main/res/drawable/ic_format_bold_black_24dp.xml b/note-editor/src/main/res/drawable/ic_format_bold_black_24dp.xml similarity index 100% rename from AnkiDroid/src/main/res/drawable/ic_format_bold_black_24dp.xml rename to note-editor/src/main/res/drawable/ic_format_bold_black_24dp.xml diff --git a/AnkiDroid/src/main/res/drawable/ic_format_font_size_24dp.xml b/note-editor/src/main/res/drawable/ic_format_font_size_24dp.xml similarity index 100% rename from AnkiDroid/src/main/res/drawable/ic_format_font_size_24dp.xml rename to note-editor/src/main/res/drawable/ic_format_font_size_24dp.xml diff --git a/AnkiDroid/src/main/res/drawable/ic_format_italic_black_24dp.xml b/note-editor/src/main/res/drawable/ic_format_italic_black_24dp.xml similarity index 100% rename from AnkiDroid/src/main/res/drawable/ic_format_italic_black_24dp.xml rename to note-editor/src/main/res/drawable/ic_format_italic_black_24dp.xml diff --git a/AnkiDroid/src/main/res/drawable/ic_format_title_black_24dp.xml b/note-editor/src/main/res/drawable/ic_format_title_black_24dp.xml similarity index 100% rename from AnkiDroid/src/main/res/drawable/ic_format_title_black_24dp.xml rename to note-editor/src/main/res/drawable/ic_format_title_black_24dp.xml diff --git a/AnkiDroid/src/main/res/drawable/ic_format_underlined_black_24dp.xml b/note-editor/src/main/res/drawable/ic_format_underlined_black_24dp.xml similarity index 100% rename from AnkiDroid/src/main/res/drawable/ic_format_underlined_black_24dp.xml rename to note-editor/src/main/res/drawable/ic_format_underlined_black_24dp.xml diff --git a/AnkiDroid/src/main/res/drawable/ic_horizontal_rule_black_24dp.xml b/note-editor/src/main/res/drawable/ic_horizontal_rule_black_24dp.xml similarity index 100% rename from AnkiDroid/src/main/res/drawable/ic_horizontal_rule_black_24dp.xml rename to note-editor/src/main/res/drawable/ic_horizontal_rule_black_24dp.xml From 233134fad5fd68aba968e171780278e9e1669ebf Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 15 Oct 2026 04:46:52 +0100 Subject: [PATCH 026/163] refactor(note-editor): extract ToolbarPreferences Assisted-by: GPT-6-Astra xhigh Assisted-by: GPT-6-Astra default --- .../java/com/ichi2/anki/NoteEditorFragment.kt | 28 ++++------- .../java/com/ichi2/anki/noteeditor/Toolbar.kt | 12 ++--- note-editor/build.gradle.kts | 1 + .../noteeditor/data/ToolbarPreferences.kt | 48 +++++++++++++++++++ .../presentation/ToolbarLayoutMode.kt | 10 ++++ 5 files changed, 71 insertions(+), 28 deletions(-) create mode 100644 note-editor/src/main/java/com/ichi2/anki/noteeditor/data/ToolbarPreferences.kt create mode 100644 note-editor/src/main/java/com/ichi2/anki/noteeditor/presentation/ToolbarLayoutMode.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt index a540552c2f2d..6a67240ed009 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt @@ -145,6 +145,7 @@ import com.ichi2.anki.noteeditor.NoteEditorMultimediaController import com.ichi2.anki.noteeditor.Toolbar import com.ichi2.anki.noteeditor.Toolbar.TextFormatListener import com.ichi2.anki.noteeditor.Toolbar.TextWrapper +import com.ichi2.anki.noteeditor.data.ToolbarPreferences import com.ichi2.anki.observability.undoableOp import com.ichi2.anki.pages.ImageOcclusion import com.ichi2.anki.pages.viewmodel.ImageOcclusionArgs @@ -300,6 +301,7 @@ class NoteEditorFragment : private var sourceText: Array? = null private val fieldState = FieldState.fromEditor(this) private lateinit var toolbar: Toolbar + private val toolbarPreferences by lazy { ToolbarPreferences(sharedPrefs()) } // save field index as key and text as value when toggle sticky clicked in Field Edit Text @VisibleForTesting @@ -1500,15 +1502,12 @@ class NoteEditorFragment : } } } - menu.findItem(R.id.action_show_toolbar).isChecked = - !shouldHideToolbar() + menu.findItem(R.id.action_show_toolbar).isChecked = toolbarPreferences.isVisible menu.findItem(R.id.action_capitalize).isChecked = sharedPrefs().getBoolean(PREF_NOTE_EDITOR_CAPITALIZE, true) menu.findItem(R.id.action_scroll_toolbar).apply { - isChecked = - sharedPrefs().getBoolean(PREF_NOTE_EDITOR_SCROLL_TOOLBAR, true) - isEnabled = - !shouldHideToolbar() + isChecked = toolbarPreferences.isScrollable + isEnabled = toolbarPreferences.isVisible } } @@ -1586,9 +1585,7 @@ class NoteEditorFragment : } R.id.action_show_toolbar -> { item.isChecked = !item.isChecked - this.sharedPrefs().edit { - putBoolean(PREF_NOTE_EDITOR_SHOW_TOOLBAR, item.isChecked) - } + toolbarPreferences.isVisible = item.isChecked updateToolbar() // Update the overflow action menu in order to switch the enable/disable status of the "Scroll toolbar" item on the spot @@ -1602,9 +1599,7 @@ class NoteEditorFragment : } R.id.action_scroll_toolbar -> { item.isChecked = !item.isChecked - this.sharedPrefs().edit { - putBoolean(PREF_NOTE_EDITOR_SCROLL_TOOLBAR, item.isChecked) - } + toolbarPreferences.isScrollable = item.isChecked updateToolbar() } } @@ -2351,7 +2346,7 @@ class NoteEditorFragment : } private fun updateToolbar() { - toolbar.isVisible = !shouldHideToolbar() + toolbar.isVisible = toolbarPreferences.isVisible applyBottomInset() if (!toolbar.isVisible) return toolbar.clearCustomItems() @@ -2882,8 +2877,6 @@ class NoteEditorFragment : const val RESULT_UPDATED_IO_NOTE = 11 // preferences keys - const val PREF_NOTE_EDITOR_SCROLL_TOOLBAR = "noteEditorScrollToolbar" - private const val PREF_NOTE_EDITOR_SHOW_TOOLBAR = "noteEditorShowToolbar" private const val PREF_NOTE_EDITOR_NEWLINE_REPLACE = "noteEditorNewlineReplace" private const val PREF_NOTE_EDITOR_CAPITALIZE = "note_editor_capitalize" private const val PREF_NOTE_EDITOR_FONT_SIZE = "note_editor_font_size" @@ -2912,10 +2905,5 @@ class NoteEditorFragment : if (ImportUtils.isInvalidViewIntent(intent)) return false return intent.resolveMimeType()?.startsWith("image/") == true } - - private fun shouldHideToolbar(): Boolean = - !appContext - .sharedPrefs() - .getBoolean(PREF_NOTE_EDITOR_SHOW_TOOLBAR, true) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/Toolbar.kt b/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/Toolbar.kt index 1b8393b1c903..6ac3ad7a224e 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/Toolbar.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/Toolbar.kt @@ -38,11 +38,10 @@ import androidx.core.view.isVisible import androidx.core.view.updatePadding import androidx.vectordrawable.graphics.drawable.VectorDrawableCompat import com.ichi2.anki.CommonString -import com.ichi2.anki.NoteEditorFragment import com.ichi2.anki.R -import com.ichi2.anki.common.android.appContext import com.ichi2.anki.common.preferences.sharedPrefs import com.ichi2.anki.compat.CompatHelper +import com.ichi2.anki.noteeditor.data.ToolbarPreferences import com.ichi2.utils.AndroidUiUtils.showSoftInput import com.ichi2.utils.ViewGroupUtils import com.ichi2.utils.ViewGroupUtils.getAllChildrenRecursive @@ -64,12 +63,13 @@ import kotlin.math.ceil * * Handles generating the 'icon' for these custom buttons: [createDrawableForString] * * Handles CTRL+ the tag of the button: [onKeyUp]. Allows for Ctrl+1..9 shortcuts * * Handles adding a dynamic number of buttons and aligning them into rows: [insertItem] - * * And handles whether these should be stacked or scrollable: [shouldScrollToolbar] + * * And handles whether these should be stacked or scrollable: [ToolbarPreferences.isScrollable] */ class Toolbar : FrameLayout { var formatListener: TextFormatListener? = null private val toolbar: LinearLayout private val toolbarLayout: LinearLayout + private val toolbarPreferences = ToolbarPreferences(context.sharedPrefs()) @VisibleForTesting internal val scrollView: HorizontalScrollView @@ -229,11 +229,7 @@ class Toolbar : FrameLayout { val twoDp = ceil((2 / context.resources.displayMetrics.density).toDouble()).toInt() button.setPaddingRelative(twoDp, twoDp, twoDp, twoDp) // end apply style - val shouldScroll = - appContext - .sharedPrefs() - .getBoolean(NoteEditorFragment.PREF_NOTE_EDITOR_SCROLL_TOOLBAR, true) - if (shouldScroll) { + if (toolbarPreferences.isScrollable) { toolbar.addView(button, toolbar.childCount) } else { addViewToToolbar(button) diff --git a/note-editor/build.gradle.kts b/note-editor/build.gradle.kts index 0ae0522543e6..909b56ebf9ec 100644 --- a/note-editor/build.gradle.kts +++ b/note-editor/build.gradle.kts @@ -14,6 +14,7 @@ configure { dependencies { implementation(project(":common:android")) + implementation(libs.androidx.core) testImplementation(libs.robolectric) testImplementation(libs.roborazzi) diff --git a/note-editor/src/main/java/com/ichi2/anki/noteeditor/data/ToolbarPreferences.kt b/note-editor/src/main/java/com/ichi2/anki/noteeditor/data/ToolbarPreferences.kt new file mode 100644 index 000000000000..54360f431a29 --- /dev/null +++ b/note-editor/src/main/java/com/ichi2/anki/noteeditor/data/ToolbarPreferences.kt @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.noteeditor.data + +import android.content.SharedPreferences +import androidx.core.content.edit +import com.ichi2.anki.noteeditor.presentation.ToolbarLayoutMode + +private const val PREF_NOTE_EDITOR_SCROLL_TOOLBAR = "noteEditorScrollToolbar" +private const val PREF_NOTE_EDITOR_SHOW_TOOLBAR = "noteEditorShowToolbar" + +/** + * Persists the toolbar layout using the existing show/scroll preferences. + * + * TODO: migrate to a single preference value. + */ +class ToolbarPreferences( + private val preferences: SharedPreferences, +) { + var isVisible: Boolean + get() = preferences.getBoolean(PREF_NOTE_EDITOR_SHOW_TOOLBAR, true) + set(value) { + preferences.edit { putBoolean(PREF_NOTE_EDITOR_SHOW_TOOLBAR, value) } + } + + /** The layout to use when visible, retained while the toolbar is hidden. */ + var isScrollable: Boolean + get() = preferences.getBoolean(PREF_NOTE_EDITOR_SCROLL_TOOLBAR, true) + set(value) { + preferences.edit { putBoolean(PREF_NOTE_EDITOR_SCROLL_TOOLBAR, value) } + } + + var layout: ToolbarLayoutMode + get() = + when { + !isVisible -> ToolbarLayoutMode.HIDDEN + isScrollable -> ToolbarLayoutMode.SCROLL + else -> ToolbarLayoutMode.STACK + } + set(value) { + preferences.edit { + putBoolean(PREF_NOTE_EDITOR_SHOW_TOOLBAR, value != ToolbarLayoutMode.HIDDEN) + if (value != ToolbarLayoutMode.HIDDEN) { + putBoolean(PREF_NOTE_EDITOR_SCROLL_TOOLBAR, value == ToolbarLayoutMode.SCROLL) + } + } + } +} diff --git a/note-editor/src/main/java/com/ichi2/anki/noteeditor/presentation/ToolbarLayoutMode.kt b/note-editor/src/main/java/com/ichi2/anki/noteeditor/presentation/ToolbarLayoutMode.kt new file mode 100644 index 000000000000..fe1a54b84b42 --- /dev/null +++ b/note-editor/src/main/java/com/ichi2/anki/noteeditor/presentation/ToolbarLayoutMode.kt @@ -0,0 +1,10 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.noteeditor.presentation + +/** Layout of the formatting toolbar. */ +enum class ToolbarLayoutMode { + SCROLL, + STACK, + HIDDEN, +} From f151fb9cb3a15cc2ca540b3d8d8ca4a19feaeaa9 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 15 Oct 2026 05:00:13 +0100 Subject: [PATCH 027/163] feat(compose): support previews for all app themes `AnkiDroidPreview` now provides all app themes via `AnkiDroidThemeProvider` Assisted-by: GPT-6-Astra xhigh --- .../compose/ui/preview/AnkiDroidPreview.kt | 16 ++++++++++------ .../ui/preview/AnkiDroidThemeProvider.kt | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+), 6 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/compose/ui/preview/AnkiDroidThemeProvider.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/compose/ui/preview/AnkiDroidPreview.kt b/AnkiDroid/src/main/java/com/ichi2/compose/ui/preview/AnkiDroidPreview.kt index 55dd0abba7c5..581ec6247f3a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/compose/ui/preview/AnkiDroidPreview.kt +++ b/AnkiDroid/src/main/java/com/ichi2/compose/ui/preview/AnkiDroidPreview.kt @@ -8,20 +8,24 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext -import com.ichi2.anki.R +import com.ichi2.anki.settings.enums.DayTheme +import com.ichi2.anki.settings.enums.NightTheme +import com.ichi2.anki.settings.enums.Theme import com.ichi2.compose.theme.AnkiDroidTheme /** - * Applies the AnkiDroid theme matching the preview's light or dark UI mode. + * Applies [theme] to preview content, defaulting to the preview's light or dark UI mode. * - * Use this wrapper with [ThemePreviews]. + * Use [AnkiDroidThemeProvider] to preview every app theme, or [ThemePreviews] for light and dark. */ @Composable -fun AnkiDroidPreview(content: @Composable () -> Unit) { +fun AnkiDroidPreview( + theme: Theme = if (isSystemInDarkTheme()) NightTheme.DARK else DayTheme.LIGHT, + content: @Composable () -> Unit, +) { // AnkiDroidTheme reads XML attributes, so changing only the preview's UI mode is insufficient. val context = LocalContext.current - val theme = if (isSystemInDarkTheme()) R.style.Theme_Dark else R.style.Theme_Light - val themedContext = remember(context, theme) { ContextThemeWrapper(context, theme) } + val themedContext = remember(context, theme) { ContextThemeWrapper(context, theme.styleResId) } CompositionLocalProvider(LocalContext provides themedContext) { AnkiDroidTheme(content = content) } diff --git a/AnkiDroid/src/main/java/com/ichi2/compose/ui/preview/AnkiDroidThemeProvider.kt b/AnkiDroid/src/main/java/com/ichi2/compose/ui/preview/AnkiDroidThemeProvider.kt new file mode 100644 index 000000000000..b90acf3c0a41 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/compose/ui/preview/AnkiDroidThemeProvider.kt @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.compose.ui.preview + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import com.ichi2.anki.settings.enums.DayTheme +import com.ichi2.anki.settings.enums.NightTheme +import com.ichi2.anki.settings.enums.Theme + +/** Supplies every app theme to a preview wrapped in [AnkiDroidPreview]. */ +class AnkiDroidThemeProvider : PreviewParameterProvider { + override val values: Sequence = DayTheme.entries.asSequence() + NightTheme.entries.asSequence() + + override fun getDisplayName(index: Int): String = + when (val theme = values.elementAt(index)) { + DayTheme.EINK -> "E-ink" + else -> theme.toString().lowercase().replaceFirstChar(Char::titlecase) + } +} From 1b25591a7b8274f0b5c9ee408a1ed7f867615774 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:36:34 +0100 Subject: [PATCH 028/163] refactor: use `RESULT_` for fragment result key names Part of 22410 Assisted-by: GPT-6-Astra default --- .../main/java/com/ichi2/anki/AnkiActivity.kt | 10 ++-- .../java/com/ichi2/anki/CardTemplateEditor.kt | 2 +- .../com/ichi2/anki/NoteTypeFieldEditor.kt | 4 +- .../ichi2/anki/browser/CardBrowserFragment.kt | 12 ++--- .../browser/FindAndReplaceDialogFragment.kt | 52 +++++++++---------- .../anki/browser/RepositionCardFragment.kt | 19 ++++--- .../anki/dialogs/DeckPickerContextMenu.kt | 12 ++--- .../ichi2/anki/dialogs/DeckSelectionDialog.kt | 10 ++-- .../ichi2/anki/dialogs/ExportReadyDialog.kt | 17 +++--- .../ichi2/anki/dialogs/InsertFieldDialog.kt | 4 +- .../anki/dialogs/LocaleSelectionDialog.kt | 4 +- .../SaveBrowserSearchDialogFragment.kt | 22 ++++---- .../SavedBrowserSearchesDialogFragment.kt | 24 ++++----- .../dialogs/customstudy/CustomStudyDialog.kt | 13 +++-- .../dialogs/customstudy/TagLimitFragment.kt | 8 +-- .../com/ichi2/anki/dialogs/help/HelpDialog.kt | 6 +-- .../anki/dialogs/tags/TagsDialogListener.kt | 18 +++---- .../anki/scheduling/ForgetCardsDialog.kt | 19 ++++--- .../permissions/PermissionsActivity.kt | 4 +- .../permissions/PermissionsFragment.kt | 4 +- .../java/com/ichi2/anki/CardBrowserTest.kt | 28 +++++----- .../com/ichi2/anki/CardTemplateEditorTest.kt | 2 +- .../java/com/ichi2/anki/DeckPickerTest.kt | 2 +- .../anki/dialogs/CustomStudyDialogTest.kt | 4 +- 24 files changed, 157 insertions(+), 143 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt index 65f4a46e8656..5f17b2df1a21 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt @@ -83,10 +83,10 @@ import com.ichi2.anki.dialogs.DatabaseErrorDialog import com.ichi2.anki.dialogs.DatabaseErrorDialog.CustomExceptionData import com.ichi2.anki.dialogs.DatabaseErrorDialog.DatabaseErrorDialogType import com.ichi2.anki.dialogs.DialogHandler -import com.ichi2.anki.dialogs.ExportReadyDialog.Companion.ARG_SHARE_AS_TEXT -import com.ichi2.anki.dialogs.ExportReadyDialog.Companion.KEY_EXPORT_PATH import com.ichi2.anki.dialogs.ExportReadyDialog.Companion.REQUEST_EXPORT_SAVE import com.ichi2.anki.dialogs.ExportReadyDialog.Companion.REQUEST_EXPORT_SHARE +import com.ichi2.anki.dialogs.ExportReadyDialog.Companion.RESULT_EXPORT_PATH +import com.ichi2.anki.dialogs.ExportReadyDialog.Companion.RESULT_SHARE_AS_TEXT import com.ichi2.anki.dialogs.SimpleMessageDialog import com.ichi2.anki.dialogs.handleExportReadyRequest import com.ichi2.anki.dialogs.viewmodel.ExportReadyViewModel @@ -170,13 +170,13 @@ open class AnkiActivity( } supportFragmentManager.setFragmentResultListener(REQUEST_EXPORT_SAVE, this) { _, bundle -> saveExportFile( - bundle.getString(KEY_EXPORT_PATH) ?: error("Missing required exportPath!"), + bundle.getString(RESULT_EXPORT_PATH) ?: error("Missing required exportPath!"), ) } supportFragmentManager.setFragmentResultListener(REQUEST_EXPORT_SHARE, this) { _, bundle -> shareFile( - path = bundle.requireString(KEY_EXPORT_PATH), - asText = bundle.getBoolean(ARG_SHARE_AS_TEXT, false), + path = bundle.requireString(RESULT_EXPORT_PATH), + asText = bundle.getBoolean(RESULT_SHARE_AS_TEXT, false), ) } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt index eebc07c52163..59040b9c9663 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt @@ -816,7 +816,7 @@ open class CardTemplateEditor : AnkiActivity(R.layout.activity_card_template_edi parentFragmentManager.setFragmentResultListener(insertFieldRequestKey, viewLifecycleOwner) { key, bundle -> // this is guaranteed to be non null, as we put a non null value on the other side - insertField(bundle.getString(InsertFieldDialog.KEY_INSERTED_FIELD)!!) + insertField(bundle.getString(InsertFieldDialog.RESULT_INSERTED_FIELD)!!) } setupMenu() } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NoteTypeFieldEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NoteTypeFieldEditor.kt index 4ae9410e5dd8..1a865ccb55a4 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/NoteTypeFieldEditor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/NoteTypeFieldEditor.kt @@ -36,8 +36,8 @@ import com.ichi2.anki.databinding.ActivityNoteTypeFieldEditorBinding import com.ichi2.anki.databinding.ItemNotetypeFieldBinding import com.ichi2.anki.dialogs.ConfirmationDialog import com.ichi2.anki.dialogs.LocaleSelectionDialog -import com.ichi2.anki.dialogs.LocaleSelectionDialog.Companion.KEY_SELECTED_LOCALE import com.ichi2.anki.dialogs.LocaleSelectionDialog.Companion.REQUEST_HINT_LOCALE_SELECTION +import com.ichi2.anki.dialogs.LocaleSelectionDialog.Companion.RESULT_SELECTED_LOCALE import com.ichi2.anki.dialogs.NoteTypeFieldEditorContextMenu.Companion.newInstance import com.ichi2.anki.dialogs.NoteTypeFieldEditorContextMenu.NoteTypeFieldEditorContextMenuAction import com.ichi2.anki.libanki.Collection @@ -123,7 +123,7 @@ class NoteTypeFieldEditor : AnkiActivity(R.layout.activity_note_type_field_edito val selectedLocale = BundleCompat.getSerializable( bundle, - KEY_SELECTED_LOCALE, + RESULT_SELECTED_LOCALE, Locale::class.java, ) if (selectedLocale != null) { 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 922c35f6c2d4..d3d951d8f14b 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt @@ -110,7 +110,7 @@ import com.ichi2.anki.common.utils.ext.ifNotZero import com.ichi2.anki.dialogs.BrowserOptionsDialog import com.ichi2.anki.dialogs.ChangeNoteTypeDialog import com.ichi2.anki.dialogs.DeckSelectionDialog -import com.ichi2.anki.dialogs.DeckSelectionDialog.Companion.ARG_SELECTED_DECK +import com.ichi2.anki.dialogs.DeckSelectionDialog.Companion.RESULT_SELECTED_DECK import com.ichi2.anki.dialogs.GradeNowDialog import com.ichi2.anki.dialogs.SaveBrowserSearchDialogFragment import com.ichi2.anki.dialogs.SavedBrowserSearchesDialogFragment @@ -327,7 +327,7 @@ class CardBrowserFragment : // onSearchForDecks starts deck selection using childFragmentManager childFragmentManager.setFragmentResultListener(DeckSelectionDialog.REQUEST_SELECT_DECK, this) { _, bundle -> - val selectedDeck = bundle.getParcelableCompat(ARG_SELECTED_DECK) + val selectedDeck = bundle.getParcelableCompat(RESULT_SELECTED_DECK) activityViewModel.setSelectedDeck(requireNotNull(selectedDeck) { "Expected non-null deck" }) } @@ -1293,10 +1293,10 @@ class CardBrowserFragment : private fun setupFragmentResultListeners() { ankiActivity.setFragmentResultListener(REQUEST_REPOSITION_NEW_CARDS) { _, bundle -> repositionCardsNoValidation( - position = bundle.getInt(RepositionCardFragment.ARG_POSITION), - step = bundle.getInt(RepositionCardFragment.ARG_STEP), - shuffle = bundle.getBoolean(RepositionCardFragment.ARG_RANDOM), - shift = bundle.getBoolean(RepositionCardFragment.ARG_SHIFT), + position = bundle.getInt(RepositionCardFragment.RESULT_POSITION), + step = bundle.getInt(RepositionCardFragment.RESULT_STEP), + shuffle = bundle.getBoolean(RepositionCardFragment.RESULT_RANDOM), + shift = bundle.getBoolean(RepositionCardFragment.RESULT_SHIFT), ) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/FindAndReplaceDialogFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/FindAndReplaceDialogFragment.kt index 4cf9d5c73744..85bbd31be294 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/FindAndReplaceDialogFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/FindAndReplaceDialogFragment.kt @@ -20,13 +20,13 @@ import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.CommonString import com.ichi2.anki.R import com.ichi2.anki.analytics.AnalyticsDialogFragment -import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.ARG_FIELD -import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.ARG_MATCH_CASE -import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.ARG_ONLY_SELECTED_NOTES -import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.ARG_REGEX -import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.ARG_REPLACEMENT -import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.ARG_SEARCH import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.REQUEST_FIND_AND_REPLACE +import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.RESULT_FIELD +import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.RESULT_MATCH_CASE +import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.RESULT_ONLY_SELECTED_NOTES +import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.RESULT_REGEX +import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.RESULT_REPLACEMENT +import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.RESULT_SEARCH import com.ichi2.anki.databinding.FragmentFindReplaceBinding import com.ichi2.anki.libanki.NoteId import com.ichi2.anki.notetype.ManageNotetypes @@ -147,13 +147,13 @@ class FindAndReplaceDialogFragment : AnalyticsDialogFragment() { setFragmentResult( REQUEST_FIND_AND_REPLACE, Bundle().apply { - putString(ARG_SEARCH, search.toString()) - putString(ARG_REPLACEMENT, replacement.toString()) - putString(ARG_FIELD, selectedField) - putBoolean(ARG_ONLY_SELECTED_NOTES, onlyInSelectedNotes) + putString(RESULT_SEARCH, search.toString()) + putString(RESULT_REPLACEMENT, replacement.toString()) + putString(RESULT_FIELD, selectedField) + putBoolean(RESULT_ONLY_SELECTED_NOTES, onlyInSelectedNotes) // "Ignore case" checkbox text => when it's checked we pass false to the backend - putBoolean(ARG_MATCH_CASE, !ignoreCase) - putBoolean(ARG_REGEX, inputAsRegex) + putBoolean(RESULT_MATCH_CASE, !ignoreCase) + putBoolean(RESULT_REGEX, inputAsRegex) }, ) } @@ -167,21 +167,21 @@ class FindAndReplaceDialogFragment : AnalyticsDialogFragment() { const val TAG = "FindAndReplaceDialogFragment" const val REQUEST_FIND_AND_REPLACE = "request_find_and_replace" const val ARG_IDS = "arg_ids" - const val ARG_SEARCH = "arg_search" - const val ARG_REPLACEMENT = "arg_replacement" - const val ARG_FIELD = "arg_field" - const val ARG_ONLY_SELECTED_NOTES = "arg_only_selected_notes" - const val ARG_MATCH_CASE = "arg_match_case" - const val ARG_REGEX = "arg_regex" + const val RESULT_SEARCH = "arg_search" + const val RESULT_REPLACEMENT = "arg_replacement" + const val RESULT_FIELD = "arg_field" + const val RESULT_ONLY_SELECTED_NOTES = "arg_only_selected_notes" + const val RESULT_MATCH_CASE = "arg_match_case" + const val RESULT_REGEX = "arg_regex" /** - * Receiving this value in the result [Bundle] for the [ARG_FIELD] entry means that + * Receiving this value in the result [Bundle] for the [RESULT_FIELD] entry means that * the user selected "All fields" as the field target for the find and replace action. */ const val ALL_FIELDS_AS_FIELD = "find_and_replace_dialog_fragment_all_fields_as_field" /** - * Receiving this value in the result [Bundle] for the [ARG_FIELD] entry means that + * Receiving this value in the result [Bundle] for the [RESULT_FIELD] entry means that * the user selected "Tags" as the field target for the find and replace action. */ const val TAGS_AS_FIELD = "find_and_replace_dialog_fragment_tags_as_field" @@ -205,12 +205,12 @@ fun CardBrowser.registerFindReplaceHandler(action: (FindReplaceResult) -> Unit) setFragmentResultListener(REQUEST_FIND_AND_REPLACE) { _, bundle -> action( FindReplaceResult( - search = bundle.getString(ARG_SEARCH) ?: error("Missing required argument: search"), - replacement = bundle.getString(ARG_REPLACEMENT) ?: error("Missing required argument: replacement"), - field = bundle.getString(ARG_FIELD) ?: error("Missing required argument: field"), - onlyOnSelectedNotes = bundle.getBoolean(ARG_ONLY_SELECTED_NOTES, true), - matchCase = bundle.getBoolean(ARG_MATCH_CASE, false), - regex = bundle.getBoolean(ARG_REGEX, false), + search = bundle.getString(RESULT_SEARCH) ?: error("Missing required argument: search"), + replacement = bundle.getString(RESULT_REPLACEMENT) ?: error("Missing required argument: replacement"), + field = bundle.getString(RESULT_FIELD) ?: error("Missing required argument: field"), + onlyOnSelectedNotes = bundle.getBoolean(RESULT_ONLY_SELECTED_NOTES, true), + matchCase = bundle.getBoolean(RESULT_MATCH_CASE, false), + regex = bundle.getBoolean(RESULT_REGEX, false), ), ) } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/RepositionCardFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/RepositionCardFragment.kt index a4b5bb17cda8..7b59431ab7ec 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/RepositionCardFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/RepositionCardFragment.kt @@ -75,10 +75,10 @@ class RepositionCardFragment : DialogFragment() { setFragmentResult( REQUEST_REPOSITION_NEW_CARDS, Bundle().apply { - putInt(ARG_POSITION, position) - putInt(ARG_STEP, step) - putBoolean(ARG_RANDOM, binding.randomizeOrderCheck.isChecked) - putBoolean(ARG_SHIFT, binding.shiftPositionCheck.isChecked) + putInt(RESULT_POSITION, position) + putInt(RESULT_STEP, step) + putBoolean(RESULT_RANDOM, binding.randomizeOrderCheck.isChecked) + putBoolean(RESULT_SHIFT, binding.shiftPositionCheck.isChecked) }, ) } @@ -124,10 +124,13 @@ class RepositionCardFragment : DialogFragment() { const val REQUEST_REPOSITION_NEW_CARDS = "request_repositions_new_cards" private const val ARG_QUEUE_TOP = "arg_queue_top" private const val ARG_QUEUE_BOTTOM = "arg_queue_bottom" - const val ARG_POSITION = "arg_position" - const val ARG_STEP = "arg_step" - const val ARG_RANDOM = "arg_random" - const val ARG_SHIFT = "arg_shift" + const val RESULT_POSITION = "arg_position" + const val RESULT_STEP = "arg_step" + const val RESULT_RANDOM = "arg_random" + const val RESULT_SHIFT = "arg_shift" + + private const val ARG_RANDOM = "arg_random" + private const val ARG_SHIFT = "arg_shift" fun newInstance( queueTop: Int, 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 85f24b7ec1de..850022f79f6b 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckPickerContextMenu.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckPickerContextMenu.kt @@ -134,19 +134,19 @@ data class DeckPickerContextMenuResult( ) { fun toBundle(): Bundle = Bundle().apply { - putLong(ARG_DECK_ID, deckId) - putSerializable(ARG_OPTION, option) + putLong(RESULT_DECK_ID, deckId) + putSerializable(RESULT_CONTEXT_MENU_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" + private const val RESULT_DECK_ID = "deck_id" + private const val RESULT_CONTEXT_MENU_OPTION = "option" fun fromBundle(bundle: Bundle) = DeckPickerContextMenuResult( - deckId = bundle.requireLong(ARG_DECK_ID), - option = bundle.requireSerializableCompat(ARG_OPTION), + deckId = bundle.requireLong(RESULT_DECK_ID), + option = bundle.requireSerializableCompat(RESULT_CONTEXT_MENU_OPTION), ) } } 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 44a83beb5447..4758eb35abfb 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckSelectionDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DeckSelectionDialog.kt @@ -38,8 +38,8 @@ import com.ichi2.anki.common.annotations.NeedsTest import com.ichi2.anki.databinding.DialogDeckPickerBinding import com.ichi2.anki.databinding.ItemDeckPickerDialogBinding import com.ichi2.anki.deckpicker.DeckFilters -import com.ichi2.anki.dialogs.DeckSelectionDialog.Companion.ARG_SELECTED_DECK import com.ichi2.anki.dialogs.DeckSelectionDialog.Companion.REQUEST_SELECT_DECK +import com.ichi2.anki.dialogs.DeckSelectionDialog.Companion.RESULT_SELECTED_DECK import com.ichi2.anki.launchCatchingTask import com.ichi2.anki.libanki.Consts.DEFAULT_DECK_ID import com.ichi2.anki.libanki.DeckId @@ -187,7 +187,7 @@ class DeckSelectionDialog : AnalyticsDialogFragment() { parentFragmentManager.setFragmentResult( requestKey, Bundle().apply { - putParcelable(ARG_SELECTED_DECK, deck) + putParcelable(RESULT_SELECTED_DECK, deck) }, ) } @@ -414,7 +414,7 @@ class DeckSelectionDialog : AnalyticsDialogFragment() { companion object { const val TAG = "DeckSelectionDialog" const val REQUEST_SELECT_DECK = "request_select_deck" - const val ARG_SELECTED_DECK = "arg_selected_deck" + const val RESULT_SELECTED_DECK = "arg_selected_deck" const val ARG_REQUEST_KEY = "arg_request_key" const val ARG_ALLOW_ALL = "arg_allow_all" const val ARG_ALLOW_FILTERED = "arg_allow_filtered" @@ -462,7 +462,7 @@ fun FragmentActivity.registerDeckSelectedHandler( action: (deck: SelectableDeck?) -> Unit, ) { setFragmentResultListener(requestKey) { _, bundle -> - val selectedDeck = bundle.getParcelableCompat(ARG_SELECTED_DECK) + val selectedDeck = bundle.getParcelableCompat(RESULT_SELECTED_DECK) action(selectedDeck) } } @@ -479,7 +479,7 @@ fun Fragment.registerDeckSelectedHandler( action: (deck: SelectableDeck?) -> Unit, ) { setFragmentResultListener(requestKey) { _, bundle -> - val selectedDeck = bundle.getParcelableCompat(ARG_SELECTED_DECK) + val selectedDeck = bundle.getParcelableCompat(RESULT_SELECTED_DECK) action(selectedDeck) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ExportReadyDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ExportReadyDialog.kt index ea40a215ccee..e9db081842ee 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ExportReadyDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ExportReadyDialog.kt @@ -16,7 +16,7 @@ import timber.log.Timber class ExportReadyDialog : DialogFragment() { private val exportPath - get() = requireArguments().requireString(KEY_EXPORT_PATH) + get() = requireArguments().requireString(ARG_EXPORT_PATH) private val asText: Boolean get() = requireArguments().getBoolean(ARG_SHARE_AS_TEXT, false) @@ -28,14 +28,14 @@ class ExportReadyDialog : DialogFragment() { .positiveButton(CommonString.export_choice_save_to) { parentFragmentManager.setFragmentResult( REQUEST_EXPORT_SAVE, - Bundle().apply { putString(KEY_EXPORT_PATH, exportPath) }, + Bundle().apply { putString(RESULT_EXPORT_PATH, exportPath) }, ) }.negativeButton(CommonString.export_choice_share) { parentFragmentManager.setFragmentResult( REQUEST_EXPORT_SHARE, Bundle().apply { - putString(KEY_EXPORT_PATH, exportPath) - putBoolean(ARG_SHARE_AS_TEXT, asText) + putString(RESULT_EXPORT_PATH, exportPath) + putBoolean(RESULT_SHARE_AS_TEXT, asText) }, ) } @@ -46,8 +46,11 @@ class ExportReadyDialog : DialogFragment() { companion object { const val REQUEST_EXPORT_SAVE = "request_export_save" const val REQUEST_EXPORT_SHARE = "request_export_share" - const val KEY_EXPORT_PATH = "key_export_path" - const val ARG_SHARE_AS_TEXT = "arg_share_as_text" + const val RESULT_EXPORT_PATH = "key_export_path" + const val RESULT_SHARE_AS_TEXT = "arg_share_as_text" + + private const val ARG_EXPORT_PATH = "key_export_path" + private const val ARG_SHARE_AS_TEXT = "arg_share_as_text" fun newInstance( exportPath: String, @@ -55,7 +58,7 @@ class ExportReadyDialog : DialogFragment() { ) = ExportReadyDialog().apply { arguments = Bundle().apply { - putString(KEY_EXPORT_PATH, exportPath) + putString(ARG_EXPORT_PATH, exportPath) putBoolean(ARG_SHARE_AS_TEXT, asText) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt index d0710c2dd91f..59172fcae434 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/InsertFieldDialog.kt @@ -107,7 +107,7 @@ class InsertFieldDialog : DialogFragment(R.layout.dialog_insert_field) { if (field == null) return@collect parentFragmentManager.setFragmentResult( requestKey, - Bundle().apply { putString(KEY_INSERTED_FIELD, field.renderToTemplateTag()) }, + Bundle().apply { putString(RESULT_INSERTED_FIELD, field.renderToTemplateTag()) }, ) dismiss() } @@ -132,7 +132,7 @@ class InsertFieldDialog : DialogFragment(R.layout.dialog_insert_field) { * * Represents the template tag for the selected field: `{{Front}}` */ - const val KEY_INSERTED_FIELD = "key_inserted_field" + const val RESULT_INSERTED_FIELD = "key_inserted_field" /** * Creates a new instance of [InsertFieldDialog] diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/LocaleSelectionDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/LocaleSelectionDialog.kt index 3f9dfb1d318b..96eb5a1dd79f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/LocaleSelectionDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/LocaleSelectionDialog.kt @@ -87,7 +87,7 @@ class LocaleSelectionDialog : AnalyticsDialogFragment() { private fun sendSelectionResult(locale: Locale? = null) { parentFragmentManager.setFragmentResult( REQUEST_HINT_LOCALE_SELECTION, - Bundle().apply { putSerializable(KEY_SELECTED_LOCALE, locale) }, + Bundle().apply { putSerializable(RESULT_SELECTED_LOCALE, locale) }, ) } @@ -151,7 +151,7 @@ class LocaleSelectionDialog : AnalyticsDialogFragment() { companion object { const val REQUEST_HINT_LOCALE_SELECTION = "request_hint_locale_selection" - const val KEY_SELECTED_LOCALE = "key_selected_locale" + const val RESULT_SELECTED_LOCALE = "key_selected_locale" /** * Language identifier for International Phonetic Alphabet. This isn't available from [Locale.getAvailableLocales], but diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/SaveBrowserSearchDialogFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/SaveBrowserSearchDialogFragment.kt index eef776c9af4d..6094786aae10 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/SaveBrowserSearchDialogFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/SaveBrowserSearchDialogFragment.kt @@ -13,8 +13,8 @@ import com.ichi2.anki.CardBrowser import com.ichi2.anki.CommonString import com.ichi2.anki.R import com.ichi2.anki.browser.search.SavedSearch -import com.ichi2.anki.dialogs.SaveBrowserSearchDialogFragment.Companion.ARG_SEARCH_QUERY -import com.ichi2.anki.dialogs.SaveBrowserSearchDialogFragment.Companion.ARG_SEARCH_QUERY_NAME +import com.ichi2.anki.dialogs.SaveBrowserSearchDialogFragment.Companion.RESULT_SEARCH_QUERY +import com.ichi2.anki.dialogs.SaveBrowserSearchDialogFragment.Companion.RESULT_SEARCH_QUERY_NAME import com.ichi2.anki.launchCatchingTask import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.utils.input @@ -53,8 +53,8 @@ class SaveBrowserSearchDialogFragment : DialogFragment() { setFragmentResult( REQUEST_SAVE_SEARCH, Bundle().apply { - putString(ARG_SEARCH_QUERY, searchQuery) - putCharSequence(ARG_SEARCH_QUERY_NAME, name) + putString(RESULT_SEARCH_QUERY, searchQuery) + putCharSequence(RESULT_SEARCH_QUERY_NAME, name) }, ) dialog.dismiss() @@ -67,12 +67,14 @@ class SaveBrowserSearchDialogFragment : DialogFragment() { /** * The actual search query that the user is saving to use later. */ - const val ARG_SEARCH_QUERY = "arg_search_query" + const val RESULT_SEARCH_QUERY = "arg_search_query" + + private const val ARG_SEARCH_QUERY = "arg_search_query" /** * The name given by the user to the saved search. */ - const val ARG_SEARCH_QUERY_NAME = "arg_search_query_name" + const val RESULT_SEARCH_QUERY_NAME = "arg_search_query_name" fun newInstance(searchQuery: String): SaveBrowserSearchDialogFragment = SaveBrowserSearchDialogFragment().apply { @@ -94,7 +96,7 @@ fun CardBrowser.registerSaveSearchHandler() { this, ) { _, bundle -> val savedSearchName = - bundle.getString(ARG_SEARCH_QUERY_NAME) + bundle.getString(RESULT_SEARCH_QUERY_NAME) if (savedSearchName.isNullOrEmpty()) { showSnackbar( CommonString.card_browser_list_my_searches_new_search_error_empty_name, @@ -105,7 +107,7 @@ fun CardBrowser.registerSaveSearchHandler() { val toSave = SavedSearch( name = savedSearchName, - query = bundle.getString(ARG_SEARCH_QUERY) ?: return@setFragmentResultListener, + query = bundle.getString(RESULT_SEARCH_QUERY) ?: return@setFragmentResultListener, ) launchCatchingTask { @@ -125,7 +127,7 @@ fun Fragment.registerSaveSearchHandler(onSaveSearch: (SavedSearch) -> Unit) { this, ) { _, bundle -> val savedSearchName = - bundle.getString(ARG_SEARCH_QUERY_NAME) + bundle.getString(RESULT_SEARCH_QUERY_NAME) if (savedSearchName.isNullOrEmpty()) { showSnackbar( CommonString.card_browser_list_my_searches_new_search_error_empty_name, @@ -137,7 +139,7 @@ fun Fragment.registerSaveSearchHandler(onSaveSearch: (SavedSearch) -> Unit) { val savedSearch = SavedSearch( name = savedSearchName, - query = bundle.getString(ARG_SEARCH_QUERY) ?: return@setFragmentResultListener, + query = bundle.getString(RESULT_SEARCH_QUERY) ?: return@setFragmentResultListener, ) onSaveSearch(savedSearch) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/SavedBrowserSearchesDialogFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/SavedBrowserSearchesDialogFragment.kt index bb007d5974b2..0883f6903375 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/SavedBrowserSearchesDialogFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/SavedBrowserSearchesDialogFragment.kt @@ -16,7 +16,7 @@ import com.ichi2.anki.browser.search.SavedSearches import com.ichi2.anki.browser.search.toMap import com.ichi2.anki.compat.CompatHelper.Companion.getSerializableCompat import com.ichi2.anki.databinding.ItemSavedSearchBinding -import com.ichi2.anki.dialogs.SavedBrowserSearchesDialogFragment.Companion.ARG_SAVED_SEARCH +import com.ichi2.anki.dialogs.SavedBrowserSearchesDialogFragment.Companion.RESULT_SAVED_SEARCH import com.ichi2.anki.dialogs.SavedBrowserSearchesDialogFragment.Companion.TYPE_SEARCH_REMOVED import com.ichi2.anki.dialogs.SavedBrowserSearchesDialogFragment.Companion.TYPE_SEARCH_SELECTED import com.ichi2.anki.launchCatchingTask @@ -51,8 +51,8 @@ class SavedBrowserSearchesDialogFragment : AnalyticsDialogFragment() { parentFragmentManager.setFragmentResult( REQUEST_SAVED_SEARCH_ACTION, Bundle().apply { - putInt(ARG_TYPE, TYPE_SEARCH_SELECTED) - putString(ARG_SAVED_SEARCH, searchName) + putInt(RESULT_SAVED_SEARCH_ACTION, TYPE_SEARCH_SELECTED) + putString(RESULT_SAVED_SEARCH, searchName) }, ) dismiss() @@ -77,8 +77,8 @@ class SavedBrowserSearchesDialogFragment : AnalyticsDialogFragment() { parentFragmentManager.setFragmentResult( REQUEST_SAVED_SEARCH_ACTION, Bundle().apply { - putInt(ARG_TYPE, TYPE_SEARCH_REMOVED) - putString(ARG_SAVED_SEARCH, searchName) + putInt(RESULT_SAVED_SEARCH_ACTION, TYPE_SEARCH_REMOVED) + putString(RESULT_SAVED_SEARCH, searchName) }, ) dialog?.dismiss() // Dismiss the root dialog @@ -127,14 +127,14 @@ class SavedBrowserSearchesDialogFragment : AnalyticsDialogFragment() { /** * The user given name for the saved search that can be used as an identifier. */ - const val ARG_SAVED_SEARCH = "arg_saved_search" + const val RESULT_SAVED_SEARCH = "arg_saved_search" /** * [Int] number identifying the type of action for the saved search specified by - * [ARG_SAVED_SEARCH]. Currently there are just two possible values [TYPE_SEARCH_SELECTED] + * [RESULT_SAVED_SEARCH]. Currently there are just two possible values [TYPE_SEARCH_SELECTED] * and [TYPE_SEARCH_REMOVED]. */ - const val ARG_TYPE = "arg_type" + const val RESULT_SAVED_SEARCH_ACTION = "arg_type" private const val ARG_SAVED_FILTERS = "arg_saved_filters" fun newInstance(savedFilters: List): SavedBrowserSearchesDialogFragment = @@ -156,8 +156,8 @@ fun CardBrowser.registerSavedSearchActionHandler(action: (Int, String?) -> Unit) SavedBrowserSearchesDialogFragment.REQUEST_SAVED_SEARCH_ACTION, this, ) { _, bundle -> - val type = bundle.getInt(SavedBrowserSearchesDialogFragment.ARG_TYPE) - val searchName = bundle.getString(ARG_SAVED_SEARCH) + val type = bundle.getInt(SavedBrowserSearchesDialogFragment.RESULT_SAVED_SEARCH_ACTION) + val searchName = bundle.getString(RESULT_SAVED_SEARCH) Timber.d("On user saved search selection named: %s", searchName) action(type, searchName) } @@ -172,8 +172,8 @@ fun Fragment.registerSavedSearchActionHandler(action: (ManageSavedSearchAction) SavedBrowserSearchesDialogFragment.REQUEST_SAVED_SEARCH_ACTION, this, ) { _, bundle -> - val type = bundle.getInt(SavedBrowserSearchesDialogFragment.ARG_TYPE) - val searchName = bundle.getString(SavedBrowserSearchesDialogFragment.ARG_SAVED_SEARCH) ?: return@setFragmentResultListener + val type = bundle.getInt(SavedBrowserSearchesDialogFragment.RESULT_SAVED_SEARCH_ACTION) + val searchName = bundle.getString(SavedBrowserSearchesDialogFragment.RESULT_SAVED_SEARCH) ?: return@setFragmentResultListener Timber.d("On user saved search selection named: %s", searchName) launchCatchingTask { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/CustomStudyDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/CustomStudyDialog.kt index 9484aec6adb9..abd0fe502d97 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/CustomStudyDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/CustomStudyDialog.kt @@ -55,8 +55,8 @@ import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog.ContextMenuOption.ST import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog.ContextMenuOption.STUDY_TAGS import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog.CustomStudyDefaults.Companion.toDomainModel import com.ichi2.anki.dialogs.tags.TagsDialog -import com.ichi2.anki.dialogs.tags.TagsDialogListener.Companion.ON_SELECTED_TAGS__SELECTED_TAGS import com.ichi2.anki.dialogs.tags.TagsDialogListener.Companion.REQUEST_SELECT_TAGS +import com.ichi2.anki.dialogs.tags.TagsDialogListener.Companion.RESULT_SELECTED_TAGS import com.ichi2.anki.launchCatchingTask import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.observability.undoableOp @@ -138,7 +138,7 @@ class CustomStudyDialog : AnalyticsDialogFragment() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) parentFragmentManager.setFragmentResultListener(REQUEST_SELECT_TAGS, this) { _, bundle -> - val tagsToInclude = bundle.getStringArrayList(ON_SELECTED_TAGS__SELECTED_TAGS) ?: emptyList() + val tagsToInclude = bundle.getStringArrayList(RESULT_SELECTED_TAGS) ?: emptyList() val option = selectedSubDialog ?: return@setFragmentResultListener val selectedCardStateIndex = viewModel.selectedCardStateIndex if (selectedCardStateIndex == AdapterView.INVALID_POSITION) return@setFragmentResultListener @@ -539,7 +539,10 @@ class CustomStudyDialog : AnalyticsDialogFragment() { STUDY_FORGOT, STUDY_AHEAD, STUDY_PREVIEW, STUDY_TAGS -> CustomStudyAction.CUSTOM_STUDY_SESSION } - setFragmentResult(CustomStudyAction.REQUEST_KEY, Bundle().apply { putInt(CustomStudyAction.BUNDLE_KEY, action.ordinal) }) + setFragmentResult( + CustomStudyAction.REQUEST_KEY, + Bundle().apply { putInt(CustomStudyAction.RESULT_CUSTOM_STUDY_ACTION, action.ordinal) }, + ) // save the default values (not in upstream) when (contextMenuOption) { @@ -640,11 +643,11 @@ class CustomStudyDialog : AnalyticsDialogFragment() { companion object { const val REQUEST_KEY = "CustomStudyDialog" - const val BUNDLE_KEY = "action" + const val RESULT_CUSTOM_STUDY_ACTION = "action" /** Extracts a [CustomStudyAction] from a [Bundle] */ fun fromBundle(bundle: Bundle): CustomStudyAction = - bundle.getInt(CustomStudyAction.BUNDLE_KEY).let { actionOrdinal -> + bundle.getInt(CustomStudyAction.RESULT_CUSTOM_STUDY_ACTION).let { actionOrdinal -> entries.first { it.ordinal == actionOrdinal } } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/TagLimitFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/TagLimitFragment.kt index 3260ebd6f8e8..228b73280737 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/TagLimitFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/customstudy/TagLimitFragment.kt @@ -98,8 +98,8 @@ class TagLimitFragment : DialogFragment() { setFragmentResult( REQUEST_CUSTOM_STUDY_TAGS, Bundle().apply { - putStringArrayList(KEY_INCLUDED_TAGS, ArrayList(tagsToInclude)) - putStringArrayList(KEY_EXCLUDED_TAGS, ArrayList(tagsToExclude)) + putStringArrayList(RESULT_INCLUDED_TAGS, ArrayList(tagsToInclude)) + putStringArrayList(RESULT_EXCLUDED_TAGS, ArrayList(tagsToExclude)) }, ) dismiss() @@ -133,8 +133,8 @@ class TagLimitFragment : DialogFragment() { companion object { const val TAG = "TagLimitFragment" const val REQUEST_CUSTOM_STUDY_TAGS = "request_custom_study_tags" - const val KEY_INCLUDED_TAGS = "key_included_tags" - const val KEY_EXCLUDED_TAGS = "key_excluded_tags" + const val RESULT_INCLUDED_TAGS = "key_included_tags" + const val RESULT_EXCLUDED_TAGS = "key_excluded_tags" private const val ARG_DECK_ID = "arg_deck_id" fun newInstance(deckId: DeckId) = diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/help/HelpDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/help/HelpDialog.kt index 8e0ad1126150..a041873b652d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/help/HelpDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/help/HelpDialog.kt @@ -82,7 +82,7 @@ class HelpDialog : AnalyticsDialogFragment() { val selectedItem = BundleCompat.getParcelable( from, - ARG_SELECTED_MENU_ITEM, + RESULT_SELECTED_MENU_ITEM, HelpItem::class.java, ) ?: return when (selectedItem.action) { @@ -173,7 +173,7 @@ internal fun Fragment.requireArgsHelpEntries(): Array { internal const val ARG_MENU_ITEMS = "arg_menu_items" internal const val REQUEST_HELP_PAGE = "request_help_page" -internal const val ARG_SELECTED_MENU_ITEM = " selected_menu_item" +internal const val RESULT_SELECTED_MENU_ITEM = " selected_menu_item" /** * This fragment is responsible for showing a list of menu items in the application's [HelpDialog]. @@ -196,7 +196,7 @@ class HelpPageFragment : Fragment(R.layout.fragment_help_page) { Analytics.send(LinkClicked(menuItem.analyticsId)) parentFragmentManager.setFragmentResult( REQUEST_HELP_PAGE, - Bundle().apply { putParcelable(ARG_SELECTED_MENU_ITEM, menuItem) }, + Bundle().apply { putParcelable(RESULT_SELECTED_MENU_ITEM, menuItem) }, ) } binding.pageContent.addView(this) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialogListener.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialogListener.kt index d8b9d1a07cc7..3c2c2efd8e5c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialogListener.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialogListener.kt @@ -34,10 +34,10 @@ interface TagsDialogListener { this, ) { _: String?, bundle: Bundle -> val selectedTags: List = - bundle.getStringArrayList(ON_SELECTED_TAGS__SELECTED_TAGS)!! + bundle.getStringArrayList(RESULT_SELECTED_TAGS)!! val indeterminateTags: List = - bundle.getStringArrayList(ON_SELECTED_TAGS__INDETERMINATE_TAGS)!! - val option = bundle.getSerializableCompat(ON_SELECTED_TAGS__OPTION)!! + bundle.getStringArrayList(RESULT_INDETERMINATE_TAGS)!! + val option = bundle.getSerializableCompat(RESULT_CARD_STATE_FILTER)!! onSelectedTags(selectedTags, indeterminateTags, option) } } @@ -52,17 +52,17 @@ interface TagsDialogListener { ) { val bundle = Bundle().apply { - putStringArrayList(ON_SELECTED_TAGS__SELECTED_TAGS, ArrayList(selectedTags)) - putStringArrayList(ON_SELECTED_TAGS__INDETERMINATE_TAGS, ArrayList(indeterminateTags)) - putSerializable(ON_SELECTED_TAGS__OPTION, stateFilter) + putStringArrayList(RESULT_SELECTED_TAGS, ArrayList(selectedTags)) + putStringArrayList(RESULT_INDETERMINATE_TAGS, ArrayList(indeterminateTags)) + putSerializable(RESULT_CARD_STATE_FILTER, stateFilter) } fragmentManager.setFragmentResult(REQUEST_SELECT_TAGS, bundle) } } const val REQUEST_SELECT_TAGS = "ON_SELECTED_TAGS_KEY" - const val ON_SELECTED_TAGS__SELECTED_TAGS = "SELECTED_TAGS" - const val ON_SELECTED_TAGS__INDETERMINATE_TAGS = "INDETERMINATE_TAGS" - const val ON_SELECTED_TAGS__OPTION = "OPTION" + const val RESULT_SELECTED_TAGS = "SELECTED_TAGS" + const val RESULT_INDETERMINATE_TAGS = "INDETERMINATE_TAGS" + const val RESULT_CARD_STATE_FILTER = "OPTION" } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/ForgetCardsDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/ForgetCardsDialog.kt index 4817c673c5d3..c7a8de1077e3 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/ForgetCardsDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/scheduling/ForgetCardsDialog.kt @@ -114,8 +114,8 @@ class ForgetCardsDialog : AnalyticsDialogFragment() { parentFragmentManager.setFragmentResult( REQUEST_KEY_FORGET, Bundle().apply { - putBoolean(ARG_RESTORE_ORIGINAL, restoreOriginalPositionIfPossible) - putBoolean(ARG_RESET_REPETITION, resetRepetitionAndLapseCounts) + putBoolean(RESULT_RESTORE_ORIGINAL, restoreOriginalPositionIfPossible) + putBoolean(RESULT_RESET_REPETITION, resetRepetitionAndLapseCounts) }, ) } @@ -132,8 +132,11 @@ class ForgetCardsDialog : AnalyticsDialogFragment() { companion object { const val REQUEST_KEY_FORGET = "request_key_forget_cards" - const val ARG_RESTORE_ORIGINAL = "key_restore_original" - const val ARG_RESET_REPETITION = "key_reset_repetition" + const val RESULT_RESTORE_ORIGINAL = "key_restore_original" + const val RESULT_RESET_REPETITION = "key_reset_repetition" + + private const val ARG_RESTORE_ORIGINAL = "key_restore_original" + private const val ARG_RESET_REPETITION = "key_reset_repetition" } } @@ -147,8 +150,8 @@ internal fun FragmentActivity.registerOnForgetHandler(cardsIdsProducer: suspend setFragmentResultListener(ForgetCardsDialog.REQUEST_KEY_FORGET) { _, bundle -> forgetCards( cardsIdsProducer = cardsIdsProducer, - restoreOriginalPositionIfPossible = bundle.getBoolean(ForgetCardsDialog.ARG_RESTORE_ORIGINAL), - resetRepetitionAndLapseCounts = bundle.getBoolean(ForgetCardsDialog.ARG_RESET_REPETITION), + restoreOriginalPositionIfPossible = bundle.getBoolean(ForgetCardsDialog.RESULT_RESTORE_ORIGINAL), + resetRepetitionAndLapseCounts = bundle.getBoolean(ForgetCardsDialog.RESULT_RESET_REPETITION), ) } } @@ -163,8 +166,8 @@ internal fun Fragment.registerOnForgetHandler(cardsIdsProducer: suspend () -> Li setFragmentResultListener(ForgetCardsDialog.REQUEST_KEY_FORGET) { _, bundle -> activity?.forgetCards( cardsIdsProducer = cardsIdsProducer, - restoreOriginalPositionIfPossible = bundle.getBoolean(ForgetCardsDialog.ARG_RESTORE_ORIGINAL), - resetRepetitionAndLapseCounts = bundle.getBoolean(ForgetCardsDialog.ARG_RESET_REPETITION), + restoreOriginalPositionIfPossible = bundle.getBoolean(ForgetCardsDialog.RESULT_RESTORE_ORIGINAL), + resetRepetitionAndLapseCounts = bundle.getBoolean(ForgetCardsDialog.RESULT_RESET_REPETITION), ) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsActivity.kt index d03a787b4b44..595d1c2c66ea 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsActivity.kt @@ -17,8 +17,8 @@ import com.ichi2.anki.StoragePermissionSet import com.ichi2.anki.common.utils.android.showThemedToast import com.ichi2.anki.common.utils.ext.getParcelableExtraCompat import com.ichi2.anki.databinding.ActivityPermissionsBinding -import com.ichi2.anki.ui.windows.permissions.PermissionsFragment.Companion.HAS_ALL_PERMISSIONS_KEY import com.ichi2.anki.ui.windows.permissions.PermissionsFragment.Companion.REQUEST_PERMISSIONS +import com.ichi2.anki.ui.windows.permissions.PermissionsFragment.Companion.RESULT_HAS_ALL_PERMISSIONS import com.ichi2.anki.utils.ext.setFragmentResultListener import dev.androidbroadcast.vbpd.viewBinding import timber.log.Timber @@ -60,7 +60,7 @@ class PermissionsActivity : AnkiActivity(R.layout.activity_permissions) { } val permissionsFragment = permissionSet.permissionsFragment.getDeclaredConstructor().newInstance() setFragmentResultListener(REQUEST_PERMISSIONS) { _, bundle -> - val hasAllPermissions = bundle.getBoolean(HAS_ALL_PERMISSIONS_KEY) + val hasAllPermissions = bundle.getBoolean(RESULT_HAS_ALL_PERMISSIONS) setContinueButtonEnabled(hasAllPermissions) } setFragmentResultListener(REQUEST_PERMISSIONS_GRANTED) { _, _ -> decideStorageAndFinish() } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsFragment.kt index 89cbac31b83c..899b59d38a33 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/windows/permissions/PermissionsFragment.kt @@ -62,7 +62,7 @@ abstract class PermissionsFragment( permissionsItems.forEach { it.updateSwitchCheckedStatus() } setFragmentResult( REQUEST_PERMISSIONS, - Bundle().apply { putBoolean(HAS_ALL_PERMISSIONS_KEY, hasAllPermissions()) }, + Bundle().apply { putBoolean(RESULT_HAS_ALL_PERMISSIONS, hasAllPermissions()) }, ) } @@ -136,6 +136,6 @@ abstract class PermissionsFragment( companion object { const val REQUEST_PERMISSIONS = "PERMISSION_FRAGMENT_RESULT" - const val HAS_ALL_PERMISSIONS_KEY = "HAS_ALL_PERMISSIONS" + const val RESULT_HAS_ALL_PERMISSIONS = "HAS_ALL_PERMISSIONS" } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt index f58abe595ff7..17d325e6813f 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt @@ -57,13 +57,13 @@ import com.ichi2.anki.browser.CardBrowserViewModelTest import com.ichi2.anki.browser.CardOrNoteId import com.ichi2.anki.browser.FindAndReplaceDialogFragment import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.ALL_FIELDS_AS_FIELD -import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.ARG_FIELD -import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.ARG_MATCH_CASE -import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.ARG_ONLY_SELECTED_NOTES -import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.ARG_REGEX -import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.ARG_REPLACEMENT -import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.ARG_SEARCH import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.REQUEST_FIND_AND_REPLACE +import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.RESULT_FIELD +import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.RESULT_MATCH_CASE +import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.RESULT_ONLY_SELECTED_NOTES +import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.RESULT_REGEX +import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.RESULT_REPLACEMENT +import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.RESULT_SEARCH import com.ichi2.anki.browser.FindAndReplaceDialogFragment.Companion.TAGS_AS_FIELD import com.ichi2.anki.browser.IdsFile import com.ichi2.anki.browser.column1 @@ -832,8 +832,8 @@ class CardBrowserTest : RobolectricTest() { b.supportFragmentManager.setFragmentResult( ForgetCardsDialog.REQUEST_KEY_FORGET, Bundle().apply { - putBoolean(ForgetCardsDialog.ARG_RESTORE_ORIGINAL, true) - putBoolean(ForgetCardsDialog.ARG_RESET_REPETITION, false) + putBoolean(ForgetCardsDialog.RESULT_RESTORE_ORIGINAL, true) + putBoolean(ForgetCardsDialog.RESULT_RESET_REPETITION, false) }, ) @@ -2112,13 +2112,13 @@ class CardBrowserTest : RobolectricTest() { supportFragmentManager.setFragmentResult( REQUEST_FIND_AND_REPLACE, Bundle().apply { - putString(ARG_SEARCH, search) - putString(ARG_REPLACEMENT, replacement) - putString(ARG_FIELD, field) - putBoolean(ARG_ONLY_SELECTED_NOTES, onlyInSelectedNotes) + putString(RESULT_SEARCH, search) + putString(RESULT_REPLACEMENT, replacement) + putString(RESULT_FIELD, field) + putBoolean(RESULT_ONLY_SELECTED_NOTES, onlyInSelectedNotes) // "Ignore case" checkbox text => when it's checked we pass false to the backend - putBoolean(ARG_MATCH_CASE, matchCase) - putBoolean(ARG_REGEX, regex) + putBoolean(RESULT_MATCH_CASE, matchCase) + putBoolean(RESULT_REGEX, regex) }, ) } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CardTemplateEditorTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CardTemplateEditorTest.kt index ab14fc41ff5d..8eac914bfaa4 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CardTemplateEditorTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CardTemplateEditorTest.kt @@ -1069,7 +1069,7 @@ class CardTemplateEditorTest : RobolectricTest() { advanceRobolectricLooper() val resultBundle = Bundle() - resultBundle.putString(InsertFieldDialog.KEY_INSERTED_FIELD, expectedFieldText) + resultBundle.putString(InsertFieldDialog.RESULT_INSERTED_FIELD, expectedFieldText) testEditor.supportFragmentManager.setFragmentResult(firstFragmentAgain.insertFieldRequestKey, resultBundle) advanceRobolectricLooper() diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt index ee059f4d911e..3a63c8cd4350 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt @@ -1663,7 +1663,7 @@ class DeckPickerTest : RobolectricTest() { browser.childFragmentManager.setFragmentResult( DeckSelectionDialog.REQUEST_SELECT_DECK, Bundle().apply { - putParcelable(DeckSelectionDialog.ARG_SELECTED_DECK, SelectableDeck.AllDecks) + putParcelable(DeckSelectionDialog.RESULT_SELECTED_DECK, SelectableDeck.AllDecks) }, ) } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/CustomStudyDialogTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/CustomStudyDialogTest.kt index b3c98d782f30..d58d9c52a8dd 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/CustomStudyDialogTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/CustomStudyDialogTest.kt @@ -27,8 +27,8 @@ import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog.ContextMenuOption import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog.CustomStudyDefaults.Companion.toDomainModel import com.ichi2.anki.dialogs.customstudy.CustomStudyViewModel -import com.ichi2.anki.dialogs.tags.TagsDialogListener.Companion.ON_SELECTED_TAGS__SELECTED_TAGS import com.ichi2.anki.dialogs.tags.TagsDialogListener.Companion.REQUEST_SELECT_TAGS +import com.ichi2.anki.dialogs.tags.TagsDialogListener.Companion.RESULT_SELECTED_TAGS import com.ichi2.anki.dialogs.utils.performPositiveClick import com.ichi2.anki.libanki.CardType import com.ichi2.anki.libanki.Collection @@ -225,7 +225,7 @@ class CustomStudyDialogTest : RobolectricTest() { // simulate tag selection studyDialog.parentFragmentManager.setFragmentResult( REQUEST_SELECT_TAGS, - Bundle().apply { putStringArrayList(ON_SELECTED_TAGS__SELECTED_TAGS, selectedTags) }, + Bundle().apply { putStringArrayList(RESULT_SELECTED_TAGS, selectedTags) }, ) val customStudyDeck = col.decks.customStudySession assertNotNull(customStudyDeck) From e07cf6f58e2c594c7582dfd3035451da0a427505 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:37:52 +0100 Subject: [PATCH 029/163] lint: enforce `RESULT_` for fragment result key names Part of 22410 Assisted-by: GPT-6-Astra default --- .../java/com/ichi2/anki/lint/IssueRegistry.kt | 2 + .../rules/FragmentResultKeyNamingDetector.kt | 205 +++++++++++++++++ .../ichi2/anki/lint/utils/ext/UExpression.kt | 4 +- .../FragmentResultKeyNamingDetectorTest.kt | 214 ++++++++++++++++++ 4 files changed, 422 insertions(+), 3 deletions(-) create mode 100644 lint-rules/src/main/java/com/ichi2/anki/lint/rules/FragmentResultKeyNamingDetector.kt create mode 100644 lint-rules/src/test/java/com/ichi2/anki/lint/rules/FragmentResultKeyNamingDetectorTest.kt diff --git a/lint-rules/src/main/java/com/ichi2/anki/lint/IssueRegistry.kt b/lint-rules/src/main/java/com/ichi2/anki/lint/IssueRegistry.kt index d9cdd0f682d5..3c798d3b7927 100644 --- a/lint-rules/src/main/java/com/ichi2/anki/lint/IssueRegistry.kt +++ b/lint-rules/src/main/java/com/ichi2/anki/lint/IssueRegistry.kt @@ -17,6 +17,7 @@ import com.ichi2.anki.lint.rules.DirectSystemTimeInstantiation import com.ichi2.anki.lint.rules.DirectToastMakeTextUsage import com.ichi2.anki.lint.rules.DuplicateCrowdInStrings import com.ichi2.anki.lint.rules.FragmentRequestKeyNamingDetector +import com.ichi2.anki.lint.rules.FragmentResultKeyNamingDetector import com.ichi2.anki.lint.rules.HardcodedPreferenceKey import com.ichi2.anki.lint.rules.InvalidStringFormatDetector import com.ichi2.anki.lint.rules.JUnitNullAssertionDetector @@ -47,6 +48,7 @@ class IssueRegistry : IssueRegistry() { DirectToastMakeTextUsage.ISSUE, DuplicateCrowdInStrings.ISSUE, FragmentRequestKeyNamingDetector.ISSUE, + FragmentResultKeyNamingDetector.ISSUE, HardcodedPreferenceKey.ISSUE, JUnitNullAssertionDetector.ISSUE, LicenseHeaderExists.ISSUE, diff --git a/lint-rules/src/main/java/com/ichi2/anki/lint/rules/FragmentResultKeyNamingDetector.kt b/lint-rules/src/main/java/com/ichi2/anki/lint/rules/FragmentResultKeyNamingDetector.kt new file mode 100644 index 000000000000..44971a726c18 --- /dev/null +++ b/lint-rules/src/main/java/com/ichi2/anki/lint/rules/FragmentResultKeyNamingDetector.kt @@ -0,0 +1,205 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.lint.rules + +import com.android.tools.lint.checks.DataFlowAnalyzer +import com.android.tools.lint.detector.api.ConstantEvaluator +import com.android.tools.lint.detector.api.Detector +import com.android.tools.lint.detector.api.Implementation +import com.android.tools.lint.detector.api.Issue +import com.android.tools.lint.detector.api.JavaContext +import com.android.tools.lint.detector.api.Scope +import com.android.tools.lint.detector.api.SourceCodeScanner +import com.android.tools.lint.detector.api.isReturningContext +import com.ichi2.anki.lint.utils.Constants +import com.ichi2.anki.lint.utils.ext.resolveToField +import com.intellij.psi.PsiMethod +import com.intellij.psi.PsiModifier +import org.jetbrains.uast.UCallExpression +import org.jetbrains.uast.UElement +import org.jetbrains.uast.UExpression +import org.jetbrains.uast.ULambdaExpression +import org.jetbrains.uast.UMethod +import org.jetbrains.uast.getParentOfType +import org.jetbrains.uast.skipParenthesizedExprDown + +/** + * Requires `RESULT_` names for constant payload keys in Fragment Result API bundles. + * + * @see FragmentRequestKeyNamingDetector for `REQUEST_` + */ +class FragmentResultKeyNamingDetector : + Detector(), + SourceCodeScanner { + /** Starts tracking explicit `Bundle(...)` constructions as potential result payloads. */ + override fun getApplicableConstructorTypes() = listOf("android.os.Bundle") + + /** + * Checks a constructed bundle only if it flows into `setFragmentResult` in this method. + * + * ```kotlin + * val result = Bundle().apply { putLong(SELECTED_DECK, 1L) } + * parentFragmentManager.setFragmentResult(REQUEST_SELECT_DECK, result) + * ``` + */ + override fun visitConstructor( + context: JavaContext, + node: UCallExpression, + constructor: PsiMethod, + ) { + val scope = node.getParentOfType() ?: return + checkBundle(context, scope, node, isResult = false) + } + + /** Selects listener registrations whose callback receives a result bundle. */ + override fun getApplicableMethodNames() = listOf("setFragmentResultListener") + + /** + * Tracks the second parameter of an inline result listener. + * + * ```kotlin + * setFragmentResultListener(REQUEST_SELECT_DECK) { _, result -> + * val deckId = result.getLong(SELECTED_DECK) + * } + * ``` + */ + override fun visitMethodCall( + context: JavaContext, + node: UCallExpression, + method: PsiMethod, + ) { + if (!method.isFragmentResultApi()) return + val listenerIndex = + method.parameterList.parameters.indexOfLast { + it.type.canonicalText.contains("FragmentResultListener") || it.name == "listener" + } + if (listenerIndex < 0) return + val listener = node.getArgumentForParameter(listenerIndex)?.skipParenthesizedExprDown() as? ULambdaExpression ?: return + val bundle = listener.valueParameters.getOrNull(1) ?: return + checkBundle(context, listener.body, bundle, isResult = true) + } + + /** + * Collects key accesses on [bundle] and its aliases, reporting them only for result payloads. + * Tracking stays within [scope]; it does not follow helper bodies or unrelated argument/saved-state bundles. + */ + private fun checkBundle( + context: JavaContext, + scope: UElement, + bundle: UElement, + isResult: Boolean, + ) { + val keys = mutableListOf() + var sentAsResult = isResult + scope.accept( + object : DataFlowAnalyzer(listOf(bundle)) { + /** Follows scope functions such as `apply`, but treats `getBundle()` as a separate bundle. */ + override fun returnsSelf(call: UCallExpression) = isReturningContext(call) + + /** Collects string key arguments in bundle calls such as `result.putString(KEY, value)`. */ + override fun receiver(call: UCallExpression) { + val method = call.resolve() ?: return + if (!context.evaluator.isMemberInClass(method, "android.os.Bundle") && + !context.evaluator.isMemberInClass(method, "android.os.BaseBundle") + ) { + return + } + if (method.parameterList.parameters + .firstOrNull() + ?.type + ?.canonicalText != "java.lang.String" + ) { + return + } + call.getArgumentForParameter(0)?.let(keys::add) + } + + /** Tracks helper keys such as `BundleCompat.getParcelable(result, KEY, type)` and result sends. */ + override fun argument( + call: UCallExpression, + reference: UElement, + ) { + val method = call.resolve() ?: return + call.bundleHelperKey(method, reference)?.let(keys::add) + if (method.name != "setFragmentResult" || !method.isFragmentResultApi()) return + val index = method.parameterList.parameters.indexOfFirst { it.type.canonicalText == "android.os.Bundle" } + if (index >= 0 && call.getArgumentForParameter(index) == reference) sentAsResult = true + } + }, + ) + if (sentAsResult) keys.forEach { checkKey(context, it) } + } + + /** + * Extracts `KEY` from helper calls such as `BundleCompat.getParcelable(result, KEY, type)`. + * Only matches when the helper's bundle argument is the tracked [bundleReference]. + */ + private fun UCallExpression.bundleHelperKey( + method: PsiMethod, + bundleReference: UElement, + ): UExpression? { + if (method.containingClass?.qualifiedName !in BUNDLE_HELPERS) return null + + val parameters = method.parameterList.parameters + val bundleIndex = parameters.indexOfFirst { it.type.canonicalText == "android.os.Bundle" } + if (bundleIndex < 0 || getArgumentForParameter(bundleIndex) != bundleReference) return null + + val keyIndex = parameters.indexOfFirst { it.type.canonicalText == "java.lang.String" } + if (keyIndex < 0) return null + return getArgumentForParameter(keyIndex) + } + + /** Reports non-`RESULT_` fields used as Bundle result keys. */ + private fun checkKey( + context: JavaContext, + expression: UExpression, + ) { + val argument = expression.skipParenthesizedExprDown() + val field = argument.resolveToField() ?: return + if (!field.hasModifierProperty(PsiModifier.STATIC) || !field.hasModifierProperty(PsiModifier.FINAL)) return + if (field.name.startsWith("RESULT_")) return + if (ConstantEvaluator.evaluate(context, argument) !is String) return + context.report( + ISSUE, + argument, + context.getLocation(argument), + "Fragment Result API bundle key constants must start with RESULT_: ${field.name}", + ) + } + + /** Restricts matching to AndroidX Fragment APIs and AnkiDroid's FragmentActivity extensions. */ + private fun PsiMethod.isFragmentResultApi() = + containingClass?.qualifiedName in + setOf( + "androidx.fragment.app.FragmentManager", + "androidx.fragment.app.FragmentKt", + "com.ichi2.anki.utils.ext.FragmentResultKt", + ) + + companion object { + /** Helpers whose bundle and string arguments represent payload access, including Kotlin extensions. */ + private val BUNDLE_HELPERS = + setOf( + "androidx.core.os.BundleCompat", + "com.ichi2.anki.utils.ext.BundleUtilsKt", + "com.ichi2.anki.compat.CompatHelper.Companion", + "com.ichi2.anki.compat.CompatHelperKt", + ) + + /** Lint diagnostic for constant payload keys that do not follow the `RESULT_` naming convention. */ + val ISSUE = + Issue.create( + id = "FragmentResultKeyNaming", + briefDescription = "Fragment result bundle keys must start with RESULT_", + explanation = + """ + Fragment Result API bundle result keys should be named RESULT_, + for example RESULT_SELECTED_DECK. + """.trimIndent(), + category = Constants.ANKI_CODE_STYLE_CATEGORY, + priority = Constants.ANKI_CODE_STYLE_PRIORITY, + severity = Constants.ANKI_CODE_STYLE_SEVERITY, + implementation = Implementation(FragmentResultKeyNamingDetector::class.java, Scope.JAVA_FILE_SCOPE), + ) + } +} diff --git a/lint-rules/src/main/java/com/ichi2/anki/lint/utils/ext/UExpression.kt b/lint-rules/src/main/java/com/ichi2/anki/lint/utils/ext/UExpression.kt index a0e4c9240f83..a411c7422074 100644 --- a/lint-rules/src/main/java/com/ichi2/anki/lint/utils/ext/UExpression.kt +++ b/lint-rules/src/main/java/com/ichi2/anki/lint/utils/ext/UExpression.kt @@ -12,7 +12,7 @@ import org.jetbrains.uast.UReferenceExpression import org.jetbrains.uast.toUElementOfType /** - * Resolves a field reference, including a Kotlin property accessed through its getter. + * Resolves a Kotlin property to its backing field, including access through its getter. * * [UReferenceExpression.resolve] can return a getter method for an ordinary Kotlin `val`. * @@ -21,8 +21,6 @@ import org.jetbrains.uast.toUElementOfType internal fun UExpression.resolveToField(): PsiField? { val reference = (this as? UQualifiedReferenceExpression)?.selector ?: this val resolved = (reference as? UReferenceExpression)?.resolve() ?: return null - if (resolved is PsiField) return resolved - val property = resolved.unwrapped as? KtProperty ?: return null return property.toUElementOfType()?.javaPsi as? PsiField } diff --git a/lint-rules/src/test/java/com/ichi2/anki/lint/rules/FragmentResultKeyNamingDetectorTest.kt b/lint-rules/src/test/java/com/ichi2/anki/lint/rules/FragmentResultKeyNamingDetectorTest.kt new file mode 100644 index 000000000000..9ef56fe41730 --- /dev/null +++ b/lint-rules/src/test/java/com/ichi2/anki/lint/rules/FragmentResultKeyNamingDetectorTest.kt @@ -0,0 +1,214 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.lint.rules + +import com.android.tools.lint.checks.infrastructure.LintDetectorTest.kotlin +import com.android.tools.lint.checks.infrastructure.TestLintTask.lint +import org.junit.Test + +class FragmentResultKeyNamingDetectorTest { + private fun check(source: String) = + lint() + .allowMissingSdk() + .files(bundle, manager, fragment, extensions, activityExtensions, kotlin(source).indented()) + .issues(FragmentResultKeyNamingDetector.ISSUE) + .run() + + @Test + fun `reports payload keys in inline and local bundles`() { + check( + """ + package test + import android.os.Bundle + import androidx.fragment.app.* + const val BAD_KEY = "value" + fun send(manager: FragmentManager) { + manager.setFragmentResult("request", Bundle().apply { putString(BAD_KEY, "payload") }) + val result = Bundle() + result.putString(BAD_KEY, "payload") + val alias = result + manager.setFragmentResult("request", alias) + } + """, + ).expectErrorCount(2) + .expectContains("Fragment Result API bundle key constants must start with RESULT_: BAD_KEY") + } + + @Test + fun `reports ordinary public and private Kotlin val properties as well as const val`() { + check( + """ + package test + import android.os.Bundle + import androidx.fragment.app.* + val TOP_LEVEL = "top" + object Keys { val OBJECT_KEY = "object" } + class Screen : Fragment() { + companion object { + val PUBLIC_KEY = "public" + private val PRIVATE_KEY = "private" + const val CONST_KEY = "constant" + } + fun send() { + setFragmentResult(result = Bundle().apply { + putString(TOP_LEVEL, "value") + putString(Keys.OBJECT_KEY, "value") + putString(Screen.PUBLIC_KEY, "value") + putString(PRIVATE_KEY, "value") + putString(CONST_KEY, "value") + }, requestKey = "request") + } + } + """, + ).expectErrorCount(5) + } + + @Test + fun `checks manager fragment and activity listener payloads`() { + check( + """ + package test + import androidx.fragment.app.* + import com.ichi2.anki.utils.ext.setFragmentResultListener + const val BAD_KEY = "value" + class Screen : Fragment() { + fun listen(manager: FragmentManager, activity: FragmentActivity) { + manager.setFragmentResultListener("request", this) { _, result -> result.getString(BAD_KEY) } + setFragmentResultListener("request") { _, result -> + val alias = result + alias.getString(BAD_KEY) + } + activity.setFragmentResultListener("request") { _, result -> result.getString(BAD_KEY) } + } + } + """, + ).expectErrorCount(3) + } + + @Test + fun `ignores arguments saved state nested bundles values and unrelated APIs`() { + check( + """ + package test + import android.os.Bundle + import androidx.fragment.app.* + const val ARG_VALUE = "argument" + const val STATE_VALUE = "state" + const val REQUEST_SEND = "send" + const val RESULT_VALUE = "result" + class Other { fun setFragmentResult(key: String, bundle: Bundle) {} } + fun send(manager: FragmentManager, savedState: Bundle) { + savedState.putString(STATE_VALUE, "value") + val args = Bundle().apply { putString(ARG_VALUE, "value") } + Other().setFragmentResult(REQUEST_SEND, args) + manager.setFragmentResult(REQUEST_SEND, Bundle().apply { + putString(RESULT_VALUE, ARG_VALUE) + args.putString(ARG_VALUE, "value") + putBundle(RESULT_VALUE, args) + }) + manager.setFragmentResultListener(REQUEST_SEND, Any()) { _, result -> + result.getString(RESULT_VALUE) + result.getBundle(RESULT_VALUE)?.getString(ARG_VALUE) + args.getString(ARG_VALUE) + } + } + """, + ).expectClean() + } + + @Test + fun `allows runtime keys and compliant properties`() { + check( + """ + package test + import android.os.Bundle + import androidx.fragment.app.* + val RESULT_VALUE = "value" + val dynamicKey = "key_" + System.nanoTime() + var mutableKey = "mutable" + val computedKey get() = "computed" + val delegatedKey by lazy { "delegated" } + fun send(manager: FragmentManager, forwardedKey: String) { + manager.setFragmentResult("request", Bundle().apply { + putString(RESULT_VALUE, "value") + putString(dynamicKey, "value") + putString(mutableKey, "value") + putString(computedKey, "value") + putString(delegatedKey, "value") + putString(forwardedKey, "value") + }) + } + """, + ).expectClean() + } + + @Test + fun `honors suppression`() { + check( + """ + package test + import android.os.Bundle + import androidx.fragment.app.FragmentManager + const val BAD_KEY = "key" + @Suppress("FragmentResultKeyNaming") + fun send(manager: FragmentManager) { + manager.setFragmentResult("request", Bundle().apply { putString(BAD_KEY, "value") }) + } + """, + ).expectClean() + } + + companion object { + private val bundle = + kotlin( + """ + package android.os + class Bundle { + fun putString(key: String, value: String) {} + fun putBundle(key: String, value: Bundle) {} + fun getString(key: String): String? = null + fun getBundle(key: String): Bundle? = null + } + """, + ).indented() + private val manager = + kotlin( + """ + package androidx.fragment.app + import android.os.Bundle + class FragmentManager { + fun setFragmentResult(requestKey: String, result: Bundle) {} + fun setFragmentResultListener(requestKey: String, owner: Any, listener: (String, Bundle) -> Unit) {} + } + """, + ).indented() + private val fragment = + kotlin( + """ + package androidx.fragment.app + open class Fragment + class FragmentActivity + """, + ).indented() + private val extensions = + kotlin( + """ + @file:JvmName("FragmentKt") + package androidx.fragment.app + import android.os.Bundle + fun Fragment.setFragmentResult(requestKey: String, result: Bundle) {} + fun Fragment.setFragmentResultListener(requestKey: String, listener: (String, Bundle) -> Unit) {} + """, + ).indented() + private val activityExtensions = + kotlin( + """ + @file:JvmName("FragmentResultKt") + package com.ichi2.anki.utils.ext + import android.os.Bundle + import androidx.fragment.app.FragmentActivity + fun FragmentActivity.setFragmentResultListener(requestKey: String, listener: (String, Bundle) -> Unit) {} + """, + ).indented() + } +} From d64360cf52c6209bdc6715f3b3f524de17d0d05b Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:37:52 +0100 Subject: [PATCH 030/163] refactor(test): convert java to kotlin Assisted-by: GPT-6-Astra default --- .../FragmentRequestKeyNamingDetectorTest.kt | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/lint-rules/src/test/java/com/ichi2/anki/lint/rules/FragmentRequestKeyNamingDetectorTest.kt b/lint-rules/src/test/java/com/ichi2/anki/lint/rules/FragmentRequestKeyNamingDetectorTest.kt index 23db1d60985c..2aaf43b2f9e1 100644 --- a/lint-rules/src/test/java/com/ichi2/anki/lint/rules/FragmentRequestKeyNamingDetectorTest.kt +++ b/lint-rules/src/test/java/com/ichi2/anki/lint/rules/FragmentRequestKeyNamingDetectorTest.kt @@ -2,7 +2,6 @@ package com.ichi2.anki.lint.rules -import com.android.tools.lint.checks.infrastructure.LintDetectorTest.java import com.android.tools.lint.checks.infrastructure.LintDetectorTest.kotlin import com.android.tools.lint.checks.infrastructure.TestLintTask.lint import org.junit.Test @@ -164,19 +163,31 @@ class FragmentRequestKeyNamingDetectorTest { companion object { private val manager = - java( + kotlin( """ - package androidx.fragment.app; - public class FragmentManager { - public void setFragmentResult(String requestKey, String result) {} - public void setFragmentResultListener(String requestKey, Runnable listener) {} - public void clearFragmentResult(String requestKey) {} - public void clearFragmentResultListener(String requestKey) {} + package androidx.fragment.app + class FragmentManager { + fun setFragmentResult(requestKey: String, result: String) {} + fun setFragmentResultListener(requestKey: String, listener: () -> Unit) {} + fun clearFragmentResult(requestKey: String) {} + fun clearFragmentResultListener(requestKey: String) {} } """, ).indented() - private val fragment = java("package androidx.fragment.app; public class Fragment {}") - private val activity = java("package androidx.fragment.app; public class FragmentActivity {}") + private val fragment = + kotlin( + """ + package androidx.fragment.app + open class Fragment + """, + ).indented() + private val activity = + kotlin( + """ + package androidx.fragment.app + class FragmentActivity + """, + ).indented() private val fragmentExtensions = kotlin( """ From 507e7582d17bb6b6be5854be5a2fcf090f2d0467 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:27:45 +0100 Subject: [PATCH 031/163] refactor(sync): move fullUploadOrDownload Prep for a `FullSync` class Prep for 22313 Assisted-by: GPT-6-Astra xhigh --- .../src/main/kotlin/com/ichi2/anki/sync/FullSync.kt | 11 +++++++++++ .../src/main/kotlin/com/ichi2/anki/sync/SyncAuth.kt | 6 ------ 2 files changed, 11 insertions(+), 6 deletions(-) create mode 100644 anki-common/src/main/kotlin/com/ichi2/anki/sync/FullSync.kt diff --git a/anki-common/src/main/kotlin/com/ichi2/anki/sync/FullSync.kt b/anki-common/src/main/kotlin/com/ichi2/anki/sync/FullSync.kt new file mode 100644 index 000000000000..fbcf16b9a1d4 --- /dev/null +++ b/anki-common/src/main/kotlin/com/ichi2/anki/sync/FullSync.kt @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.sync + +import com.ichi2.anki.libanki.Collection + +fun Collection.fullUploadOrDownload( + auth: SyncAuth, + serverUsn: Int?, + upload: Boolean, +) = fullUploadOrDownload(auth.toProto(), serverUsn, upload) diff --git a/anki-common/src/main/kotlin/com/ichi2/anki/sync/SyncAuth.kt b/anki-common/src/main/kotlin/com/ichi2/anki/sync/SyncAuth.kt index 494f79b6db01..1e93ca4385b5 100644 --- a/anki-common/src/main/kotlin/com/ichi2/anki/sync/SyncAuth.kt +++ b/anki-common/src/main/kotlin/com/ichi2/anki/sync/SyncAuth.kt @@ -35,9 +35,3 @@ fun Collection.syncCollection( auth: SyncAuth, syncMedia: Boolean, ): SyncCollectionResponse = syncCollection(auth.toProto(), syncMedia) - -fun Collection.fullUploadOrDownload( - auth: SyncAuth, - serverUsn: Int?, - upload: Boolean, -) = fullUploadOrDownload(auth.toProto(), serverUsn, upload) From a60a9c973f557faef5500f9587194336a5c5e4bf Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 15 Oct 2026 16:27:48 +0100 Subject: [PATCH 032/163] refactor(sync): document `FullSync:MediaSync` `serverUsn` meant two things: * USN was not available/known * A media sync was not requested This partially documents this differences Prep for 22313 Assisted-by: GPT-6-Astra xhigh --- .../src/main/java/com/ichi2/anki/Sync.kt | 13 +++- .../kotlin/com/ichi2/anki/sync/FullSync.kt | 36 +++++++++- .../com/ichi2/anki/sync/FullSyncTest.kt | 69 +++++++++++++++++++ 3 files changed, 114 insertions(+), 4 deletions(-) create mode 100644 anki-common/src/test/kotlin/com/ichi2/anki/sync/FullSyncTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt b/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt index bce8c357ca36..03db499057f8 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt @@ -19,6 +19,7 @@ import com.ichi2.anki.observability.ChangeManager.notifySubscribersAllValuesChan import com.ichi2.anki.settings.Prefs import com.ichi2.anki.settings.enums.ShouldFetchMedia import com.ichi2.anki.snackbar.showSnackbar +import com.ichi2.anki.sync.MediaSync import com.ichi2.anki.sync.MeteredSyncPermission import com.ichi2.anki.sync.MeteredSyncPolicy import com.ichi2.anki.sync.SyncAuth @@ -284,7 +285,11 @@ private suspend fun handleDownload( Timber.i("Sync: Backup skipped before full download: collection unchanged") } close(downgrade = false, forFullSync = true) - fullUploadOrDownload(auth, upload = false, serverUsn = mediaUsn) + fullUploadOrDownload( + auth, + upload = false, + mediaSync = mediaUsn?.let { MediaSync.Start(it) } ?: MediaSync.Skip, + ) } finally { reopen(afterFullSync = true) } @@ -318,7 +323,11 @@ private suspend fun handleUpload( ) { close(downgrade = false, forFullSync = true) try { - fullUploadOrDownload(auth, upload = true, serverUsn = mediaUsn) + fullUploadOrDownload( + auth, + upload = true, + mediaSync = mediaUsn?.let { MediaSync.Start(it) } ?: MediaSync.Skip, + ) } finally { reopen(afterFullSync = true) } diff --git a/anki-common/src/main/kotlin/com/ichi2/anki/sync/FullSync.kt b/anki-common/src/main/kotlin/com/ichi2/anki/sync/FullSync.kt index fbcf16b9a1d4..9baa2e7138a1 100644 --- a/anki-common/src/main/kotlin/com/ichi2/anki/sync/FullSync.kt +++ b/anki-common/src/main/kotlin/com/ichi2/anki/sync/FullSync.kt @@ -4,8 +4,40 @@ package com.ichi2.anki.sync import com.ichi2.anki.libanki.Collection +/** + * Whether a successful full collection sync should start a media sync (executed in the backend). + * + * Upstream [documents](https://github.com/ankitects/anki/blob/2727cf39b24869e9d379049c9aa141caaf66c49c/proto/anki/sync.proto#L85-L90) + * that omitting `server_usn` skips media sync. When supplied, the backend + * [starts media sync after a successful full sync](https://github.com/ankitects/anki/blob/2727cf39b24869e9d379049c9aa141caaf66c49c/rslib/src/backend/sync.rs#L452-L454). + */ +sealed interface MediaSync { + /** + * Leave media startup to the caller. + * + * Useful for separately enforcing network constraints. + */ + data object Skip : MediaSync + + /** Start media immediately, using the server media USN returned by the initial collection sync. */ + data class Start( + val serverUsn: Int, + ) : MediaSync +} + +/** Transfer the full collection to/from the server */ fun Collection.fullUploadOrDownload( auth: SyncAuth, - serverUsn: Int?, upload: Boolean, -) = fullUploadOrDownload(auth.toProto(), serverUsn, upload) + mediaSync: MediaSync, +) = fullUploadOrDownload( + auth = auth.toProto(), + upload = upload, + // The backend uses `usn != null` to start a media sync. + // Make this distinction explicit, so a media sync is an explicit action. + serverUsn = + when (mediaSync) { + MediaSync.Skip -> null + is MediaSync.Start -> mediaSync.serverUsn + }, +) diff --git a/anki-common/src/test/kotlin/com/ichi2/anki/sync/FullSyncTest.kt b/anki-common/src/test/kotlin/com/ichi2/anki/sync/FullSyncTest.kt new file mode 100644 index 000000000000..f3b915451268 --- /dev/null +++ b/anki-common/src/test/kotlin/com/ichi2/anki/sync/FullSyncTest.kt @@ -0,0 +1,69 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.sync + +import anki.sync.FullUploadOrDownloadRequest +import anki.sync.syncAuth +import com.ichi2.anki.libanki.testutils.InMemoryAnkiTest +import net.ankiweb.rsdroid.Backend +import net.ankiweb.rsdroid.BackendFactory +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class FullSyncTest : InMemoryAnkiTest() { + private lateinit var request: FullUploadOrDownloadRequest + private val auth = + SyncAuth( + syncAuth { + hkey = "test" + endpoint = "https://sync.example.com/" + ioTimeoutSecs = 123 + }, + ) + + override fun setUp() { + BackendFactory.setOverride { + object : Backend() { + override fun fullUploadOrDownload(input: FullUploadOrDownloadRequest) { + request = input + } + } + } + super.setUp() + } + + override fun tearDown() { + try { + super.tearDown() + } finally { + BackendFactory.setOverride(null) + } + } + + @Test + fun `skipping media omits the backend startup trigger in both directions`() { + for (upload in listOf(false, true)) { + col.fullUploadOrDownload(auth, upload = upload, mediaSync = MediaSync.Skip) + + assertFalse(request.hasServerUsn()) + assertEquals(upload, request.upload) + assertEquals(auth.toProto(), request.auth) + } + } + + @Test + fun `starting media preserves the server USN including zero in both directions`() { + for (upload in listOf(false, true)) { + for (serverUsn in listOf(0, 42)) { + col.fullUploadOrDownload(auth, upload = upload, mediaSync = MediaSync.Start(serverUsn)) + + assertTrue(request.hasServerUsn()) + assertEquals(serverUsn, request.serverUsn) + assertEquals(upload, request.upload) + assertEquals(auth.toProto(), request.auth) + } + } + } +} From 7b2baa868dab8e587416e1bffb924ab8e40154a9 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 15 Oct 2026 15:24:30 +0100 Subject: [PATCH 033/163] fix(import): do not close import dialog on startup [tablet] The back stack is now only cleared if closing a side panel. So initialization and refreshing will not clear an import dialog. Fixes 22538 Assisted-by: GPT-6-Astra default --- .../com/ichi2/anki/DeckPickerImportTest.kt | 140 ++++++++++++++++++ .../main/java/com/ichi2/anki/DeckPicker.kt | 14 +- .../com/ichi2/anki/DeckPickerImportTest.kt | 106 ++++++++++++- 3 files changed, 253 insertions(+), 7 deletions(-) create mode 100644 AnkiDroid/src/androidTest/java/com/ichi2/anki/DeckPickerImportTest.kt diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/DeckPickerImportTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/DeckPickerImportTest.kt new file mode 100644 index 000000000000..bfba6bbbd07a --- /dev/null +++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/DeckPickerImportTest.kt @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki + +import androidx.core.content.edit +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.assertion.ViewAssertions.matches +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.CollectionManager.withCol +import com.ichi2.anki.common.preferences.sharedPrefs +import com.ichi2.anki.dialogs.DialogHandler +import com.ichi2.anki.tests.InstrumentedTest +import com.ichi2.anki.testutil.GrantStoragePermission.storagePermission +import com.ichi2.anki.testutil.disableIntroductionSlide +import com.ichi2.anki.testutil.grantPermissions +import com.ichi2.anki.testutil.notificationPermission +import com.ichi2.anki.testutil.waitUntil +import com.ichi2.utils.ImportUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import org.junit.After +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +class DeckPickerImportTest : InstrumentedTest() { + @get:Rule + val runtimePermissionRule = grantPermissions(storagePermission, notificationPermission) + + private val preferences get() = testContext.sharedPrefs() + private val hadBackupPromptPreference = preferences.contains("backupPromptDisabled") + private val backupPromptDisabled = preferences.getBoolean("backupPromptDisabled", false) + + @Test + fun importConfirmationSurvivesDelayedTabletStartup() { + DialogHandler.storeMessage(ImportUtils.CollectionImportAdd("deck.apkg").toMessage()) + + withDelayedStartup { scenario, resumeStartup -> + assertImportConfirmationVisible() + scenario.assertStartupWaiting() + + resumeStartup() + scenario.awaitStartup() + + assertImportConfirmationVisible() + } + } + + @Before + fun prepareTablet() { + // The study options side panel is selected by layout-sw600dp. + assumeTrue("Requires the tablet layout", testContext.resources.configuration.smallestScreenWidthDp >= 600) + disableIntroductionSlide() + InitialActivity.setUpgradedToLatestVersion(preferences) + preferences.edit { putBoolean("backupPromptDisabled", true) } + addNoteUsingBasicNoteType() + } + + @After + fun restorePreferencesAndPendingDialogs() { + DialogHandler.storeMessage(null) + preferences.edit { + if (hadBackupPromptPreference) { + putBoolean("backupPromptDisabled", backupPromptDisabled) + } else { + remove("backupPromptDisabled") + } + } + } + + private fun withDelayedStartup(test: suspend (scenario: ActivityScenario, resumeStartup: suspend () -> Unit) -> Unit) = + runBlocking { + val collectionQueueHeld = CountDownLatch(1) + val releaseStartup = CountDownLatch(1) + val pendingCollectionOperation = + launch(Dispatchers.IO) { + withCol { + collectionQueueHeld.countDown() + check(releaseStartup.await(30, TimeUnit.SECONDS)) { "Startup was not released" } + } + } + var scenario: ActivityScenario? = null + try { + assertTrue(collectionQueueHeld.await(10, TimeUnit.SECONDS), "The collection queue must be held before launching") + val activityScenario = ActivityScenario.launch(DeckPicker::class.java) + scenario = activityScenario + test(activityScenario) { + releaseStartup.countDown() + pendingCollectionOperation.join() + } + } finally { + releaseStartup.countDown() + withContext(NonCancellable) { + pendingCollectionOperation.join() + scenario?.close() + } + } + } + + private fun ActivityScenario.assertStartupWaiting() { + onActivity { activity -> + assertTrue(activity.fragmented, "The tablet side panel must be present") + assertNull(activity.fragment, "Study options must not have been initialized yet") + assertTrue(activity.viewModel.startupJob?.isActive == true, "Startup must still be waiting") + } + } + + private fun ActivityScenario.awaitStartup() { + waitUntil(message = { "Startup did not finish initializing study options" }) { + var finished = false + onActivity { activity -> + finished = + activity.viewModel.startupJob?.isCompleted == true && + activity.viewModel.loadDeckCounts?.isCompleted == true && + activity.fragment != null + } + finished + } + } + + private fun assertImportConfirmationVisible() { + onView(withText(testContext.getString(CommonString.import_dialog_message_add, "deck.apkg"))) + .check(matches(isDisplayed())) + onView(withText(CommonString.import_message_add)) + .check(matches(isDisplayed())) + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt index 53f03539d287..9aa3e9d97400 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt @@ -2135,11 +2135,15 @@ open class DeckPicker : private fun tryShowStudyOptionsPanel(): Boolean { val containerId = binding.studyoptionsFragment?.id ?: return false - // Close other panels before looking for restored study options. - supportFragmentManager.popBackStackImmediate( - null, - FragmentManager.POP_BACK_STACK_INCLUSIVE, - ) + // Only clear navigation when closing another side panel. Import dialogs can already + // be on the back stack when startup finishes, and must survive panel initialization. + val sidePanel = supportFragmentManager.findFragmentById(containerId) + if (sidePanel != null && sidePanel !is StudyOptionsFragment) { + supportFragmentManager.popBackStackImmediate( + null, + FragmentManager.POP_BACK_STACK_INCLUSIVE, + ) + } val studyOptions = fragment if (studyOptions != null) { diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerImportTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerImportTest.kt index 5c8c9039a222..5a356b616b59 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerImportTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerImportTest.kt @@ -3,17 +3,119 @@ package com.ichi2.anki import android.content.Intent -import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.appcompat.app.AlertDialog +import androidx.core.content.edit +import androidx.lifecycle.ViewModelProvider +import com.ichi2.anki.deckpicker.DeckPickerViewModel +import com.ichi2.anki.deckpicker.DeckPickerViewModel.AnkiDroidEnvironment import com.ichi2.anki.dialogs.AsyncDialogFragment +import com.ichi2.anki.dialogs.DialogHandler import com.ichi2.anki.dialogs.ImportDialog +import com.ichi2.anki.testutils.SingleViewModelFactory +import com.ichi2.anki.utils.ext.DIALOG_FRAGMENT_TAG +import com.ichi2.testutils.BackupManagerTestUtilities +import com.ichi2.utils.ImportUtils import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers +import org.junit.After import org.junit.Assert.fail +import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.doCallRealMethod +import org.mockito.kotlin.spy +import org.mockito.kotlin.whenever +import org.robolectric.ParameterizedRobolectricTestRunner +import org.robolectric.Robolectric +import org.robolectric.RuntimeEnvironment +import kotlin.test.assertNotNull +import kotlin.test.assertTrue -@RunWith(AndroidJUnit4::class) +@RunWith(ParameterizedRobolectricTestRunner::class) class DeckPickerImportTest : RobolectricTest() { + @ParameterizedRobolectricTestRunner.Parameter + @JvmField + var qualifiers: String? = null + + companion object { + @ParameterizedRobolectricTestRunner.Parameters + @JvmStatic + fun configurations(): Collection = listOf("normal", "xlarge") + } + + @Before + fun configureScreen() { + RuntimeEnvironment.setQualifiers(qualifiers) + setIntroductionSlidesShown(true) + InitialActivity.setUpgradedToLatestVersion(getPreferences()) + getPreferences().edit { putBoolean("backupPromptDisabled", true) } + BackupManagerTestUtilities.setupSpaceForBackup(targetContext) + } + + @After + fun resetBackupSpace() { + BackupManagerTestUtilities.reset() + } + + @Test + fun `import confirmation survives delayed startup`() { + val viewModel = spy(DeckPickerViewModel()) + lateinit var startupEnvironment: AnkiDroidEnvironment + doAnswer { invocation -> + startupEnvironment = invocation.getArgument(0) + null + }.whenever(viewModel).handleStartup(any()) + + DialogHandler.storeMessage(ImportUtils.CollectionImportAdd("deck.apkg").toMessage()) + val controller = Robolectric.buildActivity(DeckPicker::class.java) + saveControllerForCleanup(controller) + val activity = controller.get() + ViewModelProvider(activity, SingleViewModelFactory.create(viewModel))[DeckPickerViewModel::class.java] + controller + .create() + .start() + .resume() + .visible() + advanceRobolectricLooper() + activity.assertImportConfirmationVisible() + + // Opening the collection at startup can finish after the pending import dialog is shown. + doCallRealMethod().whenever(viewModel).handleStartup(any()) + viewModel.handleStartup(startupEnvironment) + viewModel.awaitStartup() + advanceRobolectricLooper() + + activity.assertImportConfirmationVisible() + } + + @Test + fun `import confirmation survives startup`() { + DialogHandler.storeMessage(ImportUtils.CollectionImportAdd("deck.apkg").toMessage()) + + deckPicker { + assertImportConfirmationVisible() + } + } + + @Test + fun `import confirmation survives returning from file picker`() { + val controller = startActivityControllerNormallyOpenCollectionWithIntent(DeckPicker::class.java, Intent()) + controller.pause().stop() + DialogHandler.storeMessage(ImportUtils.CollectionImportAdd("deck.apkg").toMessage()) + + controller.start().resume().visible() + advanceRobolectricLooper() + + controller.get().assertImportConfirmationVisible() + } + + private fun DeckPicker.assertImportConfirmationVisible() { + val fragment = assertNotNull(supportFragmentManager.findFragmentByTag(DIALOG_FRAGMENT_TAG) as? ImportDialog) + assertTrue(assertNotNull(fragment.dialog as? AlertDialog).isShowing) + } + @Test fun importAddShowsImportDialog() { val deckPicker = super.startActivityNormallyOpenCollectionWithIntent(DeckPickerImport::class.java, Intent()) From 25cf98fe453826de3f2d98338097fe22920570a7 Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:18:56 +0530 Subject: [PATCH 034/163] fix(card-template-editor): count the cards a new card type creates Adding a card type from one that was just added said "This will create 0 cards", since the count came from the current card type's existing cards. Count the notes the new card type would make a card for instead, so the dialog matches what gets created. Fixes 20093 Assisted-by: Claude Opus 5.5 --- .../java/com/ichi2/anki/CardTemplateEditor.kt | 83 +++++--- .../com/ichi2/anki/CardTemplateEditorTest.kt | 180 ++++++++++++++++++ 2 files changed, 241 insertions(+), 22 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt index 59040b9c9663..4e635397b5e6 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt @@ -60,6 +60,7 @@ import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.android.input.ShortcutGroup import com.ichi2.anki.android.input.shortcut import com.ichi2.anki.cardviewer.SingleCardSide +import com.ichi2.anki.common.analytics.Analytics import com.ichi2.anki.common.android.animationDisabled import com.ichi2.anki.common.android.appContext import com.ichi2.anki.common.annotations.NeedsTest @@ -87,6 +88,8 @@ import com.ichi2.anki.libanki.NoteTypeId import com.ichi2.anki.libanki.NotetypeJson import com.ichi2.anki.libanki.Notetypes import com.ichi2.anki.libanki.Notetypes.Companion.NOT_FOUND_NOTE_TYPE +import com.ichi2.anki.libanki.backend.BackendUtils +import com.ichi2.anki.libanki.backend.model.toBackendNote import com.ichi2.anki.libanki.exception.ConfirmModSchemaException import com.ichi2.anki.libanki.getStockNotetype import com.ichi2.anki.libanki.getStockNotetypeKinds @@ -117,14 +120,18 @@ import com.ichi2.utils.show import dev.androidbroadcast.vbpd.viewBinding import kotlinx.coroutines.launch import net.ankiweb.rsdroid.Translations +import net.ankiweb.rsdroid.exceptions.BackendTemplateException import org.json.JSONArray import org.json.JSONException import org.json.JSONObject import timber.log.Timber +import java.util.concurrent.TimeoutException import java.util.regex.Pattern import kotlin.math.max import kotlin.math.min +import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeSource private typealias BackendCardTemplate = com.ichi2.anki.libanki.CardTemplate @@ -1076,16 +1083,13 @@ open class CardTemplateEditor : AnkiActivity(R.layout.activity_card_template_edi Timber.w("addCardTemplate attempted on cloze note type") return } - // Show confirmation dialog - // isOrdinalPendingAdd method will check if there are any new card types added or not, - // if TempModel has new card type then numAffectedCards will be 0 by default. - val numAffectedCards = - if (!CardTemplateNotetype.isOrdinalPendingAdd(templateEditor.tempNoteType!!, templateEditor.ord)) { - templateEditor.getColUnsafe.notetypes.tmplUseCount(templateEditor.tempNoteType!!.notetype, templateEditor.ord) - } else { - 0 - } - confirmAddCards(templateEditor.tempNoteType!!.notetype, numAffectedCards) + val noteType = templateEditor.tempNoteType!!.notetype + val newTemplate = buildNewTemplate(noteType) + launchCatchingTask { + // Show confirmation dialog + val numAffectedCards = withProgress { withCol { countCardsCreatedBy(noteType, newTemplate) } } + confirmAddCards(noteType, newTemplate, numAffectedCards) + } } fun saveNoteType(): Boolean { @@ -1484,6 +1488,7 @@ open class CardTemplateEditor : AnkiActivity(R.layout.activity_card_template_edi */ private fun confirmAddCards( notetype: NotetypeJson, + newTemplate: BackendCardTemplate, numAffectedCards: Int, ) { val d = ConfirmationDialog() @@ -1501,7 +1506,7 @@ open class CardTemplateEditor : AnkiActivity(R.layout.activity_card_template_edi positiveButtonText = getString(CommonString.menu_add), ) - val addCard = Runnable { addNewTemplate(notetype) } + val addCard = Runnable { addNewTemplate(notetype, newTemplate) } val confirm = Runnable { executeWithSyncCheck(addCard) } d.setConfirm(confirm) templateEditor.showDialogFragment(d) @@ -1577,8 +1582,22 @@ open class CardTemplateEditor : AnkiActivity(R.layout.activity_card_template_edi * Add new template to a given note type * @param noteType note type to add new template to */ - private fun addNewTemplate(noteType: NotetypeJson) { - // Build new template + private fun addNewTemplate( + noteType: NotetypeJson, + newTemplate: BackendCardTemplate, + ) { + val templates = noteType.templates + templates.append(newTemplate) + templateEditor.tempNoteType!!.addNewTemplate(newTemplate) + templateEditor.mainBinding.cardTemplateEditorPager.adapter!! + .notifyDataSetChanged() + templateEditor.mainBinding.cardTemplateEditorPager.setCurrentItem( + templates.length() - 1, + templateEditor.animationDisabled(), + ) + } + + private fun buildNewTemplate(noteType: NotetypeJson): BackendCardTemplate { val oldCardIndex = requireArguments().getInt(CARD_INDEX) val templates = noteType.templates val oldTemplate = templates[oldCardIndex] @@ -1591,16 +1610,9 @@ open class CardTemplateEditor : AnkiActivity(R.layout.activity_card_template_edi flipQA(newTemplate) } val lastExistingOrd = templates.last().ord - Timber.d("addNewTemplate() lastExistingOrd was %s", lastExistingOrd) + Timber.d("buildNewTemplate() lastExistingOrd was %s", lastExistingOrd) newTemplate.setOrd(lastExistingOrd + 1) - templates.append(newTemplate) - templateEditor.tempNoteType!!.addNewTemplate(newTemplate) - templateEditor.mainBinding.cardTemplateEditorPager.adapter!! - .notifyDataSetChanged() - templateEditor.mainBinding.cardTemplateEditorPager.setCurrentItem( - templates.length() - 1, - templateEditor.animationDisabled(), - ) + return newTemplate } /** @@ -1726,3 +1738,30 @@ open class CardTemplateEditor : AnkiActivity(R.layout.activity_card_template_edi } } } + +@VisibleForTesting +internal fun Collection.countCardsCreatedBy( + noteType: NotetypeJson, + template: BackendCardTemplate, + timeout: Duration = 1.seconds, +): Int { + val deadline = TimeSource.Monotonic.markNow() + timeout + val unreferencedOrd = generateSequence(0) { it + 1 }.first { "c${it + 1}" !in template.qfmt } + val templateBytes = BackendUtils.toJsonBytes(template.deepClone().apply { setOrd(unreferencedOrd) }) + val noteIds = notetypes.nids(noteType) + return try { + noteIds.count { nid -> + if (deadline.hasPassedNow()) { + Timber.w("counting the cards of a new card type timed out") + Analytics.sendAnalyticsException(TimeoutException("New card type count timed out with ${noteIds.size} notes"), false) + return noteIds.size + } + !backend + .renderUncommittedCardLegacy(getNote(nid).toBackendNote(), unreferencedOrd, templateBytes, false, true) + .isEmpty + } + } catch (e: BackendTemplateException) { + Timber.w(e, "new card type has a template error") + 0 + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CardTemplateEditorTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CardTemplateEditorTest.kt index 8eac914bfaa4..51446b11ee17 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CardTemplateEditorTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CardTemplateEditorTest.kt @@ -47,6 +47,7 @@ import kotlin.test.junit5.JUnit5Asserter.assertNotEquals import kotlin.test.junit5.JUnit5Asserter.assertNotNull import kotlin.test.junit5.JUnit5Asserter.assertNull import kotlin.test.junit5.JUnit5Asserter.assertTrue +import kotlin.time.Duration @RunWith(AndroidJUnit4::class) class CardTemplateEditorTest : RobolectricTest() { @@ -1142,6 +1143,185 @@ class CardTemplateEditorTest : RobolectricTest() { } } + @Test + fun `adding a second card type still counts the notes`() { + addBasicNote() + val intent = Intent(Intent.ACTION_VIEW).putExtra("noteTypeId", col.notetypes.byName("Basic")!!.id) + val controller = + Robolectric + .buildActivity(CardTemplateEditor::class.java, intent) + .create() + .start() + .resume() + .visible() + saveControllerForCleanup(controller) + val editor = controller.get() + + addCardType(editor, shadowOf(editor)) + assertTrue("Unable to click?", shadowOf(editor).clickMenuItem(R.id.action_add)) + advanceRobolectricLooper() + assertEquals( + "Adding from a new card type should count the notes", + getQuantityString(CommonPlurals.card_template_editor_confirm_add, 1, 1), + getAlertDialogText(true), + ) + } + + @Test + fun `adding an optional reverse template with an empty condition creates no cards`() { + val noteType = col.notetypes.byName("Basic (optional reversed card)")!! + val note = col.newNote(noteType) + note.setField(0, "Front") + note.setField(1, "Back") + note.setField(2, "") + col.addNote(note) + assertEquals("Only the forward card should exist", 1, getNoteTypeCardCount(noteType)) + + withCardTemplateEditor(noteType) { + selectTab(1) + advanceRobolectricLooper() + assertEquals("The optional reverse template should be selected", 1, selectedTabPosition) + + assertTrue("Unable to add a card type", shadowOf(this).clickMenuItem(R.id.action_add)) + advanceRobolectricLooper() + val confirmation = getAlertDialogText(true) + clickAlertDialogButton { positiveButton } + assertEquals("The duplicate template should be added", 3, tempNoteType!!.templateCount) + + // Saving rejects identical fronts. Keep the empty condition while making the new front distinct. + advanceRobolectricLooper() + templateFragment(2).binding.editText.setText("{{#Add Reverse}}{{Front}} {{Back}}{{/Add Reverse}}") + + assertTrue("Unable to save the templates", shadowOf(this).clickMenuItem(R.id.action_confirm)) + advanceRobolectricLooper() + val savedNoteType = col.notetypes.get(noteType.id)!! + assertEquals("The duplicate template should be saved", 3, savedNoteType.templates.length()) + assertEquals("An empty Add Reverse field should prevent generating another card", 1, getNoteTypeCardCount(noteType)) + assertEquals( + "The confirmation must not promise a card that will not be generated", + getQuantityString(CommonPlurals.card_template_editor_confirm_add, 0, 0), + confirmation, + ) + } + } + + @Test + fun `adding from an unsaved optional reverse template creates no cards`() { + val noteType = col.notetypes.byName("Basic (optional reversed card)")!! + val note = col.newNote(noteType) + note.setField(0, "Front") + note.setField(1, "Back") + note.setField(2, "") + col.addNote(note) + assertEquals("Only the forward card should exist", 1, getNoteTypeCardCount(noteType)) + + withCardTemplateEditor(noteType) { + selectTab(1) + advanceRobolectricLooper() + addCardType(this, shadowOf(this)) + advanceRobolectricLooper() + assertEquals("The first duplicate should be selected", 2, selectedTabPosition) + assertTrue( + "The selected template must be unsaved", + CardTemplateNotetype.isOrdinalPendingAdd(tempNoteType!!, selectedTabPosition), + ) + + // Keep the empty condition, but make each front distinct so saving succeeds. + templateFragment(2).binding.editText.setText("{{#Add Reverse}}{{Front}} {{Back}}{{/Add Reverse}}") + assertTrue("Unable to add a card type", shadowOf(this).clickMenuItem(R.id.action_add)) + advanceRobolectricLooper() + val confirmation = getAlertDialogText(true) + clickAlertDialogButton { positiveButton } + advanceRobolectricLooper() + assertEquals("The second duplicate should be selected", 3, selectedTabPosition) + templateFragment(3).binding.editText.setText("{{#Add Reverse}}{{Back}} {{Front}}{{/Add Reverse}}") + + assertTrue("Unable to save the templates", shadowOf(this).clickMenuItem(R.id.action_confirm)) + advanceRobolectricLooper() + assertEquals( + "Both new templates should be saved", + 4, + col.notetypes + .get(noteType.id)!! + .templates + .length(), + ) + assertEquals("Neither conditional template should generate cards", 1, getNoteTypeCardCount(noteType)) + assertEquals( + "Adding from an unsaved conditional template must not promise a card that will not be generated", + getQuantityString(CommonPlurals.card_template_editor_confirm_add, 0, 0), + confirmation, + ) + } + } + + @Test + fun `adding from a broken template does not crash`() { + addBasicAndReversedNote() + withCardTemplateEditor(col.notetypes.basicAndReversed) { + templateFragment(0).binding.editText.setText("{{#Front}}") + assertTrue("Unable to add a card type", shadowOf(this).clickMenuItem(R.id.action_add)) + advanceRobolectricLooper() + assertEquals( + "A broken template should not create cards", + getQuantityString(CommonPlurals.card_template_editor_confirm_add, 0, 0), + getAlertDialogText(true), + ) + } + } + + @Test + fun `adding a template with a card number condition counts generated cards`() { + addBasicAndReversedNote() + val noteType = col.notetypes.basicAndReversed + val initialCardCount = getNoteTypeCardCount(noteType) + assertEquals("Both original cards should exist", 2, initialCardCount) + + withCardTemplateEditor(noteType) { + // Preview supplies c3 for the new template, but card generation does not. + templateFragment(0).binding.editText.setText("{{#c3}}{{Front}}{{/c3}}") + assertTrue("Unable to add a card type", shadowOf(this).clickMenuItem(R.id.action_add)) + advanceRobolectricLooper() + val confirmation = getAlertDialogText(true) + clickAlertDialogButton { positiveButton } + advanceRobolectricLooper() + assertEquals("The new template should be selected", 2, selectedTabPosition) + + // Avoid identical fronts without changing the condition or field requirements. + templateFragment(2).binding.editText.setText("{{#c3}}{{Front}} extra{{/c3}}") + assertTrue("Unable to save the templates", shadowOf(this).clickMenuItem(R.id.action_confirm)) + advanceRobolectricLooper() + assertEquals( + "The new template should be saved", + 3, + col.notetypes + .get(noteType.id)!! + .templates + .length(), + ) + val createdCards = getNoteTypeCardCount(noteType) - initialCardCount + assertEquals("The card number condition should prevent generation", 0, createdCards) + assertEquals( + "The confirmation must count generated cards, not nonempty previews", + getQuantityString(CommonPlurals.card_template_editor_confirm_add, createdCards, createdCards), + confirmation, + ) + } + } + + @Test + fun `counting falls back to the note count when it times out`() { + val noteType = col.notetypes.byName("Basic (optional reversed card)")!! + val note = col.newNote(noteType) + note.setField(0, "Front") + note.setField(1, "Back") + col.addNote(note) + val reverseTemplate = noteType.templates[1] + + assertEquals("No reverse card is created", 0, col.countCardsCreatedBy(noteType, reverseTemplate)) + assertEquals("A timeout counts every note", 1, col.countCardsCreatedBy(noteType, reverseTemplate, timeout = Duration.ZERO)) + } + private fun CardTemplateEditor.templateFragment(ord: Int) = supportFragmentManager.findFragmentByTag("f$ord") as CardTemplateFragment private fun addCardType( From 8860d8a7d340876af659383c6216d21b78585713 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:33:01 +0100 Subject: [PATCH 035/163] test(reminders): stabilize code coverage Wait for submitted checks and layout - done on a background thread Assisted-by: GPT-6-Astra default --- .../ReminderTroubleshootingLayoutTest.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/reviewreminders/ReminderTroubleshootingLayoutTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/reviewreminders/ReminderTroubleshootingLayoutTest.kt index f099af6f7d16..ce810f3e11f2 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/reviewreminders/ReminderTroubleshootingLayoutTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/reviewreminders/ReminderTroubleshootingLayoutTest.kt @@ -38,11 +38,15 @@ class ReminderTroubleshootingLayoutTest : RobolectricTest() { withTroubleshootingFragment { val checksList = binding.checksList + @Suppress("UNCHECKED_CAST") - (checksList.adapter as ListAdapter).submitList(checks) - advanceRobolectricLooper() + val adapter = checksList.adapter as ListAdapter + adapter.submitList(checks) + // ListAdapter computes the diff on a background thread before posting the layout. + advanceRobolectricLooperUntil { adapter.currentList == checks && !checksList.isLayoutRequested } - val itemCount = checksList.adapter!!.itemCount + assertThat("the submitted checks are displayed", adapter.currentList, equalTo(checks)) + val itemCount = checks.size assertThat( "sanity: enough checks to overflow the landscape viewport", itemCount, From 205f71c84d3dc76079399e1a22432e93185843db Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:33:16 +0100 Subject: [PATCH 036/163] test(reviewer): stabilize code coverage Wait for playback to finish before asserting Assisted-by: GPT-6-Astra default --- .../test/java/com/ichi2/anki/ReviewerTest.kt | 41 +++++++++++++++++-- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerTest.kt index 0c8d226fce43..4e70ff531bc9 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerTest.kt @@ -30,6 +30,7 @@ import com.ichi2.anki.cardviewer.ViewerCommand.ANSWER_EASY import com.ichi2.anki.cardviewer.ViewerCommand.MARK import com.ichi2.anki.cardviewer.ViewerCommand.SHOW_ANSWER import com.ichi2.anki.common.preferences.sharedPrefs +import com.ichi2.anki.common.storage.CollectionHelper.getMediaDirectory import com.ichi2.anki.common.time.MockTime import com.ichi2.anki.common.time.TimeManager import com.ichi2.anki.common.ui.TransitionDirection @@ -48,12 +49,14 @@ import com.ichi2.anki.model.CardStateFilter import com.ichi2.anki.observability.undoableOp import com.ichi2.anki.preferences.PreferenceTestUtils import com.ichi2.anki.reviewer.ActionButtonStatus +import com.ichi2.anki.snackbar.SnackbarBuilder import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.testutils.RecordingInputMethodManager import com.ichi2.testutils.common.Flaky import com.ichi2.testutils.common.OS import com.ichi2.testutils.ext.addNoSuggestNote import com.ichi2.testutils.ext.createInputConnection +import com.ichi2.testutils.ext.text import com.ichi2.testutils.withBooleanPreference import junit.framework.TestCase.assertEquals import junit.framework.TestCase.assertFalse @@ -73,7 +76,12 @@ import org.robolectric.RuntimeEnvironment import org.robolectric.Shadows import org.robolectric.annotation.Config import org.robolectric.shadow.api.Shadow +import org.robolectric.shadows.ShadowMediaPlayer +import org.robolectric.shadows.util.DataSource import timber.log.Timber +import java.io.File +import java.io.IOException +import java.util.concurrent.ConcurrentLinkedQueue import kotlin.test.assertContains import kotlin.test.assertNotEquals import kotlin.test.junit5.JUnit5Asserter.assertNotNull @@ -261,15 +269,31 @@ class ReviewerTest : RobolectricTest() { } @Test - fun noErrorShouldOccurIfSoundFileNotPresent() { - addBasicNote("[[sound:not_on_file_system.mp3]]", "World") + fun `missing sound is reported without interrupting review`() { + val filename = "not_on_file_system.mp3" + addBasicNote("[sound:$filename]", "World") .firstCard() .moveToReviewQueue() - val reviewer = startReviewer() + val reviewer = startReviewer(ReviewerWithSnackbarRecording::class.java) + val mediaDirectory = getMediaDirectory(reviewer) + assertFalse(File(mediaDirectory, filename).exists()) + // The default media shadow accepts every file, including missing ones. + ShadowMediaPlayer.addException( + DataSource.toDataSource(AbstractFlashcardViewer.getMediaBaseUrl(mediaDirectory) + filename), + IOException("Missing sound file"), + ) reviewer.displayCardQuestion() - assertThat("If the sound file with given name is not present, then no error occurs", true) + // Playback runs on Dispatchers.IO; draining the main looper alone can miss it. + advanceRobolectricLooperUntil { reviewer.snackbarMessages.isNotEmpty() && !reviewer.cardMediaPlayer.isPlaying } + assertThat( + reviewer.snackbarMessages.toList(), + equalTo(listOf(reviewer.getString(CommonString.card_viewer_could_not_find_image, filename))), + ) + reviewer.displayCardAnswer() + assertTrue(reviewer.isDisplayingAnswer) + assertFalse(reviewer.isFinishing) } @Test @@ -628,6 +652,15 @@ class ReviewerTest : RobolectricTest() { private val defaultDeckConfig get() = col.decks.configDictForDeckId(Consts.DEFAULT_DECK_ID) + private class ReviewerWithSnackbarRecording : Reviewer() { + val snackbarMessages = ConcurrentLinkedQueue() + + override val baseSnackbarBuilder: SnackbarBuilder = { + super.baseSnackbarBuilder.invoke(this) + text?.let(snackbarMessages::add) + } + } + private class ReviewerForMenuItems : Reviewer() { var menu: Menu? = null private set From 3784723dba1b67ae87f2ab91b53584030a659acd Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 15 Oct 2026 19:11:41 +0100 Subject: [PATCH 037/163] fix(one-way-sync): apply metered connection restrictions for media Full uploads and downloads no longer run the media sync in the backend, They now run through WorkManager so we can apply network restrictions. Fixes 22313 Assisted-by: GPT-6-Astra default Assisted-by: GPT-6-Astra xhigh --- .../src/main/java/com/ichi2/anki/Sync.kt | 6 +- .../ichi2/anki/ForegroundMediaNetworkTest.kt | 69 +++++++++++++++++-- 2 files changed, 66 insertions(+), 9 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt b/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt index 03db499057f8..b8abe1e04b16 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/Sync.kt @@ -285,10 +285,11 @@ private suspend fun handleDownload( Timber.i("Sync: Backup skipped before full download: collection unchanged") } close(downgrade = false, forFullSync = true) + // Media starts through SyncMediaWorker so WorkManager can enforce network constraints. fullUploadOrDownload( auth, upload = false, - mediaSync = mediaUsn?.let { MediaSync.Start(it) } ?: MediaSync.Skip, + mediaSync = MediaSync.Skip, ) } finally { reopen(afterFullSync = true) @@ -323,10 +324,11 @@ private suspend fun handleUpload( ) { close(downgrade = false, forFullSync = true) try { + // Media starts through SyncMediaWorker so WorkManager can enforce network constraints. fullUploadOrDownload( auth, upload = true, - mediaSync = mediaUsn?.let { MediaSync.Start(it) } ?: MediaSync.Skip, + mediaSync = MediaSync.Skip, ) } finally { reopen(afterFullSync = true) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ForegroundMediaNetworkTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ForegroundMediaNetworkTest.kt index 1b0ead8b4aeb..fc26f3fe7956 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ForegroundMediaNetworkTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ForegroundMediaNetworkTest.kt @@ -8,6 +8,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.work.NetworkType import androidx.work.WorkInfo import androidx.work.WorkManager +import anki.sync.FullUploadOrDownloadRequest import anki.sync.SyncCollectionResponse import anki.sync.syncCollectionResponse import com.ichi2.anki.dialogs.SyncErrorDialog @@ -15,6 +16,7 @@ import com.ichi2.anki.settings.Prefs import com.ichi2.anki.settings.enums.ShouldFetchMedia import com.ichi2.anki.utils.ext.DIALOG_FRAGMENT_TAG import com.ichi2.anki.worker.UniqueWorkNames +import com.ichi2.testutils.BackupManagerTestUtilities import com.ichi2.utils.NetworkUtils import io.mockk.every import io.mockk.mockkObject @@ -38,6 +40,9 @@ import kotlin.test.assertNotNull class ForegroundMediaNetworkTest : RobolectricTest() { private var metered = false private var syncRequired = SyncCollectionResponse.ChangesRequired.NO_CHANGES + private var fullSyncRequest: FullUploadOrDownloadRequest? = null + + override fun getCollectionStorageMode() = CollectionStorageMode.ON_DISK override fun setupTestDispatcher(dispatcher: TestDispatcher) { super.setupTestDispatcher(dispatcher) @@ -47,6 +52,7 @@ class ForegroundMediaNetworkTest : RobolectricTest() { @Before fun prepareSync() { + BackupManagerTestUtilities.setupSpaceForBackup(targetContext) Prefs.hkey = "test" Prefs.lastSyncTime = 0 Prefs.allowSyncOnMeteredConnections = false @@ -65,13 +71,16 @@ class ForegroundMediaNetworkTest : RobolectricTest() { return syncCollectionResponse { required = syncRequired } } - override fun fullUploadOrDownload(input: anki.sync.FullUploadOrDownloadRequest) = Unit + override fun fullUploadOrDownload(input: FullUploadOrDownloadRequest) { + fullSyncRequest = input + } } } } @After fun resetSync() { + BackupManagerTestUtilities.reset() BackendFactory.setOverride(null) unmockkObject(NetworkUtils) } @@ -127,9 +136,10 @@ class ForegroundMediaNetworkTest : RobolectricTest() { deckPicker { syncRequired = SyncCollectionResponse.ChangesRequired.FULL_SYNC - val request = syncAndGetMediaWork(resolveConflict = true) + val request = syncAndGetMediaWork(resolveConflict = ConflictResolution.FULL_UPLOAD) assertThat(request.constraints.requiredNetworkType, equalTo(NetworkType.UNMETERED)) + assertFullSyncDoesNotStartMedia(upload = true) } @Test @@ -138,15 +148,56 @@ class ForegroundMediaNetworkTest : RobolectricTest() { metered = true syncRequired = SyncCollectionResponse.ChangesRequired.FULL_SYNC - val request = syncAndGetMediaWork(confirmWarning = true, resolveConflict = true) + val request = syncAndGetMediaWork(confirmWarning = true, resolveConflict = ConflictResolution.FULL_UPLOAD) assertThat(Prefs.allowSyncOnMeteredConnections, equalTo(false)) assertThat(request.constraints.requiredNetworkType, equalTo(NetworkType.CONNECTED)) + assertFullSyncDoesNotStartMedia(upload = true) + } + + @Test + fun `conflict download leaves media startup to the constrained worker`() = + deckPicker { + syncRequired = SyncCollectionResponse.ChangesRequired.FULL_SYNC + + val request = syncAndGetMediaWork(resolveConflict = ConflictResolution.FULL_DOWNLOAD) + + assertThat(request.constraints.requiredNetworkType, equalTo(NetworkType.UNMETERED)) + assertFullSyncDoesNotStartMedia(upload = false) + } + + @Test + fun `direct full upload leaves media startup to the constrained worker`() = + deckPicker { + syncRequired = SyncCollectionResponse.ChangesRequired.FULL_UPLOAD + + val request = syncAndGetMediaWork() + + assertThat(request.constraints.requiredNetworkType, equalTo(NetworkType.UNMETERED)) + assertFullSyncDoesNotStartMedia(upload = true) } + @Test + fun `direct full download leaves media startup to the constrained worker`() = + deckPicker { + syncRequired = SyncCollectionResponse.ChangesRequired.FULL_DOWNLOAD + + val request = syncAndGetMediaWork() + + assertThat(request.constraints.requiredNetworkType, equalTo(NetworkType.UNMETERED)) + assertFullSyncDoesNotStartMedia(upload = false) + } + + private fun assertFullSyncDoesNotStartMedia(upload: Boolean) { + val request = requireNotNull(fullSyncRequest) + assertThat(request.upload, equalTo(upload)) + // Supplying serverUsn makes the Rust backend start media before WorkManager can gate it. + assertThat("the full sync must not start native media sync", request.hasServerUsn(), equalTo(false)) + } + private fun DeckPicker.syncAndGetMediaWork( confirmWarning: Boolean = false, - resolveConflict: Boolean = false, + resolveConflict: ConflictResolution? = null, ): WorkInfo { val manager = WorkManager.getInstance(this) sync() @@ -154,10 +205,14 @@ class ForegroundMediaNetworkTest : RobolectricTest() { (ShadowDialog.getLatestDialog() as AlertDialog).getButton(DialogInterface.BUTTON_POSITIVE).performClick() } advanceRobolectricLooperUntil { Prefs.lastSyncTime != 0L } - if (resolveConflict) { + if (resolveConflict != null) { Prefs.lastSyncTime = 0 - // Choose "Keep AnkiDroid" and confirm replacing the server collection. - conflictDialog().getButton(DialogInterface.BUTTON_POSITIVE).performClick() + val button = + when (resolveConflict) { + ConflictResolution.FULL_UPLOAD -> DialogInterface.BUTTON_POSITIVE + ConflictResolution.FULL_DOWNLOAD -> DialogInterface.BUTTON_NEGATIVE + } + conflictDialog().getButton(button).performClick() advanceRobolectricLooper() conflictDialog().getButton(DialogInterface.BUTTON_POSITIVE).performClick() advanceRobolectricLooperUntil { Prefs.lastSyncTime != 0L } From 5d18a0b9106f4b077fc05d3df78438008640c752 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:24:40 +0100 Subject: [PATCH 038/163] fix(ci): mark a skipped weekly publish as 'cancelled' Currently it shows as 'success'; technically true, but it makes it seem like we publish more than we actually do. Fixes 22449 Assisted-by: GPT-6-Astra default --- .github/check_alpha_release.js | 32 +++++++++++++++++++++----------- .github/workflows/publish.yml | 3 +++ 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/.github/check_alpha_release.js b/.github/check_alpha_release.js index 332305a69137..30d732d3659b 100644 --- a/.github/check_alpha_release.js +++ b/.github/check_alpha_release.js @@ -3,12 +3,21 @@ const fs = require("node:fs"); module.exports = async function checkAlphaRelease({ github, context, core }) { + async function skipRelease(reason) { + core.info(`Skipping scheduled release: ${reason}`); + await github.rest.actions.cancelWorkflowRun({ + ...context.repo, + run_id: context.runId, + }); + // Cancellation is asynchronous. Keep this step alive so cancelWorkflowRun can kill it. + await keepProcessAlive(); + } + const metadata = JSON.parse( fs.readFileSync("AnkiDroid/build/outputs/release-metadata.json", "utf8"), ); if (metadata.releaseType !== "alpha") { - core.info("Skipping scheduled release: main is not on an alpha version."); - return false; + return await skipRelease("main is not on an alpha version."); } const releases = await github.paginate(github.rest.repos.listReleases, { @@ -22,18 +31,16 @@ module.exports = async function checkAlphaRelease({ github, context, core }) { release => !release.draft && today - Math.floor(Date.parse(release.published_at) / day) < 7, ); if (recentRelease) { - core.info( - `Skipping scheduled release: ${recentRelease.tag_name} was published fewer than seven UTC calendar days ago.`, + return await skipRelease( + `${recentRelease.tag_name} was published fewer than seven UTC calendar days ago.`, ); - return false; } const latestAlpha = releases .filter(release => !release.draft && /^v\d+\.\d+\.\d+alpha\d+$/.test(release.tag_name)) .sort((a, b) => Date.parse(b.published_at) - Date.parse(a.published_at))[0]; if (!latestAlpha) { - core.info("Skipping scheduled release: no published alpha to compare against."); - return false; + return await skipRelease("no published alpha to compare against."); } const { data: comparison } = await github.rest.repos.compareCommitsWithBasehead({ @@ -41,10 +48,7 @@ module.exports = async function checkAlphaRelease({ github, context, core }) { basehead: `${latestAlpha.tag_name}...${context.sha}`, }); if (comparison.status !== "ahead" || comparison.ahead_by === 0) { - core.info( - `Skipping scheduled release: no new commits descending from ${latestAlpha.tag_name}.`, - ); - return false; + return await skipRelease(`no new commits descending from ${latestAlpha.tag_name}.`); } core.info( @@ -52,3 +56,9 @@ module.exports = async function checkAlphaRelease({ github, context, core }) { ); return true; }; + +async function keepProcessAlive() { + // Fail after five minutes if cancellation never arrives. + await new Promise(resolve => setTimeout(resolve, 5 * 60 * 1000)); + throw new Error("Timed out waiting for workflow cancellation."); +} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7080bae85db7..0dc8bf3241e3 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -27,6 +27,9 @@ concurrency: jobs: check_release: name: 'Check whether a release is due' + permissions: + contents: read + actions: write # Cancel this run when a scheduled publish is skipped. if: github.event_name == 'schedule' && github.repository == 'ankidroid/Anki-Android' && github.ref == 'refs/heads/main' timeout-minutes: 20 runs-on: ubuntu-latest From 5c078face27feb7680657ede4b50fde1433aaf67 Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Fri, 16 Oct 2026 01:58:58 +0530 Subject: [PATCH 039/163] fix(release): push the version bump before uploading to Play The alpha5 release pushed its version bump after the Play upload, which takes about ten minutes. Main moved in the meantime, the push was rejected, and the script carried on, so main never got the bump and the next release reused alpha5's version code. Push the bump right after committing, and stop if that fails, before anything is published. Only the tag waits for Play to accept the build. Fixes 22547 Assisted-by: Claude Opus 5.5 --- tools/release.sh | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/tools/release.sh b/tools/release.sh index e2b0e14eb8df..4a548a07cc69 100755 --- a/tools/release.sh +++ b/tools/release.sh @@ -112,14 +112,22 @@ then exit 1 fi -# If any changes go in during the release process, pushing fails, so push immediately. -# Worst case this burns a version number despite a failure later, and we have a version/tag -# that never launched. That's better than having to manually patch up build.gradle.kts and push a tag -# for a release that did launch, but the push failed +# Push the version bump before Play. A merge during the upload rejects the push, +# and the script would otherwise carry on after Play had already accepted the build, +# leaving main without the version that shipped. +# If this push fails, nothing has been published yet and the release stops. +# Worst case a later failure burns a version on main. That is better than +# hand-patching a release that did launch but whose push failed. +# The tag is pushed only after Play accepts, so a Play failure does not create a GitHub release. echo "Committing changelog and version bump via git" git add $GRADLEFILE $CHANGELOG git commit -m "Bumped version to $VERSION" git tag v"$VERSION" +if ! git push +then + echo "Failed to push the version bump. Nothing was published yet." + exit 1 +fi . tools/check-keystore.sh @@ -134,8 +142,7 @@ then exit 1 fi -# If the Play Store accepted the builds, the version bump should be pushed / made concrete -git push +# If the Play Store accepted the builds, the tag should be pushed / made concrete git push --tags # Build the full set of release APKs for all flavors, with universals From 7c1f39b0c6345a91bf43e7e45f9d17db9750482f Mon Sep 17 00:00:00 2001 From: Mike Hardy Date: Thu, 15 Oct 2026 16:27:29 -0500 Subject: [PATCH 040/163] Bumped version to 2.26.0alpha5 --- AnkiDroid/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AnkiDroid/build.gradle.kts b/AnkiDroid/build.gradle.kts index f7112b097371..352b2a0c86eb 100644 --- a/AnkiDroid/build.gradle.kts +++ b/AnkiDroid/build.gradle.kts @@ -130,9 +130,9 @@ android { // // This ensures the correct ordering between the various types of releases (dev < alpha < beta < release) which is // needed for upgrades to be offered correctly. - versionCode = 22600104 + versionCode = 22600105 // If you change this to a new version, you probably also want to update .gradle/workflows/milestone.yml for the new version... - versionName = "2.26.0alpha4" + versionName = "2.26.0alpha5" testApplicationId = "com.ichi2.anki.tests" vectorDrawables.useSupportLibrary = true testInstrumentationRunner = "com.ichi2.testutils.NewCollectionPathTestRunner" From b3d74d326c99e3d57e3505baf8296c0978a0fe14 Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:29:26 +0530 Subject: [PATCH 041/163] refactor: prefix intent extra keys with `EXTRA_` Rename the intent extra key constants that didn't follow the EXTRA_ convention. The key strings stay the same. Issue 22405 Assisted-by: Claude Opus 5.5 --- .../CardTemplateBrowserAppearanceEditor.kt | 28 +++++++-------- .../java/com/ichi2/anki/CardTemplateEditor.kt | 34 +++++++++---------- .../main/java/com/ichi2/anki/DeckPicker.kt | 10 +++--- .../java/com/ichi2/anki/DrawingFragment.kt | 4 +-- .../com/ichi2/anki/IntroductionActivity.kt | 2 +- .../java/com/ichi2/anki/NoteEditorFragment.kt | 8 ++--- .../multimedia/MultimediaImageFragment.kt | 6 ++-- .../anki/noteeditor/NoteEditorDestination.kt | 4 +-- .../com/ichi2/imagecropper/ImageCropper.kt | 4 +-- .../com/ichi2/anki/CardTemplateEditorTest.kt | 4 +-- .../ichi2/anki/IntroductionActivityTest.kt | 2 +- .../anki/multimedia/ImageEditorTestHelper.kt | 6 +++- .../java/com/ichi2/testutils/ActivityList.kt | 10 +++--- .../ActivitySavedStateHelpersTest.kt | 21 +++++++----- 14 files changed, 75 insertions(+), 68 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateBrowserAppearanceEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateBrowserAppearanceEditor.kt index 3f3e446b9b55..b39f01956cb1 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateBrowserAppearanceEditor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateBrowserAppearanceEditor.kt @@ -137,14 +137,14 @@ class CardTemplateBrowserAppearanceEditor : AnkiActivity(R.layout.activity_card_ } public override fun onSaveInstanceState(outState: Bundle) { - outState.putString(INTENT_QUESTION_FORMAT, questionFormat) - outState.putString(INTENT_ANSWER_FORMAT, answerFormat) + outState.putString(EXTRA_QUESTION_FORMAT, questionFormat) + outState.putString(EXTRA_ANSWER_FORMAT, answerFormat) super.onSaveInstanceState(outState) } private fun initializeUiFromBundle(bundle: Bundle) { - binding.questionFormat.setText(bundle.getString(INTENT_QUESTION_FORMAT)) - binding.answerFormat.setText(bundle.getString(INTENT_ANSWER_FORMAT)) + binding.questionFormat.setText(bundle.getString(EXTRA_QUESTION_FORMAT)) + binding.answerFormat.setText(bundle.getString(EXTRA_ANSWER_FORMAT)) discardChangesCallback.isEnabled = hasChanges() @@ -152,9 +152,9 @@ class CardTemplateBrowserAppearanceEditor : AnkiActivity(R.layout.activity_card_ title = TR.sentenceCase.browserAppearance } - private fun answerHasChanged(intent: Intent): Boolean = intent.getStringExtra(INTENT_ANSWER_FORMAT) != answerFormat + private fun answerHasChanged(intent: Intent): Boolean = intent.getStringExtra(EXTRA_ANSWER_FORMAT) != answerFormat - private fun questionHasChanged(intent: Intent): Boolean = intent.getStringExtra(INTENT_QUESTION_FORMAT) != questionFormat + private fun questionHasChanged(intent: Intent): Boolean = intent.getStringExtra(EXTRA_QUESTION_FORMAT) != questionFormat private val questionFormat: String get() = getTextValue(binding.questionFormat) @@ -174,8 +174,8 @@ class CardTemplateBrowserAppearanceEditor : AnkiActivity(R.layout.activity_card_ Timber.i("Save and Exit") val data = Intent().apply { - putExtra(INTENT_QUESTION_FORMAT, questionFormat) - putExtra(INTENT_ANSWER_FORMAT, answerFormat) + putExtra(EXTRA_QUESTION_FORMAT, questionFormat) + putExtra(EXTRA_ANSWER_FORMAT, answerFormat) } setResult(RESULT_OK, data) finish() @@ -208,8 +208,8 @@ class CardTemplateBrowserAppearanceEditor : AnkiActivity(R.layout.activity_card_ null } else { try { - val question = intent.getStringExtra(INTENT_QUESTION_FORMAT) - val answer = intent.getStringExtra(INTENT_ANSWER_FORMAT) + val question = intent.getStringExtra(EXTRA_QUESTION_FORMAT) + val answer = intent.getStringExtra(EXTRA_ANSWER_FORMAT) Result(question, answer) } catch (e: Exception) { Timber.w(e, "Could not read result from intent") @@ -220,8 +220,8 @@ class CardTemplateBrowserAppearanceEditor : AnkiActivity(R.layout.activity_card_ } companion object { - const val INTENT_QUESTION_FORMAT = "bqfmt" - const val INTENT_ANSWER_FORMAT = "bafmt" + const val EXTRA_QUESTION_FORMAT = "bqfmt" + const val EXTRA_ANSWER_FORMAT = "bafmt" /** Specified the card browser should use the default template formatter */ const val VALUE_USE_DEFAULT = "" @@ -239,8 +239,8 @@ class CardTemplateBrowserAppearanceEditor : AnkiActivity(R.layout.activity_card_ answerFormat: String, ): Intent = Intent(context, CardTemplateBrowserAppearanceEditor::class.java).apply { - putExtra(INTENT_QUESTION_FORMAT, questionFormat) - putExtra(INTENT_ANSWER_FORMAT, answerFormat) + putExtra(EXTRA_QUESTION_FORMAT, questionFormat) + putExtra(EXTRA_ANSWER_FORMAT, answerFormat) } } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt index 4e635397b5e6..1b573e474d76 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CardTemplateEditor.kt @@ -211,22 +211,22 @@ open class CardTemplateEditor : AnkiActivity(R.layout.activity_card_template_edi // Load the args either from the intent or savedInstanceState bundle if (savedInstanceState == null) { // get note type id - noteTypeId = intent.getLongExtra(EDITOR_NOTE_TYPE_ID, NOT_FOUND_NOTE_TYPE) + noteTypeId = intent.getLongExtra(EXTRA_NOTE_TYPE_ID, NOT_FOUND_NOTE_TYPE) if (noteTypeId == NOT_FOUND_NOTE_TYPE) { Timber.e("CardTemplateEditor :: no note type ID was provided") finish() return } // get id for currently edited note (optional) - noteId = intent.getLongExtra(EDITOR_NOTE_ID, -1L) + noteId = intent.getLongExtra(EXTRA_NOTE_ID, -1L) // get id for currently edited template (optional) - startingOrdId = intent.getIntExtra(EDITOR_START_ORD_ID, -1) + startingOrdId = intent.getIntExtra(EXTRA_START_ORD_ID, -1) tabToCursorPositions[0] = hashMapOf() tabToViewId[0] = R.id.front_edit } else { - noteTypeId = savedInstanceState.getLong(EDITOR_NOTE_TYPE_ID) - noteId = savedInstanceState.getLong(EDITOR_NOTE_ID) - startingOrdId = savedInstanceState.getInt(EDITOR_START_ORD_ID) + noteTypeId = savedInstanceState.getLong(EXTRA_NOTE_TYPE_ID) + noteId = savedInstanceState.getLong(EXTRA_NOTE_ID) + startingOrdId = savedInstanceState.getInt(EXTRA_START_ORD_ID) tabToCursorPositions = savedInstanceState.getSerializableCompat>>(TAB_TO_CURSOR_POSITION_KEY)!! tabToViewId = savedInstanceState.getSerializableCompat>(TAB_TO_VIEW_ID)!! tempNoteType = CardTemplateNotetype.fromBundle(savedInstanceState) @@ -342,9 +342,9 @@ open class CardTemplateEditor : AnkiActivity(R.layout.activity_card_template_edi public override fun onSaveInstanceState(outState: Bundle) { with(outState) { tempNoteType?.let { putAll(it.toBundle()) } - putLong(EDITOR_NOTE_TYPE_ID, noteTypeId) - putLong(EDITOR_NOTE_ID, noteId) - putInt(EDITOR_START_ORD_ID, startingOrdId) + putLong(EXTRA_NOTE_TYPE_ID, noteTypeId) + putLong(EXTRA_NOTE_ID, noteId) + putInt(EXTRA_START_ORD_ID, startingOrdId) putSerializable(TAB_TO_VIEW_ID, tabToViewId) putSerializable(TAB_TO_CURSOR_POSITION_KEY, tabToCursorPositions) super.onSaveInstanceState(this) @@ -1317,7 +1317,7 @@ open class CardTemplateEditor : AnkiActivity(R.layout.activity_card_template_edi } fun getNote(col: Collection): Note? { - val nid = requireArguments().getLong(EDITOR_NOTE_ID) + val nid = requireArguments().getLong(EXTRA_NOTE_ID) return if (nid != -1L) col.getNote(nid) else null } @@ -1681,7 +1681,7 @@ open class CardTemplateEditor : AnkiActivity(R.layout.activity_card_template_edi val f = CardTemplateFragment() val args = Bundle() args.putInt(CARD_INDEX, cardIndex) - args.putLong(EDITOR_NOTE_ID, noteId) + args.putLong(EXTRA_NOTE_ID, noteId) args.putInt(EDITOR_VIEW_ID_KEY, viewId) f.arguments = args return f @@ -1699,9 +1699,9 @@ open class CardTemplateEditor : AnkiActivity(R.layout.activity_card_template_edi private const val TAB_TO_CURSOR_POSITION_KEY = "tabToCursorPosition" private const val EDITOR_VIEW_ID_KEY = "editorViewId" private const val TAB_TO_VIEW_ID = "tabToViewId" - const val EDITOR_NOTE_TYPE_ID = "noteTypeId" - private const val EDITOR_NOTE_ID = "noteId" - private const val EDITOR_START_ORD_ID = "ordId" + const val EXTRA_NOTE_TYPE_ID = "noteTypeId" + private const val EXTRA_NOTE_ID = "noteId" + private const val EXTRA_START_ORD_ID = "ordId" private const val CARD_INDEX = "card_ord" // Keys for saving pane weights in SharedPreferences @@ -1725,9 +1725,9 @@ open class CardTemplateEditor : AnkiActivity(R.layout.activity_card_template_edi ord: CardOrdinal? = null, ) = Intent(context, CardTemplateEditor::class.java) .apply { - putExtra(EDITOR_NOTE_TYPE_ID, noteTypeId) - noteId?.let { putExtra(EDITOR_NOTE_ID, it) } - ord?.let { putExtra(EDITOR_START_ORD_ID, it) } + putExtra(EXTRA_NOTE_TYPE_ID, noteTypeId) + noteId?.let { putExtra(EXTRA_NOTE_ID, it) } + ord?.let { putExtra(EXTRA_START_ORD_ID, it) } Timber.d( "Built intent for CardTemplateEditor; ntid: %s; nid: %s; ord: %s", diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt index 9aa3e9d97400..7510a57c1cce 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt @@ -525,7 +525,7 @@ open class DeckPicker : return } Timber.d("Not displaying app intro") - if (intent.hasExtra(INTENT_SYNC_FROM_LOGIN)) { + if (intent.hasExtra(EXTRA_SYNC_FROM_LOGIN)) { Timber.d("launched from introduction activity login: syncing") syncOnResume = true } @@ -1590,7 +1590,7 @@ open class DeckPicker : // Due to the App Introduction, this may be called before permission has been granted. if (syncOnResume && hasCollectionStoragePermissions()) { syncOnResume = false - intent.removeExtra(INTENT_SYNC_FROM_LOGIN) + intent.removeExtra(EXTRA_SYNC_FROM_LOGIN) Timber.i("Performing Sync on Resume") Permissions.requestNotificationPermissionsForSyncing(this) sync() @@ -2510,7 +2510,7 @@ open class DeckPicker : * * This is for the 'download existing collection from AnkiWeb' use case */ - const val INTENT_SYNC_FROM_LOGIN = "syncFromLogin" + const val EXTRA_SYNC_FROM_LOGIN = "syncFromLogin" /** * Available options performed by other activities (request codes for onActivityResult()) @@ -2537,7 +2537,7 @@ open class DeckPicker : autoSync: Boolean = false, ) = Intent(context, DeckPicker::class.java).apply { if (autoSync) { - putExtra(INTENT_SYNC_FROM_LOGIN, true) + putExtra(EXTRA_SYNC_FROM_LOGIN, true) } } } @@ -2545,7 +2545,7 @@ open class DeckPicker : override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) this.intent = intent - if (intent.hasExtra(INTENT_SYNC_FROM_LOGIN)) { + if (intent.hasExtra(EXTRA_SYNC_FROM_LOGIN)) { Timber.i("Sync requested from Login") this.syncOnResume = true } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DrawingFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DrawingFragment.kt index 42ee61a1ed73..50e550ab5a42 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DrawingFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DrawingFragment.kt @@ -94,7 +94,7 @@ class DrawingFragment : Fragment(R.layout.fragment_drawing) { val imagePath = saveWhiteboard(whiteboardView) val result = Intent().apply { - putExtra(IMAGE_PATH_KEY, imagePath) + putExtra(EXTRA_IMAGE_PATH, imagePath) } // TODO don't depend on an Activity requireActivity().setResult(Activity.RESULT_OK, result) @@ -130,7 +130,7 @@ class DrawingFragment : Fragment(R.layout.fragment_drawing) { } companion object { - const val IMAGE_PATH_KEY = "path" + const val EXTRA_IMAGE_PATH = "path" fun getIntent(context: Context): Intent = SingleFragmentActivity.getIntent(context, DrawingFragment::class) } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/IntroductionActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/IntroductionActivity.kt index 4c043d28c09d..9509ad4f4ce3 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/IntroductionActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/IntroductionActivity.kt @@ -86,7 +86,7 @@ class IntroductionActivity : val deckPicker = Intent(this, DeckPicker::class.java) deckPicker.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK) if (result == RESULT_SYNC_PROFILE) { - deckPicker.putExtra(DeckPicker.INTENT_SYNC_FROM_LOGIN, true) + deckPicker.putExtra(DeckPicker.EXTRA_SYNC_FROM_LOGIN, true) } startActivity(deckPicker) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt index 6a67240ed009..1a8f7281fe0f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt @@ -169,7 +169,7 @@ import com.ichi2.anki.utils.ext.showDialogFragment import com.ichi2.anki.utils.insetsOf import com.ichi2.anki.utils.openUrl import com.ichi2.imagecropper.ImageCropper -import com.ichi2.imagecropper.ImageCropper.Companion.CROP_IMAGE_RESULT +import com.ichi2.imagecropper.ImageCropper.Companion.EXTRA_CROP_IMAGE_RESULT import com.ichi2.imagecropper.ImageCropperLauncher import com.ichi2.utils.AndroidUiUtils.showSoftInput import com.ichi2.utils.ClipboardUtil @@ -326,7 +326,7 @@ class NoteEditorFragment : * If true, this fragment is on the trailing side of the card browser. */ private val inCardBrowserActivity - get() = requireArguments().getBoolean(IN_CARD_BROWSER_ACTIVITY) + get() = requireArguments().getBoolean(EXTRA_IN_CARD_BROWSER_ACTIVITY) private val requestAddLauncher = registerForActivityResult( @@ -929,7 +929,7 @@ class NoteEditorFragment : result.data?.let { val cropResultData = - it.getParcelableExtraCompat(CROP_IMAGE_RESULT) + it.getParcelableExtraCompat(EXTRA_CROP_IMAGE_RESULT) Timber.d("Cropped image data: $cropResultData") if (cropResultData?.uriPath == null) return@registerForActivityResult setupImageOcclusionEditor(cropResultData.uriPath) @@ -2847,7 +2847,7 @@ class NoteEditorFragment : const val EXTRA_NOTE_CHANGED = "noteChanged" const val EXTRA_RELOAD_REQUIRED = "reloadRequired" const val EXTRA_IMG_OCCLUSION = "image_uri" - const val IN_CARD_BROWSER_ACTIVITY = "inCardBrowserActivity" + const val EXTRA_IN_CARD_BROWSER_ACTIVITY = "inCardBrowserActivity" const val EXTRA_CARD_IDS = "EXTRA_CARD_IDS" const val REQUEST_DECK_SELECTION_NOTE_EDITOR = "request_deck_selection_note_editor" diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaImageFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaImageFragment.kt index 7deecae1b1a6..38c0221b4ccc 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaImageFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multimedia/MultimediaImageFragment.kt @@ -51,7 +51,7 @@ import com.ichi2.anki.workarounds.SafeWebViewClient import com.ichi2.anki.workarounds.SafeWebViewLayout import com.ichi2.compose.theme.AnkiDroidTheme import com.ichi2.imagecropper.ImageCropper -import com.ichi2.imagecropper.ImageCropper.Companion.CROP_IMAGE_RESULT +import com.ichi2.imagecropper.ImageCropper.Companion.EXTRA_CROP_IMAGE_RESULT import com.ichi2.utils.BitmapUtil import com.ichi2.utils.ExifUtil import com.ichi2.utils.FileUtil @@ -178,7 +178,7 @@ class MultimediaImageFragment : Activity.RESULT_OK -> { result.data?.let { val cropResultData = - it.getParcelableExtraCompat(CROP_IMAGE_RESULT) + it.getParcelableExtraCompat(EXTRA_CROP_IMAGE_RESULT) Timber.d("Cropped image data: $cropResultData") if (cropResultData?.uriPath == null || cropResultData.uriContent == null) return@registerForActivityResult @@ -385,7 +385,7 @@ class MultimediaImageFragment : val imageUri = BundleCompat.getParcelable( intent.extras!!, - DrawingFragment.IMAGE_PATH_KEY, + DrawingFragment.EXTRA_IMAGE_PATH, Uri::class.java, ) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/NoteEditorDestination.kt b/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/NoteEditorDestination.kt index fc937d84d9c4..e734297da16b 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/NoteEditorDestination.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/noteeditor/NoteEditorDestination.kt @@ -44,7 +44,7 @@ fun NoteEditorDestination.toIntent(context: Context): Intent = Intent(context, NoteEditorActivity::class.java).also { intent -> intent.putExtra(NoteEditorFragment.EXTRA_CALLER, NoteEditorCaller.CARDBROWSER_ADD.value) intent.putExtra(NoteEditorFragment.EXTRA_TEXT_FROM_SEARCH_VIEW, searchTerms) - intent.putExtra(NoteEditorFragment.IN_CARD_BROWSER_ACTIVITY, false) + intent.putExtra(NoteEditorFragment.EXTRA_IN_CARD_BROWSER_ACTIVITY, false) deckId?.takeIf { it > 0 }?.let { intent.putExtra(NoteEditorFragment.EXTRA_DID, it) } } is NoteEditorDestination.EditSelection -> @@ -55,7 +55,7 @@ fun NoteEditorDestination.toIntent(context: Context): Intent = // To handle multi select and note edit intent.putExtra(NoteEditorFragment.EXTRA_CARD_IDS, cardIds.toLongArray()) intent.putExtra(AnkiActivity.EXTRA_FINISH_ANIMATION, animation as Parcelable) - intent.putExtra(NoteEditorFragment.IN_CARD_BROWSER_ACTIVITY, inCardBrowserActivity) + intent.putExtra(NoteEditorFragment.EXTRA_IN_CARD_BROWSER_ACTIVITY, inCardBrowserActivity) } is NoteEditorDestination.CopyNote -> Intent(context, NoteEditorActivity::class.java).also { intent -> diff --git a/AnkiDroid/src/main/java/com/ichi2/imagecropper/ImageCropper.kt b/AnkiDroid/src/main/java/com/ichi2/imagecropper/ImageCropper.kt index cf8e5e2ced30..c4e685a48e9c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/imagecropper/ImageCropper.kt +++ b/AnkiDroid/src/main/java/com/ichi2/imagecropper/ImageCropper.kt @@ -194,7 +194,7 @@ class ImageCropper : if (result.error == null) { val resultIntent = Intent() resultIntent.putExtra( - CROP_IMAGE_RESULT, + EXTRA_CROP_IMAGE_RESULT, CropResultData( uriContent = result.uriContent, uriPath = context?.let { result.getUriFilePath(it) }, @@ -225,7 +225,7 @@ class ImageCropper : /** * The key for the cropped image path sent back in the result Intent. */ - const val CROP_IMAGE_RESULT = "crop_image_result" + const val EXTRA_CROP_IMAGE_RESULT = "crop_image_result" } @Parcelize diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CardTemplateEditorTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CardTemplateEditorTest.kt index 51446b11ee17..e49568f1b347 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CardTemplateEditorTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CardTemplateEditorTest.kt @@ -909,8 +909,8 @@ class CardTemplateEditorTest : RobolectricTest() { shadowEditor.nextStartedActivity, Activity.RESULT_OK, Intent() - .putExtra(CardTemplateBrowserAppearanceEditor.INTENT_QUESTION_FORMAT, "q") - .putExtra(CardTemplateBrowserAppearanceEditor.INTENT_ANSWER_FORMAT, "a"), + .putExtra(CardTemplateBrowserAppearanceEditor.EXTRA_QUESTION_FORMAT, "q") + .putExtra(CardTemplateBrowserAppearanceEditor.EXTRA_ANSWER_FORMAT, "a"), ) assertDiscardChangesDialogShown(shadowEditor) } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/IntroductionActivityTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/IntroductionActivityTest.kt index d2a9a8310d81..57e93c619498 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/IntroductionActivityTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/IntroductionActivityTest.kt @@ -83,7 +83,7 @@ class IntroductionActivityTest : RobolectricTest() { ) assertThat( "DeckPicker isn't asked to sync (Get Started, not Sync)", - intent.getBooleanExtra(DeckPicker.INTENT_SYNC_FROM_LOGIN, false), + intent.getBooleanExtra(DeckPicker.EXTRA_SYNC_FROM_LOGIN, false), equalTo(false), ) assertThat( diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/multimedia/ImageEditorTestHelper.kt b/AnkiDroid/src/test/java/com/ichi2/anki/multimedia/ImageEditorTestHelper.kt index 1d77fe3be05f..c40c4ca86aed 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/multimedia/ImageEditorTestHelper.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/multimedia/ImageEditorTestHelper.kt @@ -65,7 +65,11 @@ class ImageEditorTestHelper( } fun cropTo(image: File) { - val result = Intent().putExtra(ImageCropper.CROP_IMAGE_RESULT, ImageCropper.CropResultData(contentUri(image), image.absolutePath)) + val result = + Intent().putExtra( + ImageCropper.EXTRA_CROP_IMAGE_RESULT, + ImageCropper.CropResultData(contentUri(image), image.absolutePath), + ) returnImage(CommonString.crop_button, result) } diff --git a/AnkiDroid/src/test/java/com/ichi2/testutils/ActivityList.kt b/AnkiDroid/src/test/java/com/ichi2/testutils/ActivityList.kt index 494b1db491e1..24c8aeea34ee 100644 --- a/AnkiDroid/src/test/java/com/ichi2/testutils/ActivityList.kt +++ b/AnkiDroid/src/test/java/com/ichi2/testutils/ActivityList.kt @@ -10,8 +10,8 @@ import android.content.Intent import androidx.annotation.CheckResult import com.ichi2.anki.CardBrowser import com.ichi2.anki.CardTemplateBrowserAppearanceEditor -import com.ichi2.anki.CardTemplateBrowserAppearanceEditor.Companion.INTENT_ANSWER_FORMAT -import com.ichi2.anki.CardTemplateBrowserAppearanceEditor.Companion.INTENT_QUESTION_FORMAT +import com.ichi2.anki.CardTemplateBrowserAppearanceEditor.Companion.EXTRA_ANSWER_FORMAT +import com.ichi2.anki.CardTemplateBrowserAppearanceEditor.Companion.EXTRA_QUESTION_FORMAT import com.ichi2.anki.CardTemplateEditor import com.ichi2.anki.DeckPicker import com.ichi2.anki.DrawingFragment @@ -130,13 +130,13 @@ object ActivityList { private fun intentForCardTemplateBrowserAppearanceEditor(): Intent { // bundle != null return Intent().apply { - putExtra(INTENT_QUESTION_FORMAT, "{{Front}}") - putExtra(INTENT_ANSWER_FORMAT, "{{FrontSide}}\n{{Back}}") + putExtra(EXTRA_QUESTION_FORMAT, "{{Front}}") + putExtra(EXTRA_ANSWER_FORMAT, "{{FrontSide}}\n{{Back}}") } } private fun intentForCardTemplateEditor(noteTypeId: Long = 1L): Intent = - Intent().putExtra(CardTemplateEditor.EDITOR_NOTE_TYPE_ID, noteTypeId) + Intent().putExtra(CardTemplateEditor.EXTRA_NOTE_TYPE_ID, noteTypeId) private fun intentForWidgetConfig(): Intent = Intent().apply { putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, 1) } diff --git a/AnkiDroid/src/test/java/com/ichi2/testutils/ActivitySavedStateHelpersTest.kt b/AnkiDroid/src/test/java/com/ichi2/testutils/ActivitySavedStateHelpersTest.kt index de41b48b8d3a..d15138c73b29 100644 --- a/AnkiDroid/src/test/java/com/ichi2/testutils/ActivitySavedStateHelpersTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/testutils/ActivitySavedStateHelpersTest.kt @@ -29,22 +29,25 @@ class ActivitySavedStateHelpersTest : AndroidTest { val payload = byteArrayOf(1, 2, 3) assertNull(intent.getBundleExtra(SingleFragmentActivity.EXTRA_FRAGMENT_ARGS)) - intent.withLaunchPayload(SingleFragmentActivity::class.java, PAYLOAD_KEY, payload) + intent.withLaunchPayload(SingleFragmentActivity::class.java, EXTRA_PAYLOAD, payload) - assertContentEquals(payload, intent.getByteArrayExtra(PAYLOAD_KEY)) - assertContentEquals(payload, intent.getBundleExtra(SingleFragmentActivity.EXTRA_FRAGMENT_ARGS)?.getByteArray(PAYLOAD_KEY)) + assertContentEquals(payload, intent.getByteArrayExtra(EXTRA_PAYLOAD)) + assertContentEquals(payload, intent.getBundleExtra(SingleFragmentActivity.EXTRA_FRAGMENT_ARGS)?.getByteArray(EXTRA_PAYLOAD)) } @Test fun `saved state inspection detects copies made by the unfiltered default factory`() { - val intent = Intent().putExtra(PAYLOAD_KEY, ByteArray(300_000)) + val intent = Intent().putExtra(EXTRA_PAYLOAD, ByteArray(300_000)) // A plain AndroidX activity reproduces argument copying without AnkiActivity's override. Robolectric.buildActivity(FragmentActivity::class.java, intent).setup().use { controller -> controller.get().createSavedStateHandleWithDefaultFactory() val savedState = controller.saveState().parcelledCopy(javaClass.classLoader) assertTrue(savedState.savedStateHandlePaths().isNotEmpty(), "Expected a serialized SavedStateHandle provider") - assertTrue(savedState.savedStateHandlePathsToKey(PAYLOAD_KEY).isNotEmpty(), "Expected the copied intent payload to be detected") + assertTrue( + savedState.savedStateHandlePathsToKey(EXTRA_PAYLOAD).isNotEmpty(), + "Expected the copied intent payload to be detected", + ) } } @@ -54,7 +57,7 @@ class ActivitySavedStateHelpersTest : AndroidTest { val savedState = Robolectric.buildActivity(FragmentActivity::class.java).setup().use { controller -> val activity = controller.get() - val fragment = Fragment().apply { arguments = Bundle().apply { putByteArray(PAYLOAD_KEY, payload) } } + val fragment = Fragment().apply { arguments = Bundle().apply { putByteArray(EXTRA_PAYLOAD, payload) } } activity.supportFragmentManager .beginTransaction() .add(fragment, "payload") @@ -64,14 +67,14 @@ class ActivitySavedStateHelpersTest : AndroidTest { } assertTrue(savedState.savedStateHandlePaths().isNotEmpty(), "Expected a serialized SavedStateHandle provider") - assertTrue(savedState.savedStateHandlePathsToKey(PAYLOAD_KEY).isEmpty(), "Fragment arguments are not ViewModel state") + assertTrue(savedState.savedStateHandlePathsToKey(EXTRA_PAYLOAD).isEmpty(), "Fragment arguments are not ViewModel state") Robolectric.buildActivity(FragmentActivity::class.java).setup(savedState).use { controller -> val restoredFragment = controller.get().supportFragmentManager.findFragmentByTag("payload") - assertContentEquals(payload, restoredFragment?.arguments?.getByteArray(PAYLOAD_KEY)) + assertContentEquals(payload, restoredFragment?.arguments?.getByteArray(EXTRA_PAYLOAD)) } } companion object { - private const val PAYLOAD_KEY = "saved_state_test_payload" + private const val EXTRA_PAYLOAD = "saved_state_test_payload" } } From 7e29d242df6aec74e6830e5d5b31626133c34de4 Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Fri, 16 Oct 2026 02:29:30 +0530 Subject: [PATCH 042/163] lint: enforce `EXTRA_` for intent extra keys Constants used as intent extra keys now need the EXTRA_ prefix, so new keys follow the same naming as the rest. Fixes 22405 Assisted-by: Claude Opus 5.5 --- .../java/com/ichi2/anki/lint/IssueRegistry.kt | 2 + .../lint/rules/IntentExtraNamingDetector.kt | 102 +++++++++ .../rules/IntentExtraNamingDetectorTest.kt | 204 ++++++++++++++++++ 3 files changed, 308 insertions(+) create mode 100644 lint-rules/src/main/java/com/ichi2/anki/lint/rules/IntentExtraNamingDetector.kt create mode 100644 lint-rules/src/test/java/com/ichi2/anki/lint/rules/IntentExtraNamingDetectorTest.kt diff --git a/lint-rules/src/main/java/com/ichi2/anki/lint/IssueRegistry.kt b/lint-rules/src/main/java/com/ichi2/anki/lint/IssueRegistry.kt index 3c798d3b7927..21e31fa247f3 100644 --- a/lint-rules/src/main/java/com/ichi2/anki/lint/IssueRegistry.kt +++ b/lint-rules/src/main/java/com/ichi2/anki/lint/IssueRegistry.kt @@ -19,6 +19,7 @@ import com.ichi2.anki.lint.rules.DuplicateCrowdInStrings import com.ichi2.anki.lint.rules.FragmentRequestKeyNamingDetector import com.ichi2.anki.lint.rules.FragmentResultKeyNamingDetector import com.ichi2.anki.lint.rules.HardcodedPreferenceKey +import com.ichi2.anki.lint.rules.IntentExtraNamingDetector import com.ichi2.anki.lint.rules.InvalidStringFormatDetector import com.ichi2.anki.lint.rules.JUnitNullAssertionDetector import com.ichi2.anki.lint.rules.LayoutPrefixDetector @@ -50,6 +51,7 @@ class IssueRegistry : IssueRegistry() { FragmentRequestKeyNamingDetector.ISSUE, FragmentResultKeyNamingDetector.ISSUE, HardcodedPreferenceKey.ISSUE, + IntentExtraNamingDetector.ISSUE, JUnitNullAssertionDetector.ISSUE, LicenseHeaderExists.ISSUE, LocaleRootDetector.ISSUE, diff --git a/lint-rules/src/main/java/com/ichi2/anki/lint/rules/IntentExtraNamingDetector.kt b/lint-rules/src/main/java/com/ichi2/anki/lint/rules/IntentExtraNamingDetector.kt new file mode 100644 index 000000000000..c8a2957ea793 --- /dev/null +++ b/lint-rules/src/main/java/com/ichi2/anki/lint/rules/IntentExtraNamingDetector.kt @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.lint.rules + +import com.android.tools.lint.detector.api.ConstantEvaluator +import com.android.tools.lint.detector.api.Detector +import com.android.tools.lint.detector.api.Implementation +import com.android.tools.lint.detector.api.Issue +import com.android.tools.lint.detector.api.JavaContext +import com.android.tools.lint.detector.api.Scope +import com.android.tools.lint.detector.api.SourceCodeScanner +import com.ichi2.anki.lint.utils.Constants +import com.ichi2.anki.lint.utils.ext.resolveToField +import com.intellij.psi.PsiMethod +import com.intellij.psi.PsiModifier +import org.jetbrains.uast.UCallExpression +import org.jetbrains.uast.skipParenthesizedExprDown + +class IntentExtraNamingDetector : + Detector(), + SourceCodeScanner { + override fun getApplicableMethodNames() = + listOf( + "putExtra", + "hasExtra", + "removeExtra", + "getBooleanExtra", + "getByteExtra", + "getShortExtra", + "getCharExtra", + "getIntExtra", + "getLongExtra", + "getFloatExtra", + "getDoubleExtra", + "getStringExtra", + "getCharSequenceExtra", + "getBundleExtra", + "getParcelableExtra", + "getParcelableArrayExtra", + "getParcelableArrayListExtra", + "getSerializableExtra", + "getIntegerArrayListExtra", + "getStringArrayListExtra", + "getCharSequenceArrayListExtra", + "getBooleanArrayExtra", + "getByteArrayExtra", + "getShortArrayExtra", + "getCharArrayExtra", + "getIntArrayExtra", + "getLongArrayExtra", + "getFloatArrayExtra", + "getDoubleArrayExtra", + "getStringArrayExtra", + "getCharSequenceArrayExtra", + "getParcelableExtraCompat", + "getSerializableExtraCompat", + ) + + override fun visitMethodCall( + context: JavaContext, + node: UCallExpression, + method: PsiMethod, + ) { + val keyIndex = + when (method.containingClass?.qualifiedName) { + "android.content.Intent" -> 0 + "androidx.core.content.IntentCompat", + "com.ichi2.anki.common.utils.ext.IntentKt", + "com.ichi2.anki.compat.CompatHelper.Companion", + -> 1 + else -> return + } + val argument = node.getArgumentForParameter(keyIndex)?.skipParenthesizedExprDown() ?: return + val field = argument.resolveToField() ?: return + if (!field.hasModifierProperty(PsiModifier.STATIC) || !field.hasModifierProperty(PsiModifier.FINAL)) return + if (field.name.startsWith("EXTRA_")) return + if (ConstantEvaluator.evaluate(context, argument) !is String) return + context.report( + ISSUE, + argument, + context.getLocation(argument), + "Intent extra key constants must start with EXTRA_: ${field.name}", + ) + } + + companion object { + val ISSUE = + Issue.create( + id = "IntentExtraNaming", + briefDescription = "Intent extra key constants must start with EXTRA_", + explanation = + """ + Intent extra key constants should be named: EXTRA_. + The name should describe the value being passed. e.g. EXTRA_DECK_ID. + """.trimIndent(), + category = Constants.ANKI_CODE_STYLE_CATEGORY, + priority = Constants.ANKI_CODE_STYLE_PRIORITY, + severity = Constants.ANKI_CODE_STYLE_SEVERITY, + implementation = Implementation(IntentExtraNamingDetector::class.java, Scope.JAVA_FILE_SCOPE), + ) + } +} diff --git a/lint-rules/src/test/java/com/ichi2/anki/lint/rules/IntentExtraNamingDetectorTest.kt b/lint-rules/src/test/java/com/ichi2/anki/lint/rules/IntentExtraNamingDetectorTest.kt new file mode 100644 index 000000000000..2434ea80a3e4 --- /dev/null +++ b/lint-rules/src/test/java/com/ichi2/anki/lint/rules/IntentExtraNamingDetectorTest.kt @@ -0,0 +1,204 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.lint.rules + +import com.android.tools.lint.checks.infrastructure.LintDetectorTest.kotlin +import com.android.tools.lint.checks.infrastructure.TestLintTask.lint +import org.junit.Test + +class IntentExtraNamingDetectorTest { + @Test + fun `reports constants in intent and helper calls`() { + lint() + .allowMissingSdk() + .files( + intent, + intentCompat, + intentExtensions, + compatHelper, + kotlin( + """ + package test + import android.content.Intent + import androidx.core.content.IntentCompat + import com.ichi2.anki.common.utils.ext.getLongExtra + import com.ichi2.anki.common.utils.ext.getParcelableExtraCompat + import com.ichi2.anki.compat.CompatHelper.Companion.getSerializableExtraCompat + + const val NOTE_ID = "noteId" + class Screen { + companion object { + const val DECK_KEY = "deck" + } + fun read(intent: Intent) { + intent.putExtra(NOTE_ID, "1") + intent.putExtra(Screen.DECK_KEY, 1L) + intent.getStringExtra(NOTE_ID) + intent.getLongExtra(NOTE_ID, 0L) + intent.hasExtra(NOTE_ID) + intent.removeExtra(NOTE_ID) + IntentCompat.getParcelableExtra(intent, NOTE_ID, String::class.java) + intent.getLongExtra(NOTE_ID) + intent.getParcelableExtraCompat(NOTE_ID) + intent.getSerializableExtraCompat(NOTE_ID) + } + } + """, + ).indented(), + ).issues(IntentExtraNamingDetector.ISSUE) + .run() + .expectErrorCount(10) + .expectContains("Intent extra key constants must start with EXTRA_: NOTE_ID") + .expectContains("Intent extra key constants must start with EXTRA_: DECK_KEY") + } + + @Test + fun `reports extra key properties accessed through Kotlin getters`() { + lint() + .allowMissingSdk() + .files( + intent, + kotlin( + """ + package test + import android.content.Intent + + val NOTE_ID = "noteId" + object Keys { + val DECK_KEY = "deck" + } + fun read(intent: Intent) { + intent.getStringExtra(NOTE_ID) + intent.putExtra(Keys.DECK_KEY, "1") + } + """, + ).indented(), + ).issues(IntentExtraNamingDetector.ISSUE) + .run() + .expectErrorCount(2) + .expectContains("Intent extra key constants must start with EXTRA_: NOTE_ID") + .expectContains("Intent extra key constants must start with EXTRA_: DECK_KEY") + } + + @Test + fun `allows compliant constants and keys that are not constants`() { + lint() + .allowMissingSdk() + .files( + intent, + intentCompat, + kotlin( + """ + package test + import android.content.Intent + import androidx.core.content.IntentCompat + + const val EXTRA_NOTE_ID = "noteId" + object Keys { + val EXTRA_DECK_ID = "deck" + } + class Screen { + companion object { + val dynamicKey = "extra_" + System.nanoTime() + var mutableKey = "mutable" + val computedKey get() = "extra_" + System.nanoTime() + val delegatedKey by lazy { "extra_" + System.nanoTime() } + } + val instanceKey = "instance" + fun read(intent: Intent, key: String) { + intent.getStringExtra(EXTRA_NOTE_ID) + intent.putExtra(Keys.EXTRA_DECK_ID, "1") + intent.getStringExtra(dynamicKey) + intent.getStringExtra(mutableKey) + intent.getStringExtra(computedKey) + intent.getStringExtra(delegatedKey) + intent.getStringExtra(instanceKey) + intent.getStringExtra(key) + intent.getStringExtra("literal") + IntentCompat.getParcelableExtra(intent, key, String::class.java) + } + } + """, + ).indented(), + ).issues(IntentExtraNamingDetector.ISSUE) + .run() + .expectClean() + } + + @Test + fun `ignores values and unrelated methods with the same name`() { + lint() + .allowMissingSdk() + .files( + intent, + kotlin( + """ + package test + import android.content.Intent + + const val EXTRA_NOTE = "note" + const val NOTE_ID = "noteId" + fun write(intent: Intent) { + intent.putExtra(EXTRA_NOTE, NOTE_ID) + } + class Unrelated { + fun putExtra(name: String, value: String) {} + fun send() = putExtra(NOTE_ID, "1") + } + """, + ).indented(), + ).issues(IntentExtraNamingDetector.ISSUE) + .run() + .expectClean() + } + + companion object { + private val intent = + kotlin( + """ + package android.content + class Intent { + fun putExtra(name: String, value: String): Intent = this + fun putExtra(name: String, value: Long): Intent = this + fun getStringExtra(name: String): String? = null + fun getLongExtra(name: String, defaultValue: Long): Long = defaultValue + fun hasExtra(name: String): Boolean = false + fun removeExtra(name: String) {} + } + """, + ).indented() + private val intentCompat = + kotlin( + """ + package androidx.core.content + import android.content.Intent + object IntentCompat { + @JvmStatic + fun getParcelableExtra(intent: Intent, name: String, clazz: Class): T? = null + } + """, + ).indented() + private val intentExtensions = + kotlin( + """ + @file:JvmName("IntentKt") + package com.ichi2.anki.common.utils.ext + import android.content.Intent + fun Intent.getLongExtra(key: String): Long? = null + inline fun Intent.getParcelableExtraCompat(key: String): T? = null + """, + ).indented() + private val compatHelper = + kotlin( + """ + package com.ichi2.anki.compat + import android.content.Intent + class CompatHelper { + companion object { + inline fun Intent.getSerializableExtraCompat(name: String): T? = null + } + } + """, + ).indented() + } +} From 6f03d608b71609a4a17185230d3b234fb67ed2c1 Mon Sep 17 00:00:00 2001 From: Udit Pandey Date: Fri, 16 Oct 2026 08:00:02 +0530 Subject: [PATCH 043/163] fix(settings): reword web links note --- common/android/src/main/res/values/10-preferences.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/android/src/main/res/values/10-preferences.xml b/common/android/src/main/res/values/10-preferences.xml index a27c10dc368e..0ba52d57ae0e 100644 --- a/common/android/src/main/res/values/10-preferences.xml +++ b/common/android/src/main/res/values/10-preferences.xml @@ -44,7 +44,7 @@ Allow cards to launch other apps - Cards can open other apps on your device, triggering potentially unwanted actions.\nLeave off unless you trust all cards and templates in your collection. + Cards can open other apps on your device, triggering potentially unwanted actions.\nWeb links may still open other apps when this setting is disabled.\nLeave off unless you trust all cards and templates in your collection. Security: Card blocked from launching another app.\nIf you trust all cards and templates in your collection, this can be unblocked in the advanced settings From ea1a3db82c23980b3b4e9a3473d71df5e5651f4d Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:43:20 +0530 Subject: [PATCH 044/163] fix(release): stop if the release tag is missing A failed tag push was ignored, and the GitHub release was created without checking the tag, so a release could end up on an old tag or on a new one gh made from main. Stop if the tag push fails, and let gh refuse to create a release for a tag that isn't on GitHub. Fixes 22555 Assisted-by: Claude Opus 5.5 --- tools/release.sh | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/tools/release.sh b/tools/release.sh index 4a548a07cc69..ccd91e3dd7af 100755 --- a/tools/release.sh +++ b/tools/release.sh @@ -143,7 +143,11 @@ then fi # If the Play Store accepted the builds, the tag should be pushed / made concrete -git push --tags +if ! git push --tags +then + echo "Failed to push the v$VERSION tag. Play already has this build." + exit 1 +fi # Build the full set of release APKs for all flavors, with universals UCFLAVORS='Full Amazon Play' @@ -217,7 +221,11 @@ SCRIPT_DIR=$(dirname "$(readlink -f "$0")") # Read the content of the markdown file using the absolute path RELEASE_NOTES="$SCRIPT_DIR/release-description.md" echo "Creating new Github release" -gh release create v"$VERSION" --title "AnkiDroid $VERSION" -F "$RELEASE_NOTES" $RELEASE_TYPE --draft +if ! gh release create v"$VERSION" --verify-tag --title "AnkiDroid $VERSION" -F "$RELEASE_NOTES" $RELEASE_TYPE --draft +then + echo "Failed to create the GitHub release for v$VERSION" + exit 1 +fi echo "Sleeping 30s to make sure the release exists, see issue 11746" sleep 30 From b742597e5f52b503b94161de9ec6f156059fad7e Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:36:56 +0530 Subject: [PATCH 045/163] ci(emulator): pin the build time for the whole job BUILD_TIME falls back to the time Gradle configures the build, so every Gradle run that asked for different tasks or flags got a new BuildConfig and rebuilt the release APKs, R8 included. The emulator tests rebuilt what Warm Gradle Cache had just built, with the emulator already running. Use one build time for every Gradle run in the job, as release.sh does for a release. Issue 22535 Assisted-by: Claude Opus 5.5 --- .github/workflows/tests_emulator.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/tests_emulator.yml b/.github/workflows/tests_emulator.yml index a8e5dd5abd77..7a1de08b50be 100644 --- a/.github/workflows/tests_emulator.yml +++ b/.github/workflows/tests_emulator.yml @@ -163,6 +163,9 @@ jobs: restore-keys: | avd-${{ matrix.api-level }}-${{ matrix.arch }}-${{matrix.target}}-v2 + - name: Pin Build Time + run: echo "ORG_GRADLE_PROJECT_buildTime=$(date +%s000)" >> "$GITHUB_ENV" + - name: Warm Gradle Cache # This makes sure we fetch gradle network resources with a retry uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 From 7b5162790aef0ee71adfc288f4d1873de921fc23 Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Thu, 15 Oct 2026 23:36:56 +0530 Subject: [PATCH 046/163] ci(emulator): run the release smoke test in its own steps The smoke test was the last line of the emulator test script. When its build failed after every emulator test had passed, the job only said "The process '/usr/bin/sh' failed with exit code 1" and the test report listed no failures. Build it in a step without the emulator, then boot the emulator in a separate step to run it, so a failure names the step that broke. Issue 22535 Assisted-by: Claude Opus 5.5 --- .github/workflows/tests_emulator.yml | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/.github/workflows/tests_emulator.yml b/.github/workflows/tests_emulator.yml index 7a1de08b50be..eaa47d7f0912 100644 --- a/.github/workflows/tests_emulator.yml +++ b/.github/workflows/tests_emulator.yml @@ -277,12 +277,32 @@ jobs: bash .github/adb_retry.sh emu screenrecord start --time-limit 1800 video.webm sleep 5 bash .github/with_emulator_log.sh adb-log.txt python3 .github/run_emulator_tests.py - TEST_RELEASE_BUILD=false ./gradlew :baselineprofile:connectedBenchmarkBenchmarkAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.ichi2.anki.baselineprofile.ReleaseSmokeTest -Pandroid.testInstrumentationRunnerArguments.releaseSmokeTest=true --daemon - name: Report Emulator Test Results if: always() && hashFiles('AnkiDroid/build/outputs/androidTest-results/connected/**/TEST-*.xml') != '' run: python3 .github/report_test_failures.py AnkiDroid/build/outputs/androidTest-results/connected adb-log.txt --title "Emulator test failures" + - name: Build Release Smoke Test + run: TEST_RELEASE_BUILD=false ./gradlew packagePlayRelease :baselineprofile:packageBenchmarkBenchmark --daemon + + - name: Run Release Smoke Test + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 + timeout-minutes: 10 + with: + api-level: ${{ matrix.api-level }} + force-avd-creation: false + target: ${{ matrix.target }} + arch: ${{ matrix.arch }} + emulator-options: -no-snapshot-save ${{ env.EMULATOR_OPTIONS }} + ram-size: ${{ env.EMULATOR_RAM_SIZE }} + pre-emulator-launch-script: ${{ env.EMULATOR_PRE_LAUNCH_SCRIPT }} + sdcard-path-or-size: 100M + disable-animations: true + script: | + bash .github/disable_art_metrics.sh + bash .github/adb_retry.sh shell settings put global hide_error_dialogs 1 + TEST_RELEASE_BUILD=false ./gradlew :baselineprofile:connectedBenchmarkBenchmarkAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.ichi2.anki.baselineprofile.ReleaseSmokeTest -Pandroid.testInstrumentationRunnerArguments.releaseSmokeTest=true --daemon + - name: Configure NDK tools PATH for alignment checking run: | echo "Using pre-installed NDK tools for alignment checking" From cd9057886f22ced5d6ffd7e89688b13db223ab08 Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Fri, 16 Oct 2026 00:05:49 +0530 Subject: [PATCH 047/163] ci(emulator): add a tablet to scheduled runs Tablet-only bugs kept slipping through because every emulator job runs on a small phone. Scheduled and manual runs now add one Pixel Tablet job. Per-change runs are unchanged, and the phone job keeps its name because it is a required check. Like the 16 KB emulator, the tablet skips the AVD cache and always gets the warm-up. Artifact names now include the device so the two jobs don't clash. Fixes 22541 Assisted-by: Claude Opus 5.5 --- .github/workflows/tests_emulator.yml | 39 +++++++++++++++++++--------- 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/.github/workflows/tests_emulator.yml b/.github/workflows/tests_emulator.yml index eaa47d7f0912..82b27b5e0fae 100644 --- a/.github/workflows/tests_emulator.yml +++ b/.github/workflows/tests_emulator.yml @@ -68,13 +68,23 @@ jobs: // try different architectures, targets, delays etc let arch = ['x86_64']; let target = ['google_apis', 'google_apis_ps16k']; - // Keep API 37 and the 16 KB emulator off the per-change CI path. - // Scheduled and manual runs exercise both API levels and targets. + let include = []; + // Keep API 37, the 16 KB emulator and the tablet off the per-change CI path. + // Scheduled and manual runs exercise both API levels and targets, plus a tablet. if (context.eventName !== 'schedule' && context.eventName !== 'workflow_dispatch') { target = ['google_apis']; } else { // Keep the minor version: SDK 37 packages use android-37.0. apiLevel.push('37.0'); + include = iterationArray.map((iteration) => ({ + "iteration": iteration, + "api-level": 36, + "arch": "x86_64", + "target": "google_apis", + "first-boot-delay": 600, + "device": "tablet", + "profile": "pixel_tablet" + })); } let firstBootDelay = [600]; @@ -83,7 +93,9 @@ jobs: "api-level": apiLevel, "arch": arch, "target": target, - "first-boot-delay": firstBootDelay + "first-boot-delay": firstBootDelay, + "device": ["phone"], + "include": include } - name: Debug Output run: echo "${{ steps.build-matrix.outputs.result }}" @@ -91,7 +103,7 @@ jobs: # This uses the matrix generated from the matrix-prep stage # it will run unit tests on whatever OS combinations are desired emulator_test: - name: Android Emulator Test (${{ matrix.api-level }}, ${{ matrix.arch }}, ${{ matrix.target }}, ${{ matrix.first-boot-delay }}, ${{ matrix.iteration }}) + name: Android Emulator Test (${{ matrix.api-level }}, ${{ matrix.arch }}, ${{ matrix.target }}, ${{ matrix.first-boot-delay }}, ${{ matrix.iteration }}${{ matrix.device == 'tablet' && ', tablet' || '' }}) runs-on: ubuntu-latest needs: matrix_prep timeout-minutes: 75 @@ -152,7 +164,7 @@ jobs: # Cache only the per-change emulator: the 16 KB snapshot costs about 2.5 GiB. - name: AVD cache - if: matrix.target == 'google_apis' + if: matrix.target == 'google_apis' && matrix.device == 'phone' uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 id: avd-cache with: @@ -205,15 +217,16 @@ jobs: "${ANDROID_HOME:?}/system-images/android-${SDK_API_LEVEL:?}/${SDK_TARGET:?}/${SDK_ARCH:?}/.installer" - name: AVD Boot and Snapshot Creation - # Warm up the uncached 16 KB emulator on every run; otherwise only on main cache misses. + # Warm up the uncached 16 KB and tablet emulators on every run; otherwise only on main cache misses. # Comment the if out to generate snapshots on branch for performance testing - if: "${{ matrix.target == 'google_apis_ps16k' || (steps.avd-cache.outputs.cache-hit != 'true' && github.ref == 'refs/heads/main') }}" + if: "${{ matrix.target == 'google_apis_ps16k' || matrix.device == 'tablet' || (steps.avd-cache.outputs.cache-hit != 'true' && github.ref == 'refs/heads/main') }}" uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 with: api-level: ${{ matrix.api-level }} force-avd-creation: false target: ${{ matrix.target }} arch: ${{ matrix.arch }} + profile: ${{ matrix.profile }} emulator-options: ${{ env.EMULATOR_OPTIONS }} ram-size: ${{ env.EMULATOR_RAM_SIZE }} pre-emulator-launch-script: ${{ env.EMULATOR_PRE_LAUNCH_SCRIPT }} @@ -228,10 +241,10 @@ jobs: # This step is separate so pure install time may be calculated as a step - name: Emulator Snapshot After Firstboot Warmup - # Always warm up the 16 KB emulator. The default emulator only needs it on main cache misses. + # Always warm up the 16 KB and tablet emulators. The default emulator only needs it on main cache misses. # Switch the if statements via comment if generating snapshots for performance testing # if: matrix.first-boot-delay != '0' - if: "${{ matrix.target == 'google_apis_ps16k' || (steps.avd-cache.outputs.cache-hit != 'true' && github.ref == 'refs/heads/main') }}" + if: "${{ matrix.target == 'google_apis_ps16k' || matrix.device == 'tablet' || (steps.avd-cache.outputs.cache-hit != 'true' && github.ref == 'refs/heads/main') }}" env: FIRST_BOOT_DELAY: ${{ matrix.first-boot-delay }} uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 @@ -240,6 +253,7 @@ jobs: force-avd-creation: false target: ${{ matrix.target }} arch: ${{ matrix.arch }} + profile: ${{ matrix.profile }} emulator-options: ${{ env.EMULATOR_OPTIONS }} ram-size: ${{ env.EMULATOR_RAM_SIZE }} pre-emulator-launch-script: ${{ env.EMULATOR_PRE_LAUNCH_SCRIPT }} @@ -263,6 +277,7 @@ jobs: force-avd-creation: false target: ${{ matrix.target }} arch: ${{ matrix.arch }} + profile: ${{ matrix.profile }} emulator-options: -no-snapshot-save ${{ env.EMULATOR_OPTIONS }} ram-size: ${{ env.EMULATOR_RAM_SIZE }} pre-emulator-launch-script: ${{ env.EMULATOR_PRE_LAUNCH_SCRIPT }} @@ -355,21 +370,21 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: always() with: - name: ${{ matrix.api-level }}-${{ matrix.arch }}-${{matrix.target}}-${{matrix.first-boot-delay}}-${{matrix.iteration}}-jacocoAndroidTestReport + name: ${{ matrix.api-level }}-${{ matrix.arch }}-${{matrix.target}}-${{matrix.first-boot-delay}}-${{matrix.iteration}}-${{ matrix.device }}-jacocoAndroidTestReport path: ~/work/Anki-Android/Anki-Android/AnkiDroid/build/reports/jacoco/ - name: Upload Emulator Log uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: always() with: - name: ${{ matrix.api-level }}-${{ matrix.arch }}-${{matrix.target}}-${{matrix.first-boot-delay}}-${{matrix.iteration}}-adb_logs + name: ${{ matrix.api-level }}-${{ matrix.arch }}-${{matrix.target}}-${{matrix.first-boot-delay}}-${{matrix.iteration}}-${{ matrix.device }}-adb_logs path: adb-log.txt - name: Upload Emulator Screen Record uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 if: always() with: - name: ${{ matrix.api-level }}-${{ matrix.arch }}-${{matrix.target}}-${{matrix.first-boot-delay}}-${{matrix.iteration}}-adb_video + name: ${{ matrix.api-level }}-${{ matrix.arch }}-${{matrix.target}}-${{matrix.first-boot-delay}}-${{matrix.iteration}}-${{ matrix.device }}-adb_video path: video.webm - uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1 From e331d34f62b619d36b0e5d53ee04078855dc7e0a Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:12:38 +0530 Subject: [PATCH 048/163] test(study-options): fix flaky buried count test The test buried a card by hand, but the study options counts never include manually buried cards. It only passed because the deck tree unburied the card partway through the refresh, so on a run where something else asked for the deck tree first, the count was 0. Count a buried sibling instead, which is what the screen shows. Fixes 22558 Assisted-by: Claude Opus 5.5 --- .../java/com/ichi2/anki/StudyOptionsViewModelTest.kt | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/StudyOptionsViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/StudyOptionsViewModelTest.kt index 886d04a8ec93..964566d2c86e 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/StudyOptionsViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/StudyOptionsViewModelTest.kt @@ -197,19 +197,19 @@ class StudyOptionsViewModelTest : RobolectricTest() { } @Test - fun `refreshData - buried cards are counted`() = + fun `refreshData - buried siblings are counted`() = runTest { - addBasicNote("Front1", "Back1") - addBasicNote("Front2", "Back2") + addBasicAndReversedNote("Front", "Back") withCol { - val card = sched.card!! - sched.buryCards(listOf(card.id), true) + val config = decks.configDictForDeckId(decks.current().id) + config.new.bury = true + decks.save(config) } viewModel.refreshData() val state = assertIs(viewModel.state) - assertTrue(state.data.buriedNew > 0, "expected buried new cards") + assertEquals(1, state.data.buriedNew) assertEquals(1, state.data.newCardsToday) } From 311111b4f8a9a8f33494ab23bf13c95d64417a5c Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Fri, 16 Oct 2026 09:29:14 +0530 Subject: [PATCH 049/163] fix(ci): retry GitHub API calls in the label workflow A label removal after a merge failed on a one-off 500 from the GitHub API, because github-script runs with retries off by default. Retry server errors up to three times in each step, but not when posting a comment, so a retry can't post the same comment twice. Fixes 22557 Assisted-by: Claude Opus 5.5 --- .github/workflows/label.yml | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.github/workflows/label.yml b/.github/workflows/label.yml index 2fd5dbe5b228..9fad62b93163 100644 --- a/.github/workflows/label.yml +++ b/.github/workflows/label.yml @@ -14,6 +14,7 @@ jobs: steps: - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: + retries: 3 script: | let removeLabelsList = [ "Pending Merge", @@ -42,6 +43,7 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, + request: { retries: 0 }, body: `Maintainers: Please [Sync Translations](https://github.com/ankidroid/Anki-Android/actions/workflows/sync_translations.yml) to produce a commit with only the automated changes from this PR. Read more about updating strings on the wiki, @@ -80,6 +82,7 @@ jobs: steps: - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: + retries: 3 script: | const I18N_FILES = [ "01-core", @@ -133,6 +136,7 @@ jobs: owner: context.repo.owner, repo: context.repo.repo, issue_number: context.issue.number, + request: { retries: 0 }, body: `> [!IMPORTANT] > **Maintainers**: This PR contains https://github.com/ankidroid/Anki-Android/labels/Strings changes @@ -207,6 +211,7 @@ jobs: steps: - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: + retries: 3 script: | const creator = context.payload.sender.login const opts = github.rest.issues.listForRepo.endpoint.merge({ @@ -240,6 +245,7 @@ jobs: steps: - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 with: + retries: 3 script: | await github.rest.issues.addLabels({ issue_number: context.issue.number, From ec6c2ecd8619c5c46d4afbbfa08be8131c22cf50 Mon Sep 17 00:00:00 2001 From: snowtimeglass Date: Fri, 16 Oct 2026 22:21:22 +0900 Subject: [PATCH 050/163] fix(deck-picker): close FAB when background clicked Closes the FAB menu when clicking the background inside the menu container (e.g., when tapping to the left of narrower buttons such as the "Add" button). --- .../com/ichi2/anki/DeckPickerFloatingActionMenu.kt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt index 00b80c70c1d1..b3584b65abde 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt @@ -331,7 +331,17 @@ class DeckPickerFloatingActionMenu( false } + // Close the FAB menu when clicking outside the menu container binding.fabBGLayout.setOnClickListener { closeFloatingActionMenu(applyRiseAndShrinkAnimation = true) } + + // Close the FAB menu when clicking the background inside the menu container + // (e.g., when tapping to the left of narrower buttons such as the "Add" button) + binding.fabLinearLayout.setOnClickListener { + if (isFABOpen) { + closeFloatingActionMenu(applyRiseAndShrinkAnimation = true) + } + } + val addDeckListener = View.OnClickListener { if (isFABOpen) { From 5c8c0d71555620f453eeac04e53d1d06e455067d Mon Sep 17 00:00:00 2001 From: snowtimeglass Date: Fri, 16 Oct 2026 22:21:52 +0900 Subject: [PATCH 051/163] test(deck-picker): coverage for closing FAB Adds a unit test to verify that the expanded FAB menu closes when clicking the background inside the menu container (e.g., when tapping to the left of narrower buttons such as the "Add" button). --- .../test/java/com/ichi2/anki/DeckPickerTest.kt | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt index 3a63c8cd4350..4809a599c878 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt @@ -1434,6 +1434,24 @@ class DeckPickerTest : RobolectricTest() { ) } + @Test + fun `FAB menu closes when clicking background inside menu container`() = + deckPicker { + floatingActionMenu.showFloatingActionMenu() + assertThat("menu is open", floatingActionMenu.isFABOpen, equalTo(true)) + + // Click the background inside the FAB menu container + // (simulating, for example, tapping to the left of narrower buttons such as the "Add" button) + val linearLayout = floatingActionButtonBinding.fabLinearLayout + linearLayout.performClick() + + assertThat( + "clicking the background inside the FAB menu container closes the menu", + floatingActionMenu.isFABOpen, + equalTo(false), + ) + } + @Test fun `expanded FAB menu is restored after recreation`() { ActivityScenario.launch(DeckPicker::class.java).use { scenario -> From 9c86a912127c542a2686e34ac55b022c4bd1f906 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 17 Oct 2026 16:18:19 +0100 Subject: [PATCH 052/163] fix(tts): prefer voices from the default engine Make voices an ordered (distinct) list, so the default engine voices are returned first if they are available. The set was technically already ordered, but we make this ordering explicit and ensure it's valid. Fixes 22569 Assisted-by: GPT-6-Astra xhigh --- .../java/com/ichi2/anki/AndroidTtsPlayer.kt | 5 +- .../src/main/java/com/ichi2/anki/TtsVoices.kt | 26 ++++---- .../com/ichi2/anki/AndroidTtsPlayerTest.kt | 54 +++++++++++++++++ .../ichi2/anki/TtsVoicesMultiEngineTest.kt | 60 ++++++++++++++++++- 4 files changed, 129 insertions(+), 16 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AndroidTtsPlayer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AndroidTtsPlayer.kt index f300040ba594..d72fb7a5b42f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AndroidTtsPlayer.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AndroidTtsPlayer.kt @@ -24,6 +24,7 @@ import timber.log.Timber import kotlin.coroutines.resume class AndroidTtsPlayer( + /** Voices in engine-preference order, with the default engine first. */ private val voices: List, /** Builds a [TextToSpeech] bound to an engine package. Overridable for testing */ private val ttsFactory: suspend (engine: String) -> TextToSpeech? = { engine -> TtsVoices.createTts(engine) }, @@ -93,7 +94,7 @@ class AndroidTtsPlayer( TtsVoices.ttsEngine?.let { defaultEngine -> getOrCreateTts(defaultEngine) } } - // Prefer installed voices for language fallback without changing the shared matcher. + // Prefer installed voices for language fallback, preserving engine-preference order. // Keep uninstalled voices selectable: an explicit request can trigger their download. override fun getAvailableVoices(): List = voices.sortedBy { it.unavailable() } @@ -198,7 +199,7 @@ class AndroidTtsPlayer( @CheckResult suspend fun createInstance(scope: CoroutineScope): AndroidTtsPlayer { - val voices = TtsVoices.allTtsVoices().toList() + val voices = TtsVoices.allTtsVoices() return AndroidTtsPlayer(voices).apply { init(scope) Timber.v("TTS creation: initialized player instance") diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/TtsVoices.kt b/AnkiDroid/src/main/java/com/ichi2/anki/TtsVoices.kt index 977fbc5851de..4c91cfff2d72 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/TtsVoices.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/TtsVoices.kt @@ -55,8 +55,8 @@ object TtsVoices { /** Published before scanning other engines, so legacy TTS need not wait for them. */ private val engineLocaleData = AtomicReference(CompletableDeferred()) - /** An immutable list of voices available for TTS */ - private lateinit var availableVoices: Set + /** Voices in engine-preference order: the default engine first, then the other engines. */ + private lateinit var availableVoices: List /** A job which populates both the default-engine languages and all available voices. */ private var buildLocalesJob: Job? = null @@ -105,9 +105,10 @@ object TtsVoices { } /** - * Returns the list of available voices for use in TTS + * Returns distinct voices from the default engine first, then other engines in discovery order. + * Voice order within each engine is preserved. Playback uses this order for language fallback. */ - suspend fun allTtsVoices(): Set { + suspend fun allTtsVoices(): List { if (this::availableVoices.isInitialized) { return this.availableVoices } @@ -150,7 +151,7 @@ object TtsVoices { internal suspend fun loadVoices( defaultLocales: CompletableDeferred, createTts: suspend (String?) -> TextToSpeech?, - ): Set { + ): List { // A default-engine instance is needed first to enumerate the installed engines val probeTts = try { @@ -163,16 +164,16 @@ object TtsVoices { if (probeTts == null) { Timber.e("Unable to build list of TTS Voices") defaultLocales.complete(EngineLocales(null, emptyList())) - return emptySet() + return emptyList() } val enginePackages = try { val engine = probeTts.defaultEngine defaultLocales.complete(EngineLocales(engine, readLocales(probeTts))) - // `engines` lists every installed engine; include the default defensively + // Put the default first, preserving discovery order for the remaining engines. try { - (probeTts.engines.map { it.name } + listOfNotNull(engine)).distinct() + (listOfNotNull(engine) + probeTts.engines.map { it.name }).distinct() } catch (e: Exception) { Timber.w(e, "unable to list TTS engines") listOfNotNull(engine) @@ -209,15 +210,16 @@ object TtsVoices { * * Each engine is initialised independently so a single misbehaving engine cannot prevent the * others from being listed. + * Voices are distinct by engine and voice name, keeping the first occurrence. * * @param createTts builds a [TextToSpeech] bound to the provided engine package, or `null` on failure - * @return the union of all voices across [enginePackages] + * @return all voices in [enginePackages] order, preserving each engine's voice order */ internal suspend fun loadVoicesFromEngines( enginePackages: List, createTts: suspend (engine: String) -> TextToSpeech?, - ): Set { - val voices = mutableSetOf() + ): List { + val voices = mutableListOf() for (engine in enginePackages) { val tts = createTts(engine) if (tts == null) { @@ -234,7 +236,7 @@ object TtsVoices { tts.shutdown() } } - return voices + return voices.distinctBy { it.engine to it.voice.name } } /** diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/AndroidTtsPlayerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/AndroidTtsPlayerTest.kt index 15bc8c8540c5..d8e0676097aa 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/AndroidTtsPlayerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/AndroidTtsPlayerTest.kt @@ -33,6 +33,59 @@ import kotlin.time.Duration.Companion.seconds /** Test for [AndroidTtsPlayer] */ @RunWith(AndroidJUnit4::class) class AndroidTtsPlayerTest { + @Test + fun `locale fallback respects engine preference order`() { + val first = AndroidTtsVoice(fakeVoice("first", Locale.FRANCE), ENGINE_A) + val second = AndroidTtsVoice(fakeVoice("second", Locale.FRANCE), ENGINE_B) + + for (voices in listOf(listOf(first, second), listOf(second, first))) { + val player = AndroidTtsPlayer(voices) + assertThat(player.voiceForTag(frenchTag()), equalTo(TtsVoiceMatch(voices.first(), rank = -100))) + } + } + + @Test + fun `unavailable voices follow installed voices with engine order preserved in both groups`() { + val installedB = AndroidTtsVoice(fakeVoice("installed-b", Locale.FRANCE), ENGINE_B) + val unavailableB = AndroidTtsVoice(fakeVoice("unavailable-b", Locale.FRANCE, installed = false), ENGINE_B) + val installedA = AndroidTtsVoice(fakeVoice("installed-a", Locale.FRANCE), ENGINE_A) + val unavailableA = AndroidTtsVoice(fakeVoice("unavailable-a", Locale.FRANCE, installed = false), ENGINE_A) + val installedC = AndroidTtsVoice(fakeVoice("installed-c", Locale.FRANCE), ENGINE_C) + val unavailableC = AndroidTtsVoice(fakeVoice("unavailable-c", Locale.FRANCE, installed = false), ENGINE_C) + val player = AndroidTtsPlayer(listOf(unavailableB, installedB, unavailableA, installedA, unavailableC, installedC)) + + assertThat(player.voices(), equalTo(listOf(installedB, installedA, installedC, unavailableB, unavailableA, unavailableC))) + assertThat(player.voiceForTag(frenchTag()), equalTo(TtsVoiceMatch(installedB, rank = -100))) + } + + @Test + fun `locale fallback uses another engine when the default lacks the language`() { + val french = AndroidTtsVoice(fakeVoice("french", Locale.FRANCE), ENGINE_A) + val english = AndroidTtsVoice(fakeVoice("english", Locale.US), ENGINE_B) + val player = AndroidTtsPlayer(listOf(english, french)) + + assertThat(player.voiceForTag(frenchTag()), equalTo(TtsVoiceMatch(french, rank = -100))) + } + + @Test + fun `installed voice from another engine precedes unavailable default engine voice`() { + val installed = AndroidTtsVoice(fakeVoice("installed", Locale.FRANCE), ENGINE_A) + val unavailable = AndroidTtsVoice(fakeVoice("unavailable", Locale.FRANCE, installed = false), ENGINE_B) + val player = AndroidTtsPlayer(listOf(unavailable, installed)) + + assertThat(player.voiceForTag(frenchTag()), equalTo(TtsVoiceMatch(installed, rank = -100))) + } + + @Test + fun `explicit voice from another engine takes precedence over the default engine`() { + val requested = AndroidTtsVoice(fakeVoice("requested", Locale.FRANCE), ENGINE_A) + val preferred = AndroidTtsVoice(fakeVoice("preferred", Locale.FRANCE), ENGINE_B) + val player = AndroidTtsPlayer(listOf(preferred, requested)) + + assertThat(player.voiceForTag(frenchTag(requested.name)), equalTo(TtsVoiceMatch(requested, rank = 0))) + assertThat(player.voiceForTag(frenchTag("missing")), equalTo(TtsVoiceMatch(preferred, rank = -100))) + } + @Test fun `network timeout is reported with its specific error code`() { val error = AndroidTtsError.fromErrorCode(TextToSpeech.ERROR_NETWORK_TIMEOUT) @@ -265,6 +318,7 @@ class AndroidTtsPlayerTest { companion object { private const val ENGINE_A = "com.example.engine.a" private const val ENGINE_B = "com.example.engine.b" + private const val ENGINE_C = "com.example.engine.c" private const val ENGINE_FAILING = "com.example.engine.failing" private val PLAYBACK_TIMEOUT = 5.seconds } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/TtsVoicesMultiEngineTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/TtsVoicesMultiEngineTest.kt index c59eab0ebdd0..0281adbecad4 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/TtsVoicesMultiEngineTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/TtsVoicesMultiEngineTest.kt @@ -103,7 +103,7 @@ class TtsVoicesMultiEngineTest { assertTrue(defaultLocales.isCompleted) assertEquals(emptyList(), defaultLocales.await().locales) - assertEquals(emptySet(), voices) + assertEquals(emptyList(), voices) } @Test @@ -147,6 +147,61 @@ class TtsVoicesMultiEngineTest { } } + @Test + fun `default engine comes first while other engines retain discovery order`() = + runTest { + val probe = probeTts() + every { probe.defaultEngine } returns ENGINE_B + every { probe.engines } returns + listOf(ENGINE_C, ENGINE_B, ENGINE_A).map { engine -> + TextToSpeech.EngineInfo().apply { name = engine } + } + + val voices = + TtsVoices.loadVoices(CompletableDeferred()) { engine -> + if (engine == null) { + probe + } else { + fakeTts(setOf(fakeVoice("first", Locale.US), fakeVoice("second", Locale.FRANCE)), emptySet()) + } + } + + assertEquals(listOf(ENGINE_B, ENGINE_B, ENGINE_C, ENGINE_C, ENGINE_A, ENGINE_A), voices.map { it.engine }) + assertEquals(listOf("first", "second", "first", "second", "first", "second"), voices.map { it.voice.name }) + } + + @Test + fun `default engine is included first even when missing from discovered engines`() = + runTest { + val probe = probeTts() + every { probe.defaultEngine } returns ENGINE_C + + val voices = + TtsVoices.loadVoices(CompletableDeferred()) { engine -> + if (engine == null) probe else fakeTts(setOf(fakeVoice("voice", Locale.US)), emptySet()) + } + + assertEquals(listOf(ENGINE_C, ENGINE_A, ENGINE_B), voices.map { it.engine }) + } + + @Test + fun `voices are distinct by engine and name while preserving their first occurrence`() = + runTest { + val first = fakeVoice("shared", Locale.US) + val duplicate = fakeVoice("shared", Locale.US) + val other = fakeVoice("other", Locale.FRANCE) + val engineB = fakeTts(setOf(first, duplicate, other), emptySet()) + val engineA = fakeTts(setOf(fakeVoice("shared", Locale.US)), emptySet()) + + val voices = + TtsVoices.loadVoicesFromEngines(listOf(ENGINE_B, ENGINE_A, ENGINE_B)) { engine -> + if (engine == ENGINE_B) engineB else engineA + } + + assertEquals(listOf("$ENGINE_B-shared", "$ENGINE_B-other", "$ENGINE_A-shared"), voices.map { it.name }) + assertSame(first, voices.first().voice) + } + @Test fun `voices are aggregated across multiple engines`() = runBlocking { @@ -222,7 +277,7 @@ class TtsVoicesMultiEngineTest { setUpFailingTts() val locales = CompletableDeferred() - assertEquals(emptySet(), TtsVoices.loadVoices(locales, TtsVoices::createTts)) + assertEquals(emptyList(), TtsVoices.loadVoices(locales, TtsVoices::createTts)) assertEquals(TtsVoices.EngineLocales(null, emptyList()), locales.await()) } @@ -320,5 +375,6 @@ class TtsVoicesMultiEngineTest { companion object { private const val ENGINE_A = "com.example.engine.a" private const val ENGINE_B = "com.example.engine.b" + private const val ENGINE_C = "com.example.engine.c" } } From ba8fa41d50d3fc81dbcb3f499b4301334ec21c22 Mon Sep 17 00:00:00 2001 From: Mike Hardy Date: Sun, 18 Oct 2026 06:31:01 +0000 Subject: [PATCH 053/163] Bumped version to 2.26.0alpha6 --- AnkiDroid/build.gradle.kts | 4 +- AnkiDroid/src/main/assets/changelog.html | 764 ++++++++++++++++++++++- 2 files changed, 764 insertions(+), 4 deletions(-) diff --git a/AnkiDroid/build.gradle.kts b/AnkiDroid/build.gradle.kts index 352b2a0c86eb..f285bf7bf7d4 100644 --- a/AnkiDroid/build.gradle.kts +++ b/AnkiDroid/build.gradle.kts @@ -130,9 +130,9 @@ android { // // This ensures the correct ordering between the various types of releases (dev < alpha < beta < release) which is // needed for upgrades to be offered correctly. - versionCode = 22600105 + versionCode = 22600106 // If you change this to a new version, you probably also want to update .gradle/workflows/milestone.yml for the new version... - versionName = "2.26.0alpha5" + versionName = "2.26.0alpha6" testApplicationId = "com.ichi2.anki.tests" vectorDrawables.useSupportLibrary = true testInstrumentationRunner = "com.ichi2.testutils.NewCollectionPathTestRunner" diff --git a/AnkiDroid/src/main/assets/changelog.html b/AnkiDroid/src/main/assets/changelog.html index 161320a39136..ea5c5d394647 100644 --- a/AnkiDroid/src/main/assets/changelog.html +++ b/AnkiDroid/src/main/assets/changelog.html @@ -464,6 +464,605 @@

AnkiDroid Changelog

+
+

Version 2.26.0alpha6 is a test build. Its changelog comes with the stable release. Until then, here are the changes in the latest stable version.

+
+
+

Version 2.25.3 (20261017)

+
+
+

Hello everyone out there in AnkiDroid-land 👋

+
+
+

We’re already moving right along on work for 2.26 but we are always listening for and +fixing issues when we hear about them - hopefully this patch release gets rid of some +pain points for everyone

+
+
+

Cheers!

+
+
+
    +
  • +

    Deck Picker: Fix the import confirmation disappearing on tablets during startup

    +
  • +
  • +

    Reviewing: Re-enable HTTP(s) links - our security fixes went too far

    +
  • +
  • +

    Note Editor: Fix filename normalization issues when pasting media

    +
  • +
  • +

    Text-to-speech: Report network errors and timeouts

    +
  • +
  • +

    App-wide: Fix crashes if widgets are unsupported

    +
  • +
+
+
+

Full list of 2.25.3 changes.

+
+
+
+
+

Version 2.25.2 (20261013)

+
+
+

…​aaaaand we’re back! Google has officially blessed our releases and allowed them +out in the wild again after our donation link kerfuffle. In fact, literally millions +of you (nearly 3 million and counting) are already on the new 2.25 release series

+
+
+

For any of you that have opted in to crash reporting, thank you! That’s one of the main +ways we detect new problems, and your reward is this fresh batch of fixes

+
+
+

Enjoy!

+
+
+
    +
  • +

    Deck Picker: Fix 'one way sync' disappearing on tablets

    +
  • +
  • +

    Text-to-speech: Handle AnkiDroid crashing when accessing faulty TTS Engines

    +
  • +
  • +

    Reviewer: Fix TTS freezes when leaving the study screen

    +
  • +
  • +

    Performance: Fix various freezes while syncing

    +
  • +
  • +

    Sync: Fix errors when closing the app

    +
  • +
  • +

    App-wide: Fix some Android region settings blocking backend translations

    +
  • +
  • +

    App-wide: Fix random freezes on some devices (Custom Tabs issue)

    +
  • +
  • +

    App-wide: Fix crashes when closing/rotating screens

    +
  • +
  • +

    Internationalization: Fix tablet panes resizing in the opposite direction

    +
  • +
+
+
+

Full list of 2.25.2 changes.

+
+
+
+
+

Version 2.25.1 (20261006)

+
+
+

Wait what!? What is this? A 2.25.1 and you didn’t even get 2.25.0 yet!

+
+
+

That’s right! 2.25.0 did ship but Google wouldn’t release it because…​well, honestly +we’re not sure, their approval / rejection process is completely opaque! All we know is that +it originally had to do with them rejecting our donations link. And now it appears they’re +rejecting old versions! But we do what we can. And we can keep trying. So here’s 2.25.1!

+
+
+

With sincere wishes all of our lovely AnkiDroid users get to enjoy it ☺️ 🫶

+
+
+

Full 2.25.0 changelog down below 👇

+
+
+

Here’s our 2.25.1 bugfix list:

+
+
+
    +
  • +

    Study screen: Preserve scroll position when flipping a card

    +
  • +
  • +

    Study screen: Reset zoom when changing cards

    +
  • +
  • +

    Reviewing: Fix MathJax font scaling (may change the layout of some cards)

    +
  • +
  • +

    Reviewing: Fix crash on close after a WebView failure

    +
  • +
  • +

    Text-to-speech: Prefer installed voices (fixes issues on Pixel devices)

    +
  • +
  • +

    Text-to-speech: Performance improvements for legacy TTS

    +
  • +
  • +

    Card Browser: Fix crashes while scrolling

    +
  • +
  • +

    Card Browser: Preserve selection in more cases

    +
  • +
  • +

    Card Browser: Fix icon tint being applied globally

    +
  • +
  • +

    Tags: Fix parent tags remaining selected after being unchecked

    +
  • +
  • +

    Tags: Fix checkbox states when editing multiple notes or filtering tags

    +
  • +
  • +

    Tags: Fix ordering (partially selected tags at the top)

    +
  • +
  • +

    Tags: Fix tags being changed on scroll

    +
  • +
  • +

    Tags: Better performance

    +
  • +
  • +

    Note Editor: Handle 'Restore to Default' in the template editor

    +
  • +
  • +

    Note Editor: Match Anki Desktop when switching to a note type without an deck

    +
  • +
  • +

    Note Types: Update state after undo/redo

    +
  • +
  • +

    Various screens: Better edge to edge UI support

    +
  • +
  • +

    Sync: Fix crash on long background syncs

    +
  • +
  • +

    Sync: Fix undismissable notification sometimes appearing after a completed sync

    +
  • +
  • +

    Deck Options: Prevent scrolling from changing Easy Days

    +
  • +
  • +

    Deck Picker Widget: Make the whole deck row tappable

    +
  • +
  • +

    Widgets: Improve layout with large fonts and small widget sizes

    +
  • +
  • +

    Widgets: Hide cut-off rows and refresh the deck list when resized

    +
  • +
  • +

    Widgets: Fix incorrect colors on load (Android 12+)

    +
  • +
  • +

    App-wide: Reduce unnecessary data saved when moving app data to disk

    +
  • +
  • +

    App-wide: Fix crash when TMPDIR is unavailable

    +
  • +
+
+ +
+
+
+

Version 2.25.0 (20260927)

+
+
+

Hey hey hey! It’s new AnkiDroid version time 🎁👀. We’ve got a bunch of new features for you, plus the usual +mix of security fixes, bug fixes and quality of life improvements. We hope you enjoy it.

+
+
+

Removal of donation links: Please see GitHub Issue 21656 for why this has happened and how to help us with this.

+
+
+

Google Summer of Code 2026

+
+

We participated in Google Summer of Code 2026!

+
+
+ +
+
+
+

Security

+
+

A special thank you to the following people for reporting the security issues fixed in this release:

+
+
+ +
+
+
    +
  • +

    Reviewer: cards can no longer open dangerous links [GHSL-2026-085, GHSA-54q9-5c5p-9rxg]

    +
  • +
  • +

    Reviewer: cards require permission to launch apps [GHSL-2026-085, GHSA-54q9-5c5p-9rxg]

    +
    +
      +
    • +

      May be unblocked in advanced settings, if all your cards are trustworthy.

      +
    • +
    +
    +
  • +
  • +

    JavaScript API: blocked calls which could read and modify cards (other than the currently selected card) [GHSL-2026-084, GHSA-54q9-5c5p-9rxg]

    +
    +
      +
    • +

      May be unblocked in advanced settings, if all your cards are trustworthy.

      +
    • +
    +
    +
  • +
  • +

    Reviewer: fix path traversal when loading media [GHSL-2026-083, GHSA-qjjx-px4w-4986]

    +
  • +
  • +

    Import: sanitize filenames to block path traversal [GHSA-q29p-h3pp-mh3v]

    +
  • +
  • +

    Note Editor: reject untrusted files from the image picker [GHSA-cr2g-gf75-cm6r]

    +
  • +
+
+
+
+

AnkiDroid features

+
+
    +
  • +

    Design: All screens now support edge to edge (Android requirement)

    +
    +
      +
    • +

      This required extensive changes to all screens of the app. Please report any bugs.

      +
    • +
    +
    +
  • +
  • +

    Themes: E-ink theme & improved theme selection.

    +
  • +
  • +

    Card Browser: Find & Replace support.

    +
  • +
  • +

    Card Browser: Improved sort screen, with new columns to sort by.

    +
  • +
  • +

    TTS: Support all installed engines.

    +
  • +
  • +

    Analytics: Analytics are fixed. Analytics are opt-in only, and all opt-in flags have been reset.

    +
  • +
  • +

    Deck Picker: Better designed floating action button.

    +
  • +
  • +

    New Study Screen: Card templates can record audio.

    +
  • +
  • +

    Performance: Whiteboard and drawing now save 'webp' files.

    +
  • +
  • +

    Change note type: Huge UI improvements.

    +
  • +
+
+
+
+

Anki 26.05 features

+
+
    +
  • +

    New Cloze syntax: {{c1,2,3::text}} to hide text on cards 1, 2 and 3. (manual)

    +
  • +
  • +

    Reviewing: Card content determines RTL direction (dir="auto") (source)

    +
  • +
  • +

    Search: syntax for ignoring accents: tag:nc:.

    +
  • +
  • +

    Image Occlusion: improved RTL layout.

    +
  • +
  • +

    Image Occlusion: display text annotations during review.

    +
  • +
  • +

    CSV import: supplying a note-type column allows differing field counts.

    +
  • +
  • +

    Deck Options: UI improvements on smaller screens.

    +
  • +
  • +

    FSRS: faster calculations and better spacing of siblings.

    +
  • +
  • +

    FSRS: suspended cards are excluded from rescheduling.

    +
  • +
  • +

    Media: better handling of case-sensitive filenames.

    +
  • +
  • +

    See more in the Anki 26.05 changelog.

    +
  • +
+
+
+
+

Fixes and improvements

+
+
    +
  • +

    Google Play: 'Donate' links removed to comply with Play policies

    +
  • +
  • +

    Custom study: Fix 'due cards' not working when tag selection is skipped.

    +
  • +
  • +

    Deck Picker: A deck is selected when it is created.

    +
  • +
  • +

    Deck Picker: Better navigation via arrow keys.

    +
  • +
  • +

    Deck Picker: Improved 'Rename deck' dialog UX.

    +
  • +
  • +

    Deck Picker: Fix counts when totals above 9,999.

    +
  • +
  • +

    Shared Deck Download: Improved retry logic.

    +
  • +
  • +

    Card Browser: UX: better selection restore and RTL design.

    +
  • +
  • +

    Filtered Decks: UX Improvements.

    +
  • +
  • +

    Filtered Decks: Handle state restoration after process death.

    +
  • +
  • +

    Reviewer: Fix the shake gesture firing rapidly.

    +
  • +
  • +

    Previewer: Fixed background in night mode.

    +
  • +
  • +

    Study Options: Fix 'undo' opening the reviewer.

    +
  • +
  • +

    Deck Options: Improved progress reporting.

    +
  • +
  • +

    Note Editor: Fewer 'unsaved changes' prompts.

    +
  • +
  • +

    Card Template Editor: Better focus handling.

    +
  • +
  • +

    Set Due Date: Fixed crash when opening.

    +
  • +
  • +

    WebViews: Improved outdated WebView detection.

    +
  • +
  • +

    Deck Picker: Fixed "deck not found in limits map" error.

    +
  • +
  • +

    API: Fixed a crash in answerCard.

    +
  • +
  • +

    New Study Screen: better handling of custom scheduling and app restoration.

    +
  • +
  • +

    Widgets: UI improvements.

    +
  • +
  • +

    Note Editor: Fix out of memory crashes.

    +
  • +
  • +

    Accessibility: Improvements for Card Browser and Reviewer.

    +
  • +
+
+
+
+
+
+

Version 2.24.1 (20260831)

+
+
+

Urgent update to our 2.24.0 release: security updates, and temporarily removing donation links to +comply with Play Store policies (more below).

+
+
+

A special thank you to the following people for reporting the security issues fixed in this release:

+
+
+ +
+
+

Removal of donation links: Please see GitHub Issue 21656 for why this has happened and how +to help us with this.

+
+
+

Please see the 2.24.0 changelog below for the main 2.24 features announcement 👇

+
+
+
    +
  • +

    Security

    +
    +
      +
    • +

      Reviewer: cards can no longer open dangerous links [GHSL-2026-085, GHSA-54q9-5c5p-9rxg]

      +
    • +
    • +

      Reviewer: cards require permission to launch apps [GHSL-2026-085, GHSA-54q9-5c5p-9rxg]

      +
      +
        +
      • +

        May be unblocked in advanced settings, if all your cards are trustworthy.

        +
      • +
      +
      +
    • +
    • +

      JavaScript API: blocked calls which could read and modify cards (other than the currently selected card) [GHSL-2026-084, GHSA-54q9-5c5p-9rxg]

      +
      +
        +
      • +

        May be unblocked in advanced settings, if all your cards are trustworthy.

        +
      • +
      +
      +
    • +
    • +

      Reviewer: fix path traversal when loading media [GHSL-2026-083, GHSA-qjjx-px4w-4986]

      +
    • +
    • +

      Import: sanitize filenames to block path traversal [GHSA-q29p-h3pp-mh3v]

      +
    • +
    • +

      Note Editor: reject untrusted files from the image picker [GHSA-cr2g-gf75-cm6r]

      +
    • +
    +
    +
  • +
  • +

    Improvements

    +
    +
      +
    • +

      Privacy: removed a log entry which could hint at your timezone

      +
    • +
    • +

      Card Browser: better 'invalid search' messages

      +
    • +
    • +

      Google Play: 'Donate' links removed to comply with Play policies

      +
    • +
    +
    +
  • +
  • +

    Fixes

    +
    +
      +
    • +

      Deck Picker & Card Browser: Fix memory leaks

      +
    • +
    • +

      Fix crash when sharing an image to the Note Editor

      +
    • +
    • +

      Fix crash when exporting a large number of notes

      +
    • +
    • +

      Fix 'Auto Advance' not following the review queue

      +
    • +
    • +

      Improve error names in error messages

      +
    • +
    • +

      Whiteboard: fix flickering [Samsung/OnePlus]

      +
    • +
    • +

      Whiteboard: fix crash on rotation when 'type in the answer' is open

      +
    • +
    • +

      Note Editor: fix 'MathJax block' syntax

      +
    • +
    • +

      Note Types: only enable 'Save' when there are unsaved changes

      +
    • +
    • +

      Sync: fix conflict dialog only showing its title [Samsung-only]

      +
    • +
    • +

      Export: sharing text is now a text/plain file

      +
    • +
    • +

      Settings: fix crash when dragging reviewer actions and switching screens

      +
    • +
    • +

      Settings: include 'Themes' in search results

      +
    • +
    • +

      Reviewer: fix snackbar position when answer buttons are hidden

      +
    • +
    +
    +
  • +
+
+
+

Full list of 2.24.1 fixes

+
+
+

Version 2.24.0 (20260502)

@@ -520,6 +1119,9 @@

+ +
  • Sync: Added 'Network timeout' preference

  • @@ -1863,6 +2465,85 @@

    +

    Version 2.19.6 (20260831)

    +
    +
    +

    Urgent update to our 2.19.5 release: security updates, and temporarily removing donation links to +comply with Play Store policies (more below).

    +
    +
    +

    A special thank you to the following people for reporting the security issues fixed in this release:

    +
    +
    + +
    +
    +

    Removal of donation links: Please see GitHub Issue 21656 for why this has happened and how +to help us with this.

    +
    +
    +

    Please see the 2.19.1 changelog below for the main 2.19 features announcement 👇

    +
    +
    +
      +
    • +

      Security

      +
      +
        +
      • +

        Reviewer: cards can no longer open dangerous links [GHSL-2026-085, GHSA-54q9-5c5p-9rxg]

        +
      • +
      • +

        Reviewer: cards require permission to launch apps [GHSL-2026-085, GHSA-54q9-5c5p-9rxg]

        +
        +
          +
        • +

          May be unblocked in advanced settings, if all your cards are trustworthy.

          +
        • +
        +
        +
      • +
      • +

        JavaScript API: blocked calls which could read and modify cards (other than the currently selected card) [GHSL-2026-084, GHSA-54q9-5c5p-9rxg]

        +
        +
          +
        • +

          May be unblocked in advanced settings, if all your cards are trustworthy.

          +
        • +
        +
        +
      • +
      • +

        Reviewer: fix path traversal when loading media [GHSA-qjjx-px4w-4986]

        +
      • +
      • +

        Import: sanitize filenames to block path traversal [GHSA-q29p-h3pp-mh3v]

        +
      • +
      +
      +
    • +
    • +

      Improvements

      +
      +
        +
      • +

        Google Play: 'Donate' links removed to comply with Play policies

        +
      • +
      +
      +
    • +
    +
    +
    +

  • +

    Version 2.19.5 (20250711)

    @@ -2795,6 +3476,85 @@

    +

    Version 2.16.6 (20260831)

    +
    +
    +

    Urgent update to our 2.16.5 release: security updates, and temporarily removing donation links to +comply with Play Store policies (more below).

    +
    +
    +

    A special thank you to the following people for reporting the security issues fixed in this release:

    +
    +
    + +
    +
    +

    Removal of donation links: Please see GitHub Issue 21656 for why this has happened and how +to help us with this.

    +
    +
    +

    Please see the 2.16.2 changelog below for the main 2.16 features announcement 👇

    +
    +
    +
      +
    • +

      Security

      +
      +
        +
      • +

        Reviewer: cards can no longer open dangerous links [GHSL-2026-085, GHSA-54q9-5c5p-9rxg]

        +
      • +
      • +

        Reviewer: cards require permission to launch apps [GHSL-2026-085, GHSA-54q9-5c5p-9rxg]

        +
        +
          +
        • +

          May be unblocked in advanced settings, if all your cards are trustworthy.

          +
        • +
        +
        +
      • +
      • +

        JavaScript API: blocked calls which could read and modify cards (other than the currently selected card) [GHSL-2026-084, GHSA-54q9-5c5p-9rxg]

        +
        +
          +
        • +

          May be unblocked in advanced settings, if all your cards are trustworthy.

          +
        • +
        +
        +
      • +
      • +

        Reviewer: fix path traversal when loading media [GHSA-qjjx-px4w-4986]

        +
      • +
      • +

        Import: sanitize filenames to block path traversal [GHSA-q29p-h3pp-mh3v]

        +
      • +
      +
      +
    • +
    • +

      Improvements

      +
      +
        +
      • +

        Google Play: 'Donate' links removed to comply with Play policies

        +
      • +
      +
      +
    • +
    +
    +
    +

    +

    Version 2.16.5 (20230906)

    @@ -5833,8 +6593,8 @@

    <

    - \ No newline at end of file + From d77eb29ce5fc6d6f74d114474df7c66a7f7cb3f7 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:20:46 +0100 Subject: [PATCH 054/163] docs(ci): explain screenshot dependency Assisted-by: GPT-6-Astra xhigh --- .github/workflows/screenshot_compare.yml | 3 ++- .github/workflows/screenshot_store.yml | 5 +++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/screenshot_compare.yml b/.github/workflows/screenshot_compare.yml index 963f72813ed3..035035941021 100644 --- a/.github/workflows/screenshot_compare.yml +++ b/.github/workflows/screenshot_compare.yml @@ -57,7 +57,8 @@ jobs: gradle-version: wrapper - name: Warm Gradle Cache - # This makes sure we fetch gradle network resources with a retry + # Keep compilation here: robolectricSdkDownload only downloads SDK jars. + # Compiling resolves test dependencies within the retry (Issue 21682). uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 with: timeout_minutes: 10 diff --git a/.github/workflows/screenshot_store.yml b/.github/workflows/screenshot_store.yml index fab55ea32b01..09704c853715 100644 --- a/.github/workflows/screenshot_store.yml +++ b/.github/workflows/screenshot_store.yml @@ -71,8 +71,9 @@ jobs: gradle-version: wrapper - name: Warm Gradle Cache - # This makes sure we fetch gradle network resources with a retry - # TODO: --rerun-tasks in 'Record screenshots' discards the compilation performed here + # Keep compilation here: robolectricSdkDownload only downloads SDK jars. + # Compiling resolves test dependencies within the retry (Issue 21682). + # --rerun-tasks repeats compilation but reuses downloaded dependencies. uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 with: timeout_minutes: 10 From 3fd4723cf609e6eb411141e9574348af87055ac7 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:45:21 +0100 Subject: [PATCH 055/163] test: move `ScreenshotTestCategory` to `:common` fixtures Assisted-by: GPT-6-Astra xhigh --- AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt | 2 -- .../java/com/ichi2/anki/ScreenshotTestCategory.kt | 6 ++++++ note-editor/build.gradle.kts | 1 + 3 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 common/android/src/testFixtures/java/com/ichi2/anki/ScreenshotTestCategory.kt diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt index f0dd6c3072fb..23f5ae505bd1 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt @@ -31,8 +31,6 @@ import org.robolectric.shadow.api.Shadow import org.robolectric.shadows.ShadowWindowManagerImpl import java.io.File -interface ScreenshotTestCategory - /** * Base class for [roborazzi](https://github.com/takahirom/roborazzi) screenshot tests */ diff --git a/common/android/src/testFixtures/java/com/ichi2/anki/ScreenshotTestCategory.kt b/common/android/src/testFixtures/java/com/ichi2/anki/ScreenshotTestCategory.kt new file mode 100644 index 000000000000..64523fde0595 --- /dev/null +++ b/common/android/src/testFixtures/java/com/ichi2/anki/ScreenshotTestCategory.kt @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki + +/** Screenshot tests selected by the root screenshot commands or `-Pscreenshot`. */ +interface ScreenshotTestCategory diff --git a/note-editor/build.gradle.kts b/note-editor/build.gradle.kts index 909b56ebf9ec..3271cbaf8199 100644 --- a/note-editor/build.gradle.kts +++ b/note-editor/build.gradle.kts @@ -16,6 +16,7 @@ dependencies { implementation(project(":common:android")) implementation(libs.androidx.core) + testImplementation(testFixtures(project(":common:android"))) testImplementation(libs.robolectric) testImplementation(libs.roborazzi) testImplementation(libs.junit.vintage.engine) From 3e6e58372e964e5973a86508e957121f169e1ff4 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:35:21 +0100 Subject: [PATCH 056/163] build: move Roborazzi to convention plugin Allows defining common logic across all of AnkiDroid Assisted-by: GPT-6-Astra xhigh --- AnkiDroid/build.gradle.kts | 2 +- buildSrc/build.gradle.kts | 1 + .../src/main/kotlin/ankidroid.plugins.screenshots.gradle.kts | 5 +++++ note-editor/build.gradle.kts | 2 +- 4 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 buildSrc/src/main/kotlin/ankidroid.plugins.screenshots.gradle.kts diff --git a/AnkiDroid/build.gradle.kts b/AnkiDroid/build.gradle.kts index f285bf7bf7d4..a7f8e71a63cd 100644 --- a/AnkiDroid/build.gradle.kts +++ b/AnkiDroid/build.gradle.kts @@ -18,7 +18,7 @@ plugins { alias(libs.plugins.androidx.baselineprofile) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.keeper) - alias(libs.plugins.roborazzi) + id("ankidroid.plugins.screenshots") id("idea") } diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index d4ab4c15c98d..4e23c3df1619 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -20,6 +20,7 @@ dependencies { // Force the catalog version of KGP, otherwise it's overridden by AGP. implementation("org.jetbrains.kotlin:kotlin-gradle-plugin:${libs.versions.kotlin.get()}") implementation("org.jetbrains.kotlin:compose-compiler-gradle-plugin:${libs.versions.kotlin.get()}") + implementation("io.github.takahirom.roborazzi:roborazzi-gradle-plugin:${libs.versions.roborazzi.get()}") testImplementation(gradleTestKit()) testImplementation(libs.junit.jupiter) testRuntimeOnly(libs.junit.platform.launcher) diff --git a/buildSrc/src/main/kotlin/ankidroid.plugins.screenshots.gradle.kts b/buildSrc/src/main/kotlin/ankidroid.plugins.screenshots.gradle.kts new file mode 100644 index 000000000000..554e70f56602 --- /dev/null +++ b/buildSrc/src/main/kotlin/ankidroid.plugins.screenshots.gradle.kts @@ -0,0 +1,5 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +plugins { + id("io.github.takahirom.roborazzi") +} diff --git a/note-editor/build.gradle.kts b/note-editor/build.gradle.kts index 3271cbaf8199..7b200457f99a 100644 --- a/note-editor/build.gradle.kts +++ b/note-editor/build.gradle.kts @@ -5,7 +5,7 @@ import com.android.build.api.dsl.LibraryExtension plugins { id("ankidroid.android.library") id("ankidroid.android.compose") - alias(libs.plugins.roborazzi) + id("ankidroid.plugins.screenshots") } configure { From d2906883e9cb7f45adddcd31e015b79667a8db9f Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:38:03 +0100 Subject: [PATCH 057/163] build: share screenshot commands across modules Add the following for all screenshot-enabled modules: * compileScreenshotTests * recordScreenshots * compareScreenshots * verifyScreenshots `Debug` for Libraries; `PlayDebug` for the app. Moves `-Pscreenshot` into the convention plugin, required for `record`; `compare` and `verify` Assisted-by: GPT-6-Astra xhigh --- AnkiDroid/build.gradle.kts | 4 --- .../ankidroid.plugins.screenshots.gradle.kts | 33 +++++++++++++++++++ 2 files changed, 33 insertions(+), 4 deletions(-) diff --git a/AnkiDroid/build.gradle.kts b/AnkiDroid/build.gradle.kts index a7f8e71a63cd..772789c94c5b 100644 --- a/AnkiDroid/build.gradle.kts +++ b/AnkiDroid/build.gradle.kts @@ -333,8 +333,6 @@ android { test.useJUnitPlatform { // ./gradlew testFullDebugUnitTest -Pscreenshot if (project.hasProperty("screenshot")) { - includeTags("com.ichi2.anki.ScreenshotTestCategory") - // ./gradlew :AnkiDroid:recordRoborazziPlayDebug -Pscreenshot -Ptheme=black -Pdevice=tablet,foldable val theme = project.findProperty("theme")?.takeIf { it.toString().isNotEmpty() } ?: "light" val device = project.findProperty("device")?.takeIf { it.toString().isNotEmpty() } ?: "phone" @@ -357,8 +355,6 @@ android { test.systemProperty("screenshot.theme", theme) test.systemProperty("screenshot.device", device) - } else { - excludeTags("com.ichi2.anki.ScreenshotTestCategory") } // Ensures that no EmptyApplication test relies on AnkiDroidApp // by only running EmptyApplication tests diff --git a/buildSrc/src/main/kotlin/ankidroid.plugins.screenshots.gradle.kts b/buildSrc/src/main/kotlin/ankidroid.plugins.screenshots.gradle.kts index 554e70f56602..094474d8a7ba 100644 --- a/buildSrc/src/main/kotlin/ankidroid.plugins.screenshots.gradle.kts +++ b/buildSrc/src/main/kotlin/ankidroid.plugins.screenshots.gradle.kts @@ -1,5 +1,38 @@ // SPDX-License-Identifier: GPL-3.0-or-later +// Apply after the Android app/library plugin. Gradle runs these aliases in every +// participating module: ./gradlew compareScreenshots -Pscreenshot plugins { id("io.github.takahirom.roborazzi") } + +val screenshotRun = providers.gradleProperty("screenshot").isPresent +val variant = if (plugins.hasPlugin("com.android.application")) "PlayDebug" else "Debug" + +tasks.register("compileScreenshotTests") { + group = "verification" + description = "Compiles $variant screenshot tests without running them." + dependsOn("compile${variant}UnitTestSources") +} + +listOf("record", "compare", "verify").forEach { mode -> + tasks.register("${mode}Screenshots") { + group = "verification" + description = "Runs Roborazzi $mode for $variant (requires -Pscreenshot)." + if (screenshotRun) { + dependsOn("${mode}Roborazzi$variant") + } else { + doLast { throw GradleException("Screenshot tasks require -Pscreenshot") } + } + } +} + +tasks.withType().configureEach { + useJUnitPlatform { + if (screenshotRun) { + includeTags("com.ichi2.anki.ScreenshotTestCategory") + } else { + excludeTags("com.ichi2.anki.ScreenshotTestCategory") + } + } +} From 336955916ff604f5ab9faa5fb37ed1168d8b93db Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:39:11 +0100 Subject: [PATCH 058/163] test: clear obsolete differences Previously, the following left stale images * Change a screen and compare * Restore the screen and rerun: passes but the previously generated images could remain Now, obsolete differences are cleared: `cleanScreenshotDiffs` removes these files and intermediate copies. A new `/build/reports/screenshot-diffs/` is added; as prep for CI to use. It contains only `_compare` images; see `collectScreenshotDiffs` Assisted-by: GPT-6-Astra xhigh --- buildSrc/build.gradle.kts | 2 + .../ankidroid.plugins.screenshots.gradle.kts | 30 ++++ .../ichi2/anki/gradle/ScreenshotPluginTest.kt | 138 ++++++++++++++++++ 3 files changed, 170 insertions(+) create mode 100644 buildSrc/src/test/kotlin/com/ichi2/anki/gradle/ScreenshotPluginTest.kt diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 4e23c3df1619..a25c793d3d40 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -30,6 +30,8 @@ tasks.test { useJUnitPlatform() // Rerun the path guard if the app build file is moved. inputs.files("../AnkiDroid/build.gradle.kts") + // The screenshot fixture uses the same Android SDK as the main build. + inputs.file("../gradle/libs.versions.toml") } configure { diff --git a/buildSrc/src/main/kotlin/ankidroid.plugins.screenshots.gradle.kts b/buildSrc/src/main/kotlin/ankidroid.plugins.screenshots.gradle.kts index 094474d8a7ba..ca5a90687d3d 100644 --- a/buildSrc/src/main/kotlin/ankidroid.plugins.screenshots.gradle.kts +++ b/buildSrc/src/main/kotlin/ankidroid.plugins.screenshots.gradle.kts @@ -27,6 +27,31 @@ listOf("record", "compare", "verify").forEach { mode -> } } +val cleanScreenshotDiffs = + tasks.register("cleanScreenshotDiffs") { + // Keep baselines, including when running a filtered subset of tests. + // Match generated filenames: a directory named `diffs` may contain baselines. + // Clear cached copies too, so Roborazzi cannot restore obsolete comparisons. + listOf("outputs/roborazzi", "intermediates/roborazzi").forEach { directory -> + delete( + fileTree(layout.buildDirectory.dir(directory)) { + include("**/*_compare.png", "**/*_actual.png") + }, + ) + } + } + +val collectScreenshotDiffs = + tasks.register("collectScreenshotDiffs") { + // Roborazzi declares this directory as a test output, including cached runs. + from(layout.buildDirectory.dir("intermediates/roborazzi")) { + include("**/*_compare.png") + } + into(layout.buildDirectory.dir("reports/screenshot-diffs")) + includeEmptyDirs = false + mustRunAfter("finalizeTestRoborazzi$variant") + } + tasks.withType().configureEach { useJUnitPlatform { if (screenshotRun) { @@ -35,4 +60,9 @@ tasks.withType().configureEach { excludeTags("com.ichi2.anki.ScreenshotTestCategory") } } + if (screenshotRun && name == "test${variant}UnitTest") { + dependsOn(cleanScreenshotDiffs) + // Keep comparisons available even when verification fails. + finalizedBy(collectScreenshotDiffs) + } } diff --git a/buildSrc/src/test/kotlin/com/ichi2/anki/gradle/ScreenshotPluginTest.kt b/buildSrc/src/test/kotlin/com/ichi2/anki/gradle/ScreenshotPluginTest.kt new file mode 100644 index 000000000000..7808946730a9 --- /dev/null +++ b/buildSrc/src/test/kotlin/com/ichi2/anki/gradle/ScreenshotPluginTest.kt @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.gradle + +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import java.io.File + +/** Test of `ankidroid.plugins.screenshots.gradle.kts` */ +class ScreenshotPluginTest { + @TempDir + lateinit var projectDir: File + + @Test + fun `matching screenshots clear previous diffs including cached results`() { + writeFixture() + // Preserve baselines not exercised by this run, even inside a directory named 'diffs'. + // Populate that directory up front so creating it cannot change the comparison's cache key. + val baselines = mapOf("Example/screen.png" to "baseline", "Example/diffs/other.png" to "unselected baseline") + val diffs = mapOf("Example/diffs/screen_compare.png" to "comparison", "Example/diffs/screen_actual.png" to "actual") + baselines.forEach { (path, contents) -> + writeFile("build/outputs/roborazzi/$path", contents) + } + + val runner = + GradleRunner + .create() + .withProjectDir(projectDir) + .withTestKitDir(File("build/test-kit").absoluteFile) + .withPluginClasspath() + .withArguments("compareScreenshots", "-Pscreenshot", "--build-cache", "--configuration-cache", "--stacktrace") + + // Run changed → matching once normally, then repeat with both results restored from cache. + listOf(TaskOutcome.SUCCESS, TaskOutcome.FROM_CACHE).forEach { outcome -> + listOf(true, false).forEach { changed -> + writeFile("changed.txt", changed.toString()) + val result = runner.build() + assertEquals(outcome, result.task(":testDebugUnitTest")?.outcome, result.output) + val expectedDiffs = if (changed) diffs else emptyMap() + listOf("outputs/roborazzi", "intermediates/roborazzi").forEach { directory -> + assertEquals(baselines + expectedDiffs, fileContents(directory), "$directory: $outcome, changed=$changed") + } + if (outcome == TaskOutcome.FROM_CACHE) { + assertTrue(result.output.contains("Reusing configuration cache.")) + assertEquals(expectedDiffs.filterKeys { it.endsWith("_compare.png") }, fileContents("reports/screenshot-diffs")) + } + } + } + } + + private fun fileContents(directory: String): Map { + val root = File(projectDir, "build/$directory") + return root + .walkTopDown() + .filter { it.isFile } + .associate { it.relativeTo(root).invariantSeparatorsPath to it.readText() } + } + + private fun writeFixture() { + File("../gradle/libs.versions.toml").copyTo(File(projectDir, "libs.versions.toml")) + File("../local.properties").takeIf { it.isFile }?.copyTo(File(projectDir, "local.properties")) + writeFile( + "settings.gradle", + """ + rootProject.name = 'screenshot-cleanup-regression' + dependencyResolutionManagement { + repositories { google(); mavenCentral() } + versionCatalogs { libs { from(files('libs.versions.toml')) } } + } + buildCache { local { directory = file('build-cache') } } + """.trimIndent(), + ) + writeFile("gradle.properties", "android.builtInKotlin=false\nandroid.newDsl=false\norg.gradle.workers.max=2\n") + + writeFile( + "build.gradle", + """ + plugins { + id 'com.android.library' + id 'ankidroid.plugins.screenshots' + } + android { + namespace = 'regression.screenshots' + compileSdk = libs.versions.compileSdk.get().toInteger() + compileSdkMinor = libs.versions.compileSdkMinor.get().toInteger() + defaultConfig { minSdk = 24 } + } + dependencies { + testImplementation libs.junit.jupiter + testRuntimeOnly libs.junit.platform.launcher + } + tasks.withType(Test).configureEach { + inputs.file('changed.txt') + } + """.trimIndent(), + ) + // Pixel comparison is outside this test's scope. Emit a renderer's diff files and let + // the real Roborazzi and screenshot plugins handle copying, cleanup and cache restoration. + writeFile( + "src/test/java/regression/ScreenshotTest.java", + """ + package regression; + + import java.nio.file.Files; + import java.nio.file.Path; + import org.junit.jupiter.api.Tag; + import org.junit.jupiter.api.Test; + + @Tag("com.ichi2.anki.ScreenshotTestCategory") + public class ScreenshotTest { + @Test + void screenshot() throws Exception { + if (Files.readString(Path.of("changed.txt")).equals("true")) { + Path diffs = Path.of("build/outputs/roborazzi/Example/diffs"); + Files.createDirectories(diffs); + Files.writeString(diffs.resolve("screen_compare.png"), "comparison"); + Files.writeString(diffs.resolve("screen_actual.png"), "actual"); + } + } + } + """.trimIndent(), + ) + } + + private fun writeFile( + path: String, + contents: String, + ) { + File(projectDir, path).apply { + parentFile.mkdirs() + writeText(contents) + } + } +} From 530d1457de6d43c034172b8609812f9b4e92f526 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:39:58 +0100 Subject: [PATCH 059/163] feat(ci): add screenshots tests for all modules Use the new `compileScreenshotTests` and look for data in the new `/screenshot-diffs` directory. This is future support to allow `:note-editor` to submit screenshot diffs. Assisted-by: GPT-6-Astra xhigh --- .github/workflows/screenshot_comment.yml | 9 +++++---- .github/workflows/screenshot_compare.yml | 25 +++++++++++------------- .github/workflows/screenshot_store.yml | 13 +++++++----- 3 files changed, 24 insertions(+), 23 deletions(-) diff --git a/.github/workflows/screenshot_comment.yml b/.github/workflows/screenshot_comment.yml index 1e994a187b84..a9c8698c0a81 100644 --- a/.github/workflows/screenshot_comment.yml +++ b/.github/workflows/screenshot_comment.yml @@ -64,7 +64,7 @@ jobs: name: Check if there are valid files shell: bash run: | - # Find Roborazzi diff PNGs in the downloaded artifact (in /). + # Find Roborazzi diff PNGs in the downloaded artifact (in //). mapfile -t files_to_add < <(find . -name "*_compare.png" -type f) if [ ${#files_to_add[@]} -gt 0 ]; then exist_valid_files="true" @@ -82,17 +82,18 @@ jobs: BASE_REF: ${{ github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.pull_requests[0].base.ref || github.event.repository.default_branch }} shell: bash run: | - # Find Roborazzi diff PNGs to mention in the comment (in /). + # Find Roborazzi diff PNGs to mention in the comment (in //). mapfile -t files < <(find . -name "*_compare.png" -type f | sort) total=${#files[@]} # Cap the full list so we remain within GitHub's comment limit max_details=100 - # Group by test class - the file's immediate parent directory. + # Group by module and test class, after the artifact's per-run directory. declare -A class_counts class_files for file in "${files[@]}"; do class_dir="${file%/*}" - class="${class_dir##*/}" + class="${class_dir#./screenshot-diff/}" + class="${class#*/}" class_counts[$class]=$(( ${class_counts[$class]:-0} + 1 )) class_files[$class]+="$(basename "$file")"$'\n' done diff --git a/.github/workflows/screenshot_compare.yml b/.github/workflows/screenshot_compare.yml index 035035941021..60595f5d2c13 100644 --- a/.github/workflows/screenshot_compare.yml +++ b/.github/workflows/screenshot_compare.yml @@ -64,7 +64,7 @@ jobs: timeout_minutes: 10 retry_wait_seconds: 60 max_attempts: 3 - command: ./gradlew robolectricSdkDownload :AnkiDroid:compilePlayDebugUnitTestKotlin -Pscreenshot --daemon + command: ./gradlew robolectricSdkDownload compileScreenshotTests -Pscreenshot --daemon - name: Download base branch screenshots uses: dawidd6/action-download-artifact@eab87c9830c39eff17e5a6eadb20bfb4bc880477 # v27 @@ -78,7 +78,7 @@ jobs: - name: Compare screenshots id: compare-screenshot-test run: | - ./gradlew compareRoborazziPlayDebug -Pscreenshot --stacktrace + ./gradlew compareScreenshots -Pscreenshot --stacktrace - name: Stage screenshot diffs if: ${{ always() }} @@ -86,18 +86,15 @@ jobs: # Folder name inside the artifact (e.g. "screenshot-diff-pr-12345" on PRs, "screenshot-diff-main" on push) SUBDIR: ${{ github.event_name == 'pull_request' && format('screenshot-diff-pr-{0}', github.event.number) || format('screenshot-diff-{0}', github.ref_name) }} run: | - # Trim 'AnkiDroid/build/outputs/roborazzi' from the artifact - # Only keep the _compare images of failing cases: /diffs/*_compare.png moves to / + # Preserve module paths so identically named tests cannot overwrite one another. mkdir -p "screenshot-diff/$SUBDIR" - shopt -s nullglob - for diffs_dir in AnkiDroid/build/outputs/roborazzi/*/diffs; do - class_name="$(basename "$(dirname "$diffs_dir")")" - compares=("$diffs_dir"/*_compare.png) - if (( ${#compares[@]} )); then - mkdir -p "screenshot-diff/$SUBDIR/$class_name" - mv "${compares[@]}" "screenshot-diff/$SUBDIR/$class_name/" - fi - done + while IFS= read -r -d '' compare; do + module="${compare%%/build/reports/screenshot-diffs/*}" + relative="${compare#*/build/reports/screenshot-diffs/}" + destination="screenshot-diff/$SUBDIR/${module#./}/$relative" + mkdir -p "$(dirname "$destination")" + cp "$compare" "$destination" + done < <(find . -type f -path '*/build/reports/screenshot-diffs/*_compare.png' -print0) - name: Upload screenshot diffs uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -134,4 +131,4 @@ jobs: uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 with: name: pr # downloaded by screenshot_comment.yml - path: pr/ \ No newline at end of file + path: pr/ diff --git a/.github/workflows/screenshot_store.yml b/.github/workflows/screenshot_store.yml index 09704c853715..56781679b1b1 100644 --- a/.github/workflows/screenshot_store.yml +++ b/.github/workflows/screenshot_store.yml @@ -32,9 +32,12 @@ on: paths: - '.github/workflows/screenshot_*.yml' - 'gradle/libs.versions.toml' - - 'AnkiDroid/build.gradle' - - 'AnkiDroid/build.gradle.kts' # future-proof + - '**/build.gradle' + - '**/build.gradle.kts' + - 'settings.gradle.kts' - '**/ScreenshotTest.kt' + - '**/ScreenshotTestCategory.kt' + - 'buildSrc/src/main/kotlin/ankidroid.plugins.screenshots.gradle.kts' - 'tools/compare-screenshot-test.sh' permissions: {} @@ -79,13 +82,13 @@ jobs: timeout_minutes: 10 retry_wait_seconds: 60 max_attempts: 3 - command: ./gradlew robolectricSdkDownload :AnkiDroid:compilePlayDebugUnitTestKotlin -Pscreenshot --daemon + command: ./gradlew robolectricSdkDownload compileScreenshotTests -Pscreenshot --daemon - name: Record screenshots id: record-test run: | # Use --rerun-tasks to disable cache for test task - ./gradlew recordRoborazziPlayDebug -Pscreenshot --stacktrace --rerun-tasks + ./gradlew recordScreenshots -Pscreenshot --stacktrace --rerun-tasks - name: Upload screenshot baseline uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 @@ -112,4 +115,4 @@ jobs: name: screenshot-test-results path: | **/build/test-results - retention-days: 30 \ No newline at end of file + retention-days: 30 From 87dc3a7b86200774fc7142f93f6c462bdba8ef06 Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:43:47 +0530 Subject: [PATCH 060/163] fix(navigation-drawer): don't crash on a config change before setup On first launch the Deck Picker finishes in onCreate to show the app intro, before the navigation drawer is set up. A rotation the system had already queued still reached onConfigurationChanged and crashed on the uninitialized drawerToggle. Skip the toggle until it exists. --- .../ichi2/anki/NavigationDrawerActivity.kt | 4 +++- .../java/com/ichi2/anki/DeckPickerTest.kt | 19 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NavigationDrawerActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NavigationDrawerActivity.kt index b4c4adbed9b4..f3936f4ccff7 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/NavigationDrawerActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/NavigationDrawerActivity.kt @@ -306,7 +306,9 @@ abstract class NavigationDrawerActivity( override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) // Pass any configuration change to the drawer toggles - drawerToggle.onConfigurationChanged(newConfig) + if (::drawerToggle.isInitialized) { + drawerToggle.onConfigurationChanged(newConfig) + } } /** diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt index 4809a599c878..43a69b7a0055 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt @@ -8,6 +8,7 @@ import android.content.DialogInterface import android.content.Intent import android.content.SharedPreferences import android.content.pm.PackageManager +import android.content.res.Configuration import android.database.sqlite.SQLiteDatabaseCorruptException import android.os.Bundle import android.os.SystemClock @@ -1734,6 +1735,24 @@ class DeckPickerTest : RobolectricTest() { ) } + @Test + fun `rotating while finishing to show the App Intro does not crash`() { + setIntroductionSlidesShown(false) + val controller = Robolectric.buildActivity(DeckPicker::class.java, Intent()).create() + saveControllerForCleanup(controller) + val deckPicker = controller.get() + assertTrue(deckPicker.isFinishing, "DeckPicker should finish to show the App Intro") + + val current = deckPicker.resources.configuration + val rotated = + Configuration(current).apply { + orientation = Configuration.ORIENTATION_LANDSCAPE + screenWidthDp = current.screenHeightDp + screenHeightDp = current.screenWidthDp + } + assertDoesNotThrow { controller.configurationChange(rotated, deckPicker.resources.displayMetrics) } + } + @Test fun `startup response is cleared after handling so it does not re-run on resume`() = deckPicker { From 099055ffafc6e66a1193b55fb847a9b2045d76ad Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Mon, 19 Oct 2026 03:45:19 +0530 Subject: [PATCH 061/163] test(profiles): fix flaky profile ID uniqueness test The test made 1000 random profile IDs and expected no duplicates, but with 32 random bits that fails about once in 8600 runs. It now checks the ID format and randomness only, and a new test covers the retry that keeps a new profile ID from matching an existing one. Fixes 21909 Assisted-by: Claude Opus 5.5 --- .../ichi2/anki/multiprofile/ProfileIdTest.kt | 5 +---- .../anki/multiprofile/ProfileManagerTest.kt | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileIdTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileIdTest.kt index ba1a7974bb94..0fb8f1dd1c3f 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileIdTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileIdTest.kt @@ -16,7 +16,7 @@ class ProfileIdTest { } @Test - fun `generate creates unique IDs using the purely random portion of a UUID`() { + fun `generate uses the purely random portion of a UUID`() { val iterations = 1000 val generatedIds = List(iterations) { ProfileId.generate().value } @@ -25,9 +25,6 @@ class ProfileIdTest { assertEquals("ID length should be 10 (p_ + 8 hex chars)", 10, id.length) } - val uniqueIds = generatedIds.toSet() - assertEquals("All $iterations generated IDs should be unique", iterations, uniqueIds.size) - val prefixLength = 2 val hexLength = 8 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 0bfb264d80c9..d0d64ecec5d8 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileManagerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileManagerTest.kt @@ -35,6 +35,7 @@ import org.junit.jupiter.api.Assertions.assertThrows import org.junit.runner.RunWith import org.robolectric.annotation.Config import java.io.File +import java.util.UUID import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -340,6 +341,24 @@ class ProfileManagerTest { assertTrue(allProfiles.containsKey(ProfileId.DEFAULT)) } + @Test + fun `a new profile ID that matches an existing profile is generated again`() { + val manager = ProfileManager.create(context) + mockkStatic(UUID::class) + every { UUID.randomUUID() } returnsMany + listOf( + UUID.fromString("aaaaaaaa-0000-4000-8000-000000000000"), + UUID.fromString("aaaaaaaa-1111-4111-8111-111111111111"), + UUID.fromString("bbbbbbbb-0000-4000-8000-000000000000"), + ) + + val first = manager.createNewProfile(ProfileName.fromTrustedSource("Work")) + val second = manager.createNewProfile(ProfileName.fromTrustedSource("Personal")) + + assertEquals(ProfileId("p_aaaaaaaa"), first) + assertEquals(ProfileId("p_bbbbbbbb"), second) + } + @Test fun `renameProfile updates displayName in registry`() { val manager = ProfileManager.create(context) From b7dcd5c8c9fd7097f4edd6603d85b57ba11c6c35 Mon Sep 17 00:00:00 2001 From: Kushagra Date: Mon, 19 Oct 2026 09:08:47 +0530 Subject: [PATCH 062/163] test: add fallback filename test for ImportUtils.validateFileName --- .../src/main/java/com/ichi2/utils/ImportUtils.kt | 2 -- .../test/java/com/ichi2/utils/ImportUtilsTest.kt | 15 +++++++++++++++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/ImportUtils.kt b/AnkiDroid/src/main/java/com/ichi2/utils/ImportUtils.kt index b5443720bb4c..03434a14a076 100644 --- a/AnkiDroid/src/main/java/com/ichi2/utils/ImportUtils.kt +++ b/AnkiDroid/src/main/java/com/ichi2/utils/ImportUtils.kt @@ -20,7 +20,6 @@ import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CommonString import com.ichi2.anki.R import com.ichi2.anki.common.android.appContext -import com.ichi2.anki.common.annotations.NeedsTest import com.ichi2.anki.common.coroutines.applicationScope import com.ichi2.anki.common.crashreporting.CrashReportService import com.ichi2.anki.common.exception.ManuallyReportedException @@ -311,7 +310,6 @@ object ImportUtils { } } - @NeedsTest("Add test for the fallback, ensure the fallback filename \"file_.\" is produced when decoding fails") private fun validateFileName(fileName: String): String { // #6137 - filenames can be too long when URLEncoded val sanitized = sanitizeFileName(fileName) diff --git a/AnkiDroid/src/test/java/com/ichi2/utils/ImportUtilsTest.kt b/AnkiDroid/src/test/java/com/ichi2/utils/ImportUtilsTest.kt index 5e3026532961..89558906f83c 100644 --- a/AnkiDroid/src/test/java/com/ichi2/utils/ImportUtilsTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/utils/ImportUtilsTest.kt @@ -15,6 +15,8 @@ import com.ichi2.anki.CommonString import com.ichi2.anki.RobolectricTest import com.ichi2.anki.common.crashreporting.CrashReportService import com.ichi2.anki.common.crashreporting.CrashReporter +import com.ichi2.anki.common.time.MockTime +import com.ichi2.anki.common.time.TimeManager import com.ichi2.utils.ImportUtils.FileImporter import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.containsString @@ -255,6 +257,19 @@ class ImportUtilsTest : RobolectricTest() { assertEquals(expectedFilepath, actualFilepath) } + @Test + fun getFileCachedCopyUsesFallbackFileNameWhenDecodingFails() { + TimeManager.resetWith(MockTime(1_600_000_000_000)) + val prefix = "a".repeat(89) + val invalidShortenedFileName = "${prefix}ääääääääää.apkg" + val expectedFallbackFileName = "file_1600000000000.apkg" + val expectedFilepath = File(targetContext.cacheDir, expectedFallbackFileName).absolutePath + + val actualFilepath = TestFileImporter(invalidShortenedFileName).getFileCachedCopy(targetContext, "dummy".toUri()) + + assertEquals(expectedFilepath, actualFilepath) + } + @Test fun collectionApkgIsValid() { assertTrue(ImportUtils.isValidPackageName("collection.apkg")) From 2dd553e49899884007b28e7491e8dbbcaf5dd390 Mon Sep 17 00:00:00 2001 From: Kushagra Date: Mon, 19 Oct 2026 09:54:09 +0530 Subject: [PATCH 063/163] test: add modifier and cloze tests for previewer TypeAnswer --- .../com/ichi2/anki/previewer/TypeAnswer.kt | 6 +- .../ichi2/anki/previewer/TypeAnswerTest.kt | 56 +++++++++++++++++++ 2 files changed, 57 insertions(+), 5 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/TypeAnswer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/TypeAnswer.kt index 86ea68b09870..e783909a25e4 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/TypeAnswer.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/TypeAnswer.kt @@ -5,7 +5,6 @@ package com.ichi2.anki.previewer import android.os.LocaleList import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.cardviewer.TypeAnswerModifiers -import com.ichi2.anki.common.annotations.NeedsTest import com.ichi2.anki.libanki.Card import com.ichi2.anki.libanki.Field import com.ichi2.anki.servicelayer.LanguageHintService @@ -20,13 +19,11 @@ import org.jetbrains.annotations.VisibleForTesting * @see [imeHintLocales] * @see [noSuggest] * */ -@NeedsTest("combining and non combining answers are properly parsed") -@NeedsTest("nosuggest modifier is parsed and composes with nc/cloze") class TypeAnswer private constructor( private val text: String, /** whether combining characters should be compared. Defined by the presence of the * `nc:` specifier in the type answer tag */ - private val combining: Boolean, + val combining: Boolean, /** * Whether keyboard suggestions, swiping and autocorrect should be disabled (#10352). * @@ -88,7 +85,6 @@ class TypeAnswer private constructor( * @param isCloze whether the placeholder was a `cloze:` type filter * @param fieldName the name of the field in the card template */ - @NeedsTest("cloze type-in-answer are properly parsed") private suspend fun getExpectedTypeInAnswer( card: Card, isCloze: Boolean, diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/previewer/TypeAnswerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/previewer/TypeAnswerTest.kt index 38ffabf6ce7c..66c35dc5135c 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/previewer/TypeAnswerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/previewer/TypeAnswerTest.kt @@ -65,6 +65,62 @@ class TypeAnswerTest : JvmTest() { assertThat(typeAnswer.noSuggest, equalTo(true)) } + @Test + fun `combining is true when nc modifier is absent`() = + runTest { + val card = addBasicWithTypingNote("front", "back").firstCard() + val typeAnswer = requireNotNull(TypeAnswer.getInstance(card, "[[type:Back]]")) + assertThat(typeAnswer.combining, equalTo(true)) + } + + @Test + fun `combining is false when nc modifier is present`() = + runTest { + val card = addBasicWithTypingNote("front", "back").firstCard() + val typeAnswer = requireNotNull(TypeAnswer.getInstance(card, "[[type:nc:Back]]")) + assertThat(typeAnswer.combining, equalTo(false)) + } + + @Test + fun `nosuggest composes with cloze modifier`() = + runTest { + val card = addClozeNote("{{c1::hello}} world").firstCard() + val typeAnswer = requireNotNull(TypeAnswer.getInstance(card, "[[type:nosuggest:cloze:Text]]")) + assertThat(typeAnswer.noSuggest, equalTo(true)) + assertThat(typeAnswer.expectedAnswer, equalTo("hello")) + } + + @Test + fun `nosuggest composes with cloze and nc modifiers`() = + runTest { + val card = addClozeNote("{{c1::hello}} world").firstCard() + val typeAnswer = requireNotNull(TypeAnswer.getInstance(card, "[[type:nosuggest:cloze:nc:Text]]")) + assertThat(typeAnswer.noSuggest, equalTo(true)) + assertThat(typeAnswer.combining, equalTo(false)) + assertThat(typeAnswer.expectedAnswer, equalTo("hello")) + } + + @Test + fun `cloze type-in-answer extracts expected answer for each card ordinal`() = + runTest { + val note = addClozeNote("{{c1::alpha}} and {{c2::beta}}") + val (card1, card2) = note.cards() + + val typeAnswerCard1 = requireNotNull(TypeAnswer.getInstance(card1, "[[type:cloze:Text]]")) + assertThat(typeAnswerCard1.expectedAnswer, equalTo("alpha")) + + val typeAnswerCard2 = requireNotNull(TypeAnswer.getInstance(card2, "[[type:cloze:Text]]")) + assertThat(typeAnswerCard2.expectedAnswer, equalTo("beta")) + } + + @Test + fun `cloze type-in-answer extracts expected answer with hint`() = + runTest { + val card = addClozeNote("{{c1::alpha::hint for alpha}}").firstCard() + val typeAnswer = requireNotNull(TypeAnswer.getInstance(card, "[[type:cloze:Text]]")) + assertThat(typeAnswer.expectedAnswer, equalTo("alpha")) + } + companion object { suspend fun TypeAnswer.Companion.createInstance(card: Card) = requireNotNull(TypeAnswer.getInstance(card, VALID_CARD_TEXT)) From e8a5af2a6fbfdb7fb0c93fcc04fad0990498f551 Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:35:09 +0530 Subject: [PATCH 064/163] feat(manage-note-types): show progress from the ViewModel Loading, renaming and deleting note types now report progress from ManageNoteTypesViewModel, so the dialog can't be dismissed mid-operation, no longer flashes, and touches are blocked while it runs. Assisted-by: Claude Opus 5.5 --- .../anki/notetype/ManageNoteTypesState.kt | 2 - .../anki/notetype/ManageNoteTypesViewModel.kt | 178 +++++++++--------- .../ichi2/anki/notetype/ManageNotetypes.kt | 9 +- .../notetype/ManageNoteTypesViewModelTest.kt | 71 +++++++ .../anki/notetype/ManageNotetypesTest.kt | 42 +++++ 5 files changed, 206 insertions(+), 96 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNoteTypesState.kt b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNoteTypesState.kt index 345764127f64..1d87cb41cce4 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNoteTypesState.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNoteTypesState.kt @@ -16,8 +16,6 @@ import com.ichi2.anki.utils.Destination /** Encapsulates the entire state for [ManageNotetypes] */ data class ManageNoteTypesState( - /** Indicator if the UI should show a "loading" view to the user */ - val isLoading: Boolean = true, /** List of [Notetype] to show */ val noteTypes: List = emptyList(), /** User entered string to use for filtering the [noteTypes] list */ diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNoteTypesViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNoteTypesViewModel.kt index 22fee2a794b5..0ea8ce636fcb 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNoteTypesViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNoteTypesViewModel.kt @@ -23,6 +23,8 @@ import com.ichi2.anki.notetype.ManageNoteTypesState.FieldsEditor import com.ichi2.anki.notetype.ManageNoteTypesState.UserMessage import com.ichi2.anki.notetype.NoteTypeItemState.Companion.asModel import com.ichi2.anki.observability.undoableOp +import com.ichi2.anki.progress.HasProgress +import com.ichi2.anki.progress.ProgressManager import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update @@ -30,7 +32,11 @@ import kotlinx.coroutines.launch import net.ankiweb.rsdroid.BackendException import timber.log.Timber -class ManageNoteTypesViewModel : ViewModel() { +class ManageNoteTypesViewModel : + ViewModel(), + HasProgress { + override val progressManager = ProgressManager() + val state: StateFlow field = MutableStateFlow(ManageNoteTypesState()) @@ -40,18 +46,19 @@ class ManageNoteTypesViewModel : ViewModel() { fun refreshNoteTypes() { Timber.i("Refreshing list of notetypes") - state.update { oldState -> oldState.copy(isLoading = true) } viewModelScope.launch { - withCol { safeGetNotetypeNameIdUseCount() } - .onFailure { - state.update { oldState -> - oldState.copy(isLoading = false, error = ReportableException(it)) - } - }.onSuccess { - state.update { oldState -> - oldState.copy(isLoading = false, noteTypes = it) + progressManager.withProgress { + withCol { safeGetNotetypeNameIdUseCount() } + .onFailure { + state.update { oldState -> + oldState.copy(error = ReportableException(it)) + } + }.onSuccess { + state.update { oldState -> + oldState.copy(noteTypes = it) + } } - } + } } } @@ -62,7 +69,7 @@ class ManageNoteTypesViewModel : ViewModel() { oldState.noteTypes.map { it.copy(shouldBeDisplayed = it.name.contains(query, ignoreCase = true)) } - oldState.copy(isLoading = false, noteTypes = matchedNoteTypes, searchQuery = query) + oldState.copy(noteTypes = matchedNoteTypes, searchQuery = query) } } @@ -71,30 +78,30 @@ class ManageNoteTypesViewModel : ViewModel() { name: String, ) { Timber.i("Renaming notetype with id $ntid") - state.update { oldState -> oldState.copy(isLoading = true) } viewModelScope.launch { - undoableOp { - safeRenameNoteType(ntid, name) - .onSuccess { changes -> - state.update { oldState -> - val updatedNoteTypes = - oldState.noteTypes.withUpdatedItem(ntid) { old -> old.copy(name = name) } - oldState.copy(isLoading = false, noteTypes = updatedNoteTypes) - } - return@undoableOp changes - }.onFailure { - // TODO: Change to CardTypeException: https://github.com/ankidroid/Anki-Android-Backend/issues/537 - // Card template 1 in note type 'character' has a problem. - // Expected to find a field replacement on the front of the card template. - state.update { oldState -> - oldState.copy( - isLoading = false, - error = ReportableException(it, it !is BackendException), - ) + progressManager.withProgress { + undoableOp { + safeRenameNoteType(ntid, name) + .onSuccess { changes -> + state.update { oldState -> + val updatedNoteTypes = + oldState.noteTypes.withUpdatedItem(ntid) { old -> old.copy(name = name) } + oldState.copy(noteTypes = updatedNoteTypes) + } + return@undoableOp changes + }.onFailure { + // TODO: Change to CardTypeException: https://github.com/ankidroid/Anki-Android-Backend/issues/537 + // Card template 1 in note type 'character' has a problem. + // Expected to find a field replacement on the front of the card template. + state.update { oldState -> + oldState.copy( + error = ReportableException(it, it !is BackendException), + ) + } + OpChanges.getDefaultInstance() } - OpChanges.getDefaultInstance() - } - OpChanges.getDefaultInstance() + OpChanges.getDefaultInstance() + } } } } @@ -102,34 +109,31 @@ class ManageNoteTypesViewModel : ViewModel() { /** Deletes the note type with [ntid] and also updates the multi select mode status if needed */ fun delete(ntid: NoteTypeId) { Timber.i("Deleting notetype with id $ntid") - state.update { oldState -> oldState.copy(isLoading = true) } - val noteTypesCount = state.value.noteTypes.size + if (state.value.noteTypes.size <= 1) { + state.update { oldState -> oldState.copy(message = UserMessage.DeletingLastModel) } + return + } viewModelScope.launch { - undoableOp { - if (noteTypesCount <= 1) { - state.update { oldState -> - oldState.copy(isLoading = false, message = UserMessage.DeletingLastModel) - } - return@undoableOp OpChanges.getDefaultInstance() - } - safeRemoveNoteType(ntid) - .onSuccess { changes -> - state.update { oldState -> - val updatedNoteTypes = oldState.noteTypes.filterNot { it.id == ntid } - oldState.copy( - isLoading = false, - noteTypes = updatedNoteTypes, - isInMultiSelectMode = updatedNoteTypes.multiSelectModeStatus, - ) - } - return@undoableOp changes - }.onFailure { - state.update { oldState -> - oldState.copy(isLoading = false, error = ReportableException(it)) + progressManager.withProgress { + undoableOp { + safeRemoveNoteType(ntid) + .onSuccess { changes -> + state.update { oldState -> + val updatedNoteTypes = oldState.noteTypes.filterNot { it.id == ntid } + oldState.copy( + noteTypes = updatedNoteTypes, + isInMultiSelectMode = updatedNoteTypes.multiSelectModeStatus, + ) + } + return@undoableOp changes + }.onFailure { + state.update { oldState -> + oldState.copy(error = ReportableException(it)) + } + return@undoableOp OpChanges.getDefaultInstance() } - return@undoableOp OpChanges.getDefaultInstance() - } - OpChanges.getDefaultInstance() + OpChanges.getDefaultInstance() + } } } } @@ -225,39 +229,39 @@ class ManageNoteTypesViewModel : ViewModel() { noteType.copy(isSelected = false) } oldState.copy( - isLoading = true, noteTypes = updateNotetypes, isInMultiSelectMode = false, ) } viewModelScope.launch { - val errors = mutableMapOf() - noteTypesToDelete.forEach { noteType -> - undoableOp { - safeRemoveNoteType(noteType.id) - .onFailure { exception -> - errors[noteType] = exception - OpChanges.getDefaultInstance() - }.onSuccess { return@undoableOp it } - OpChanges.getDefaultInstance() + progressManager.withProgress { + val errors = mutableMapOf() + noteTypesToDelete.forEach { noteType -> + undoableOp { + safeRemoveNoteType(noteType.id) + .onFailure { exception -> + errors[noteType] = exception + OpChanges.getDefaultInstance() + }.onSuccess { return@undoableOp it } + OpChanges.getDefaultInstance() + } + } + // look through any errors we might have and remove from our list of note types the ones + // that were in noteTypesToDelete but not in errors map(which presumably weren't deleted) + val removedIds = noteTypesToDelete.map { it.id } - errors.keys.map { it.id }.toSet() + val updatedNoteTypes = state.value.noteTypes.filterNot { it.id in removedIds } + val combinedException = + CombinedException.from( + errors.map { (state, throwable) -> + "${state.name} - $throwable" to throwable + }, + ) + state.update { oldState -> + oldState.copy( + noteTypes = updatedNoteTypes, + error = combinedException?.let { throwable -> ReportableException(throwable) }, + ) } - } - // look through any errors we might have and remove from our list of note types the ones - // that were in noteTypesToDelete but not in errors map(which presumably weren't deleted) - val removedIds = noteTypesToDelete.map { it.id } - errors.keys.map { it.id }.toSet() - val updatedNoteTypes = state.value.noteTypes.filterNot { it.id in removedIds } - val combinedException = - CombinedException.from( - errors.map { (state, throwable) -> - "${state.name} - $throwable" to throwable - }, - ) - state.update { oldState -> - oldState.copy( - isLoading = false, - noteTypes = updatedNoteTypes, - error = combinedException?.let { throwable -> ReportableException(throwable) }, - ) } } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNotetypes.kt b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNotetypes.kt index 681ca558fe51..b27260afbfe9 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNotetypes.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/notetype/ManageNotetypes.kt @@ -41,10 +41,9 @@ import com.ichi2.anki.CommonString import com.ichi2.anki.R import com.ichi2.anki.common.crashreporting.CrashReportService import com.ichi2.anki.databinding.ActivityManageNoteTypesBinding -import com.ichi2.anki.dialogs.dismissLoadingDialog -import com.ichi2.anki.dialogs.showLoadingDialog import com.ichi2.anki.launchCatchingTask import com.ichi2.anki.notetype.ManageNoteTypesState.UserMessage +import com.ichi2.anki.progress.observeProgress import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.startup.ensureStorageIsReady import com.ichi2.anki.sync.userAcceptsSchemaChange @@ -107,6 +106,7 @@ class ManageNotetypes : AnkiActivity(R.layout.activity_manage_note_types) { setTransparentStatusBar() enableToolbar() binding.noteTypesList.adapter = notetypesAdapter + observeProgress(viewModel) { it } binding.floatingActionButton.apply { setOnClickListener { val addNewNotesType = AddNewNotesType(this@ManageNotetypes) @@ -205,11 +205,6 @@ class ManageNotetypes : AnkiActivity(R.layout.activity_manage_note_types) { viewModel.clearError() return } - if (state.isLoading) { - showLoadingDialog() - } else { - dismissLoadingDialog() - } // send only the items that should be displayed notetypesAdapter.submitList(state.noteTypes.filter { it.shouldBeDisplayed }) notetypesAdapter.isInMultiSelectMode = state.isInMultiSelectMode diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/notetype/ManageNoteTypesViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/notetype/ManageNoteTypesViewModelTest.kt index 4a13001ba6b5..da74ed5d32b5 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/notetype/ManageNoteTypesViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/notetype/ManageNoteTypesViewModelTest.kt @@ -3,13 +3,21 @@ package com.ichi2.anki.notetype import androidx.test.ext.junit.runners.AndroidJUnit4 +import app.cash.turbine.test import com.ichi2.anki.RobolectricTest import com.ichi2.anki.exception.CombinedException import com.ichi2.anki.libanki.Collection +import com.ichi2.anki.libanki.getNotetypeNameIdUseCount import com.ichi2.anki.libanki.removeNotetype +import com.ichi2.anki.notetype.ManageNoteTypesState.UserMessage +import com.ichi2.anki.progress.ViewModelProgress import io.mockk.every import io.mockk.mockkStatic import io.mockk.unmockkStatic +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.setMain import org.hamcrest.CoreMatchers.equalTo import org.hamcrest.CoreMatchers.hasItems import org.hamcrest.CoreMatchers.not @@ -23,6 +31,7 @@ import org.junit.runner.RunWith import kotlin.test.assertContentEquals import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertNotNull import kotlin.test.assertTrue @@ -191,6 +200,68 @@ class ManageNoteTypesViewModelTest : RobolectricTest() { unmockkStatic(Collection::removeNotetype) } + @Test + fun `refreshNoteTypes reports progress`() = + runProgressTest { + assertProgressAround { refreshNoteTypes() } + } + + @Test + fun `rename reports progress`() = + runProgressTest { + assertProgressAround { rename(noteTypeForName(TEST_NAME_1).id, TEST_NAME_2) } + } + + @Test + fun `delete reports progress`() = + runProgressTest { + assertProgressAround { delete(noteTypeForName(TEST_NAME_1).id) } + } + + @Test + fun `deleteSelectedNoteTypes reports progress`() = + runProgressTest { + onItemLongClick(noteTypeForName(TEST_NAME_1)) + + assertProgressAround { deleteSelectedNoteTypes() } + } + + @Test + fun `deleting the last note type reports no progress`() = + runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + col.getNotetypeNameIdUseCount().drop(1).forEach { col.removeNotetype(it.id) } + val viewModel = ManageNoteTypesViewModel() + advanceUntilIdle() + + viewModel.progressManager.progress.test { + assertIs(awaitItem()) + viewModel.delete(viewModel.noteTypes.single().id) + advanceUntilIdle() + expectNoEvents() + } + assertEquals(UserMessage.DeletingLastModel, viewModel.state.value.message) + } + + private fun runProgressTest(testBody: suspend ManageNoteTypesViewModel.() -> Unit) = + runTest { + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + addStandardNoteType(TEST_NAME_1, arrayOf("front", "back"), "", "") + val viewModel = ManageNoteTypesViewModel() + advanceUntilIdle() + viewModel.testBody() + } + + private suspend fun ManageNoteTypesViewModel.assertProgressAround(op: () -> Unit) { + progressManager.progress.test { + assertIs(awaitItem()) + op() + assertIs>(awaitItem()) + assertIs(awaitItem()) + expectNoEvents() + } + } + /** Returns the current list of [NoteTypeItemState] */ private val ManageNoteTypesViewModel.noteTypes: List get() = state.value.noteTypes diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/notetype/ManageNotetypesTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/notetype/ManageNotetypesTest.kt index 7ab41a336f70..16d4c13b2825 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/notetype/ManageNotetypesTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/notetype/ManageNotetypesTest.kt @@ -3,13 +3,20 @@ package com.ichi2.anki.notetype +import android.view.WindowManager import android.widget.EditText import androidx.appcompat.app.AlertDialog import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.CollectionManager import com.ichi2.anki.R import com.ichi2.anki.RobolectricTest +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.equalTo +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith import org.robolectric.shadows.ShadowDialog @@ -48,6 +55,41 @@ class ManageNotetypesTest : RobolectricTest() { assertThat("Rename button disabled for empty name", positiveButton.isEnabled, equalTo(false)) } + @Test + fun `deleting a note type blocks touches until it finishes`() = + runTest { + ensureCollectionLoadIsSynchronous() + addStandardNoteType(TEST_NOTE_TYPE_NAME, arrayOf("front", "back"), "", "") + val activity = startRegularActivity() + val noteType = + activity.viewModel.state.value.noteTypes + .first { it.name == TEST_NOTE_TYPE_NAME } + + withQueuedCollectionAccess { + activity.viewModel.delete(noteType.id) + assertTrue("touches blocked while deleting", activity.touchesBlocked()) + } + advanceUntilIdle() + + assertFalse("touches restored after deleting", activity.touchesBlocked()) + assertTrue( + "note type deleted", + activity.viewModel.state.value.noteTypes + .none { it.id == noteType.id }, + ) + } + + private suspend fun TestScope.withQueuedCollectionAccess(block: suspend () -> T): T { + val previousQueue = CollectionManager.setTestDispatcher(StandardTestDispatcher(testScheduler), useReentrantLock = false) + try { + return block() + } finally { + CollectionManager.setTestDispatcher(previousQueue) + } + } + + private fun ManageNotetypes.touchesBlocked() = window.attributes.flags and WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE != 0 + companion object { private const val TEST_NOTE_TYPE_NAME = "BasicTestNoteType1" } From ad8ccfd184e306c6beb78d27027dba159012b70f Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:13:27 +0530 Subject: [PATCH 065/163] fix(progress): block back while an operation runs Touches were blocked as soon as an operation started, but back was not, so pressing it before the progress dialog appeared closed the screen mid-operation. Back is now ignored until the operation ends. Assisted-by: Claude Opus 5.5 --- .../ichi2/anki/progress/ProgressObserver.kt | 29 +++++++++++------- .../anki/notetype/ManageNotetypesTest.kt | 30 +++++++++++++++++++ .../anki/progress/ProgressObserverTest.kt | 17 +++++++++++ 3 files changed, 66 insertions(+), 10 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/progress/ProgressObserver.kt b/AnkiDroid/src/main/java/com/ichi2/anki/progress/ProgressObserver.kt index 3d20f930496b..3524785dd899 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/progress/ProgressObserver.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/progress/ProgressObserver.kt @@ -6,6 +6,8 @@ package com.ichi2.anki.progress import android.content.Context import android.view.Window import android.view.WindowManager +import androidx.activity.OnBackPressedCallback +import androidx.activity.addCallback import androidx.fragment.app.Fragment import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope @@ -23,7 +25,7 @@ import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds /** - * Blocks touches and shows/dismisses a loading dialog driven by [viewModel]'s progress flow. + * Blocks touches and back, and shows/dismisses a loading dialog driven by [viewModel]'s progress flow. * [delayMillis] defers the initial show so quick operations don't flash a dialog. * [resolveMessage] maps the ViewModel's message to the text shown in the dialog. */ @@ -34,7 +36,20 @@ fun AnkiActivity.observeProgress( ) { val dialogTag = "${LoadingDialogFragment.TAG}:${viewModel.javaClass.name}" var dialogVisible = supportFragmentManager.findFragmentByTag(dialogTag) != null - var blockingTouches = false + var backBlock: OnBackPressedCallback? = null + + fun blockInput() { + if (backBlock != null) return + window.blockTouches() + backBlock = onBackPressedDispatcher.addCallback(this) { Timber.i("Ignoring back while an operation runs") } + } + + fun unblockInput() { + val block = backBlock ?: return + backBlock = null + block.remove() + window.unblockTouches() + } lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { @@ -49,16 +64,10 @@ fun AnkiActivity.observeProgress( pendingShow = null dialogVisible = false dismissLoadingDialog(dialogTag) - if (blockingTouches) { - blockingTouches = false - window.unblockTouches() - } + unblockInput() } is ViewModelProgress.Active -> { - if (!blockingTouches) { - blockingTouches = true - window.blockTouches() - } + blockInput() if (dialogVisible) { showLoadingDialog( message = formatMessage(state, resolveMessage), diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/notetype/ManageNotetypesTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/notetype/ManageNotetypesTest.kt index 16d4c13b2825..c8a426a42b04 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/notetype/ManageNotetypesTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/notetype/ManageNotetypesTest.kt @@ -10,6 +10,7 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import com.ichi2.anki.CollectionManager import com.ichi2.anki.R import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.dialogs.LoadingDialogFragment import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle @@ -79,6 +80,35 @@ class ManageNotetypesTest : RobolectricTest() { ) } + @Test + fun `back during the progress delay does not finish an activity deleting note types`() = + runTest { + ensureCollectionLoadIsSynchronous() + addStandardNoteType(TEST_NOTE_TYPE_NAME, arrayOf("front", "back"), "", "") + val activity = startRegularActivity() + val noteType = + activity.viewModel.state.value.noteTypes + .first { it.name == TEST_NOTE_TYPE_NAME } + activity.viewModel.onItemLongClick(noteType) + + try { + withQueuedCollectionAccess { + activity.viewModel.deleteSelectedNoteTypes() + assertTrue("deletion is still in progress", activity.touchesBlocked()) + assertFalse( + "the progress dialog has not appeared yet", + activity.supportFragmentManager.fragments.any { it is LoadingDialogFragment }, + ) + + activity.onBackPressedDispatcher.onBackPressed() + + assertFalse("Back must not finish the activity during deletion", activity.isFinishing) + } + } finally { + advanceUntilIdle() + } + } + private suspend fun TestScope.withQueuedCollectionAccess(block: suspend () -> T): T { val previousQueue = CollectionManager.setTestDispatcher(StandardTestDispatcher(testScheduler), useReentrantLock = false) try { diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/progress/ProgressObserverTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/progress/ProgressObserverTest.kt index 7878223d6175..a9027ff91813 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/progress/ProgressObserverTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/progress/ProgressObserverTest.kt @@ -232,6 +232,23 @@ class ProgressObserverTest : RobolectricTest() { op.cancel() } + @Test + fun `back is blocked as soon as an op starts and restored when it ends`() { + val activity = startActivity().get() + activity.observeProgress(viewModel, delayMillis = SHOW_DELAY) { it } + + val gate = CompletableDeferred() + val op = launchOp(gate) + activity.onBackPressedDispatcher.onBackPressed() + assertFalse(activity.isFinishing, "back must be blocked before the dialog shows") + + gate.complete(Unit) + idleMainLooper(SHOW_DELAY) + activity.onBackPressedDispatcher.onBackPressed() + assertTrue(activity.isFinishing, "back must work again once the op ends") + op.cancel() + } + @Test fun `an observer whose op ends does not restore touches while another op runs`() { val activity = startActivity().get() From 9ea96078f3df27c00bf97b82af0f64d7988daf47 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 17 Oct 2026 17:37:07 +0100 Subject: [PATCH 066/163] fix(deck-picker): stop 'Add' FAB overshooting When the FAB menu was opened, 'Add' expanded too much, and then contracted. Fix this by using the extended padding in calculations. Fixes 22577 Assisted-by: GPT-6-Astra default --- .../anki/DeckPickerFloatingActionMenu.kt | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt index b3584b65abde..27e27392dd54 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt @@ -30,6 +30,7 @@ class DeckPickerFloatingActionMenu( ) { // TODO: refactor this to decouple with Homescreen & DeckPicker private val binding: IncludeFloatingAddButtonBinding = homescreenBinding.deckPickerPane.floatingActionButton + private val extendedFabHorizontalPadding = binding.fabMain.paddingStart + binding.fabMain.paddingEnd /** Layout deck_picker.xml is attached here */ private val linearLayout: LinearLayout = homescreenBinding.deckPickerPane.deckpickerView @@ -70,7 +71,7 @@ class DeckPickerFloatingActionMenu( binding.fabBGLayout.visibility = View.VISIBLE binding.fabMain.backgroundTintList = ColorStateList.valueOf(fabPressedColor) binding.fabMain.setIconResource(addNoteIcon) - binding.fabMain.extend() + extendMainButton() with(binding) { addSharedButton.animate().translationY(0f).duration = 100 @@ -102,6 +103,26 @@ class DeckPickerFloatingActionMenu( } } + /** Extends Add without overshooting its final width, preserving the styled minimum width. */ + private fun extendMainButton() = + with(binding.fabMain) { + val originalMinWidth = minWidth + val originalMinimumWidth = minimumWidth + // Material measures with collapsed padding, then adds the extra extended padding. + // Discount that padding from the minimum during measurement so it isn't counted twice. + val extraPadding = extendedFabHorizontalPadding - paddingStart - paddingEnd + // Both TextView's minWidth and View's minimumWidth constrain this measurement. + minWidth = (originalMinWidth - extraPadding).coerceAtLeast(0) + minimumWidth = (originalMinimumWidth - extraPadding).coerceAtLeast(0) + try { + extend() + } finally { + // Preserve the styled minimum width for the final wrap_content layout. + minWidth = originalMinWidth + minimumWidth = originalMinimumWidth + } + } + /** * This function takes a parameter which decides if we want to apply the rise and shrink animation * for FAB or not. From 7b026a0b00a6c3b88e2802e973340f10f7276724 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 13 Oct 2026 13:26:37 +0100 Subject: [PATCH 067/163] fix(tests): fix flake by waiting for deck list ``` DeckPickerInsetsTest.destroying the deck picker removes its global layout listener java.lang.AssertionError: the listener updates padding while the activity is alive Expected: <72> but: was <0> ``` Fixes 22482 Assisted-by: GPT-6-Astra default --- .../src/test/java/com/ichi2/anki/DeckPickerInsetsTest.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerInsetsTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerInsetsTest.kt index 3c887b6bf074..318487a7a615 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerInsetsTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerInsetsTest.kt @@ -37,10 +37,14 @@ class DeckPickerInsetsTest : RobolectricTest() { addDeck("Test Deck") val controller = startActivityControllerNormallyOpenCollectionWithIntent(DeckPicker::class.java, Intent()) val deckPicker = controller.get() + advanceRobolectricLooperUntil(lazyMessage = { "the deck list should be shown" }) { + deckPicker.deckPickerBinding.deckPickerContent.isVisible + } deckPicker.dispatchInsets(navBarBottom = 48.dp) deckPicker.layoutForTest() val list = deckPicker.deckPickerBinding.decks val observer = deckPicker.deckPickerBinding.root.viewTreeObserver + check(list.height > 0) { "the deck list must be laid out, or the layout listener is a no-op" } val padding = list.paddingBottom check(padding > 0) From a7e70bac6f7b426575d3b455515403455b9f8352 Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Sun, 18 Oct 2026 01:46:56 +0530 Subject: [PATCH 068/163] fix(previewer): stop audio when the card side changes In the browser preview, with "Don't play audio automatically" on, audio started on one side kept playing after flipping to the other. Showing the answer, going back to the question and "back side only" now stop playback first. Rotating the screen still keeps the audio playing. Fixes 19481 Assisted-by: Claude Opus 5.5 --- .../anki/previewer/CardViewerViewModel.kt | 3 +- .../anki/previewer/PreviewerViewModel.kt | 5 +- .../anki/previewer/PreviewerViewModelTest.kt | 50 +++++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerViewModel.kt index 26af26daee03..4c86c9ab1a1d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerViewModel.kt @@ -43,7 +43,8 @@ abstract class CardViewerViewModel( val showingAnswer = savedStateHandle.getMutableStateFlow(KEY_SHOWING_ANSWER, false) - protected val cardMediaPlayer = + @VisibleForTesting(otherwise = VisibleForTesting.PROTECTED) + val cardMediaPlayer = CardMediaPlayer( javascriptEvaluator = { launchCatchingIO { eval.emit(it) } }, mediaErrorListener = mediaErrorHandler, diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerViewModel.kt index 1665e4f862a3..cf4cf14737ab 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerViewModel.kt @@ -69,7 +69,6 @@ class PreviewerViewModel( /** Call this after the webView has finished loading the page */ @NeedsTest("16302 - a sound-only card on the back/flipped with 'don't keep activities'") - @NeedsTest("16302 - on config changes, sound continues to play") override fun onPageFinished(isAfterRecreation: Boolean) { launchCatchingIO { if (isAfterRecreation) { @@ -92,9 +91,11 @@ class PreviewerViewModel( launchCatchingIO { backSideOnly.emit(!backSideOnly.value) if (!backSideOnly.value && showingAnswer.value) { + cardMediaPlayer.stop() showQuestion() cardMediaPlayer.autoplayAllForSide(CardSide.QUESTION) } else if (backSideOnly.value && !showingAnswer.value) { + cardMediaPlayer.stop() showAnswer() cardMediaPlayer.autoplayAllForSide(CardSide.ANSWER) } @@ -135,6 +136,7 @@ class PreviewerViewModel( fun onNextButtonClick() { launchCatchingIO { if (!showingAnswer.value && !backSideOnly.value) { + cardMediaPlayer.stop() showAnswer() cardMediaPlayer.autoplayAllForSide(CardSide.ANSWER) } else { @@ -152,6 +154,7 @@ class PreviewerViewModel( if (currentIndex.value > 0) { updateCurrentIndex { it - 1 } } else if (showingAnswer.value && !backSideOnly.value) { + cardMediaPlayer.stop() showQuestion() } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/previewer/PreviewerViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/previewer/PreviewerViewModelTest.kt index 30ae3ef122ae..44c4a53ce563 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/previewer/PreviewerViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/previewer/PreviewerViewModelTest.kt @@ -11,6 +11,7 @@ import com.ichi2.anki.utils.ext.flag import com.ichi2.testutils.JvmTest import io.mockk.coEvery import io.mockk.spyk +import kotlinx.coroutines.Job import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -58,6 +59,7 @@ class PreviewerViewModelTest : JvmTest() { val note = addBasicNote() note.cards().map { it.id } } + updateDeckConfig(col.decks.selected()) { autoplay = false } viewModel = spyk(PreviewerViewModel(SavedStateHandle(), cardIds, initialIndex = 0)) // the default implementation requires the Collection media directory, // which needs Robolectric with CollectionStorageMode.IN_MEMORY_WITH_MEDIA or ON_DISK @@ -254,4 +256,52 @@ class PreviewerViewModelTest : JvmTest() { onSliderChange(sliderPosition = 2) assertEquals("Index should update for valid input", 1, viewModel.currentIndex.value) } + + @Test + fun `showing the answer stops the question audio`() = + runTest { + viewModel.onPageFinished(false) + val audio = audioPlaying() + + onNextButtonClick() + + assertTrue("question audio should stop", audio.isCancelled) + } + + @Test + fun `going back to the question stops the answer audio`() = + runTest { + viewModel.onPageFinished(false) + onNextButtonClick() + val audio = audioPlaying() + + onPreviousButtonClick() + + assertTrue("answer audio should stop", audio.isCancelled) + } + + @Test + fun `showing only the back side stops the question audio`() = + runTest { + viewModel.onPageFinished(false) + val audio = audioPlaying() + + toggleBackSideOnly() + + assertTrue("question audio should stop", audio.isCancelled) + } + + @Test + fun `audio keeps playing after a configuration change`() = + runTest { + viewModel.onPageFinished(false) + val audio = audioPlaying() + + viewModel.onPageFinished(isAfterRecreation = true) + advanceUntilIdle() + + assertFalse("audio should keep playing", audio.isCancelled) + } + + private fun audioPlaying() = Job().also { viewModel.cardMediaPlayer.playAvTagsJob = it } } From 628a1988fb3502646f496de08e58c37d1aadea8a Mon Sep 17 00:00:00 2001 From: Ratnesh5 Date: Tue, 13 Oct 2026 17:45:02 +0530 Subject: [PATCH 069/163] build(ci): add emulator test results to workflow summary (#22011) --- .github/report_test_failures.py | 97 +++++++++++++++++++++++++--- .github/workflows/tests_emulator.yml | 2 +- 2 files changed, 90 insertions(+), 9 deletions(-) diff --git a/.github/report_test_failures.py b/.github/report_test_failures.py index 718bcdbbfe3a..7acc7ed803a3 100644 --- a/.github/report_test_failures.py +++ b/.github/report_test_failures.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 # SPDX-License-Identifier: GPL-3.0-or-later -"""Report failures and skipped tests from JUnit XML using the standard library.""" +"""Report failures, skipped tests, and overview summary from JUnit XML using the standard library.""" import argparse import html @@ -12,6 +12,19 @@ import xml.etree.ElementTree as ET +TABLE_HEADER = ( + "## Test Results\n\n" + "| Suite | Result | Duration | Tests | Passed | Failed | Skipped |\n" + "| ----- | ------ | -------- | ----- | ------ | ------ | ------- |\n" +) + + +def format_duration(seconds): + minutes = int(seconds // 60) + rem_seconds = seconds % 60 + return f"{minutes:02d}m {rem_seconds:06.3f}s" + + def read_ignored_tests(log_file): ignored = set() failed = set() @@ -26,22 +39,61 @@ def read_ignored_tests(log_file): return ignored - failed -def main(results_dir, log_file=None, pattern="TEST-*.xml", title="Test failures"): +def get_report_duration(root): + suites = root.findall("testsuite") + if suites: + total = 0.0 + for s in suites: + try: + total += float(s.get("time", 0) or 0) + except (ValueError, TypeError): + pass + if total > 0.0: + return total + try: + root_time = float(root.get("time", 0) or 0) + if root_time > 0.0: + return root_time + except (ValueError, TypeError): + pass + total = 0.0 + for tc in root.iter("testcase"): + try: + total += float(tc.get("time", 0) or 0) + except (ValueError, TypeError): + pass + return total + + +def main(results_dir, log_file=None, pattern="TEST-*.xml", title="Test failures", suite=None): failures = [] skipped = [] ignored = read_ignored_tests(log_file) + total_duration = 0.0 + passed_count = 0 + failed_count = 0 + skipped_count = 0 + for report in sorted(results_dir.rglob(pattern)): try: root = ET.parse(report).getroot() except (ET.ParseError, OSError) as error: - failures.append(f"Could not read {report}: {error}") + if os.environ.get("GITHUB_ACTIONS") == "true": + print(f"::warning::Could not read {report}: {error}") + else: + print(f"Warning: Could not read {report}: {error}", file=sys.stderr) continue + total_duration += get_report_duration(root) + for test in root.iter("testcase"): + name = f"{test.get('classname', '')}.{test.get('name', '')}" + has_failure = False + has_skipped = False + for result in test: if result.tag not in ("failure", "error", "skipped"): continue - name = f"{test.get('classname', '')}.{test.get('name', '')}" details = "".join(result.itertext()).strip() message = result.get("message", "") # Android can report assumption violations as failures or errors. @@ -56,10 +108,30 @@ def main(results_dir, log_file=None, pattern="TEST-*.xml", title="Test failures" "org.junit.AssumptionViolatedException", "org.junit.internal.AssumptionViolatedException", ): + has_skipped = True skipped.append(f"{name}\n{details or result.get('type', 'Test skipped')}") else: + has_failure = True failures.append(f"{name}\n{details or result.get('type', 'Test failed')}") + if has_failure: + failed_count += 1 + elif has_skipped: + skipped_count += 1 + else: + passed_count += 1 + + total_tests = passed_count + failed_count + skipped_count + is_successful = failed_count == 0 and total_tests > 0 + result_text = "SUCCESS" if is_successful else "FAILURE" + elapsed = format_duration(total_duration) + + if suite: + print( + f"{suite}: {result_text} ({elapsed}) - " + f"{total_tests} tests: {passed_count} passed, {failed_count} failed, {skipped_count} skipped" + ) + for failure in failures: if os.environ.get("GITHUB_ACTIONS") == "true": # GitHub renders the escaped message as a multiline error in the log. @@ -79,19 +151,27 @@ def main(results_dir, log_file=None, pattern="TEST-*.xml", title="Test failures" if os.environ.get("GITHUB_ACTIONS") == "true": print("::endgroup::") - if (failures or skipped) and os.environ.get("GITHUB_STEP_SUMMARY"): - with open(os.environ["GITHUB_STEP_SUMMARY"], "a", encoding="utf-8") as summary: + if os.environ.get("GITHUB_STEP_SUMMARY"): + with open(os.environ["GITHUB_STEP_SUMMARY"], "a+", encoding="utf-8") as summary: + summary.seek(0) + existing_content = summary.read() + if suite: + if "## Test Results" not in existing_content: + summary.write(TABLE_HEADER) + summary.write(f"| {suite} | **{result_text}** | {elapsed} | {total_tests} | {passed_count} | {failed_count} | {skipped_count} |\n\n") + if failures: summary.write(f"### {title}\n\n") for failure in failures: summary.write(f"
    {html.escape(failure)}
    \n\n") if skipped: + skipped_title = f"Skipped tests ({len(skipped)})" summary.write(f"
    {skipped_title}\n\n") for skip in skipped: summary.write(f"
    {html.escape(skip)}
    \n\n") summary.write("
    \n\n") - return 1 if failures else 0 + return 1 if (failures or (suite and not is_successful)) else 0 if __name__ == "__main__": @@ -100,5 +180,6 @@ def main(results_dir, log_file=None, pattern="TEST-*.xml", title="Test failures" parser.add_argument("log_file", type=Path, nargs="?") parser.add_argument("--pattern", default="TEST-*.xml", help="Recursive report glob relative to results_dir") parser.add_argument("--title", default="Test failures", help="Failure heading in the job summary") + parser.add_argument("--suite", help="Suite name for markdown summary table") args = parser.parse_args() - sys.exit(main(args.results_dir, args.log_file, args.pattern, args.title)) + sys.exit(main(args.results_dir, args.log_file, args.pattern, args.title, args.suite)) diff --git a/.github/workflows/tests_emulator.yml b/.github/workflows/tests_emulator.yml index 82b27b5e0fae..c6ed46ca5f78 100644 --- a/.github/workflows/tests_emulator.yml +++ b/.github/workflows/tests_emulator.yml @@ -295,7 +295,7 @@ jobs: - name: Report Emulator Test Results if: always() && hashFiles('AnkiDroid/build/outputs/androidTest-results/connected/**/TEST-*.xml') != '' - run: python3 .github/report_test_failures.py AnkiDroid/build/outputs/androidTest-results/connected adb-log.txt --title "Emulator test failures" + run: python3 .github/report_test_failures.py AnkiDroid/build/outputs/androidTest-results/connected adb-log.txt --title "Emulator test failures" --suite ":AnkiDroid:connectedPlayReleaseAndroidTest" - name: Build Release Smoke Test run: TEST_RELEASE_BUILD=false ./gradlew packagePlayRelease :baselineprofile:packageBenchmarkBenchmark --daemon From 471d861689a7bbc2a58c70703a997a80c8c46a6a Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Tue, 20 Oct 2026 08:50:32 +0530 Subject: [PATCH 070/163] fix(scrollbar): hide the fast scroller again after a thumb drag After dragging the Card Browser scrollbar once, it stayed on screen in its pressed colour and never hid again. Letting go of the thumb now clears the pressed state and starts the hide timer, like 2.23 did. Assisted-by: Claude Opus 5.5 --- .../com/ichi2/anki/ui/RecyclerFastScroller.kt | 3 +- .../ichi2/anki/ui/RecyclerFastScrollerTest.kt | 144 ++++++++++++++++++ 2 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/ui/RecyclerFastScrollerTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt index 6837ad870d09..3a7db00cdfed 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/RecyclerFastScroller.kt @@ -396,11 +396,12 @@ class RecyclerFastScroller true } MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { - handle.isSelected = false + handle.isPressed = false if (recyclerView != null) { recyclerView?.let { removeCallbacks(scrollTask) } scrollTask.run() } + postAutoHide() false } else -> super.onTouchEvent(event) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ui/RecyclerFastScrollerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ui/RecyclerFastScrollerTest.kt new file mode 100644 index 000000000000..4f2e60f2385e --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ui/RecyclerFastScrollerTest.kt @@ -0,0 +1,144 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 Ashish Yadav + +package com.ichi2.anki.ui + +import android.app.Activity +import android.os.Looper +import android.os.SystemClock +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.widget.RelativeLayout +import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.RecyclerView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.EmptyApplicationCategory +import com.ichi2.testutils.EmptyApplication +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo +import org.junit.Test +import org.junit.experimental.categories.Category +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import java.time.Duration + +@RunWith(AndroidJUnit4::class) +@Config(application = EmptyApplication::class) +@Category(EmptyApplicationCategory::class) +class RecyclerFastScrollerTest { + @Test + fun `scroller hides after a thumb drag`() { + val list = FastScrollerTestList(evenRows(200)) + list.press() + list.moveTo(trackProportion = 0.5f) + list.release() + + list.waitPastAutoHide() + + assertThat(list.isThumbPressed, equalTo(false)) + assertThat(list.isScrollerHidden, equalTo(true)) + } + + @Test + fun `scroller hides after the thumb is held still past the hide delay`() { + val list = FastScrollerTestList(evenRows(200)) + list.press() + list.moveTo(trackProportion = 0.5f) + list.waitPastAutoHide() + list.release() + + list.waitPastAutoHide() + + assertThat(list.isScrollerHidden, equalTo(true)) + } +} + +private const val ROW_HEIGHT = 150 + +private fun evenRows(count: Int) = List(count) { ROW_HEIGHT } + +private class FastScrollerTestList( + rowHeights: List, +) { + private val activity = Robolectric.buildActivity(Activity::class.java).setup().get() + private val recyclerView = + RecyclerView(activity).also { + it.layoutManager = LinearLayoutManager(activity) + it.adapter = FixedHeightRowsAdapter(rowHeights) + } + private val scroller = RecyclerFastScroller(activity) + private val root = + RelativeLayout(activity).apply { + addView(recyclerView, RelativeLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)) + addView( + scroller, + RelativeLayout.LayoutParams(WRAP_CONTENT, MATCH_PARENT).apply { addRule(RelativeLayout.ALIGN_PARENT_END) }, + ) + } + private val downTime = SystemClock.uptimeMillis() + private var fingerY = 0f + + val isThumbPressed: Boolean get() = scroller.handle.isPressed + + val isScrollerHidden: Boolean get() = scroller.translationX == scroller.hiddenTranslationX.toFloat() + + init { + scroller.attachRecyclerView(recyclerView) + activity.setContentView(root, ViewGroup.LayoutParams(WIDTH, HEIGHT)) + showNextFrame() + } + + fun press() = touch(MotionEvent.ACTION_DOWN, (scroller.handle.top + scroller.handle.bottom) / 2f) + + fun moveTo(trackProportion: Float) { + val thumbHeight = scroller.handle.height + touch(MotionEvent.ACTION_MOVE, thumbHeight / 2f + trackProportion * (HEIGHT - thumbHeight)) + } + + fun release() = touch(MotionEvent.ACTION_UP, fingerY) + + fun waitPastAutoHide() = shadowOf(Looper.getMainLooper()).idleFor(AUTO_HIDE_WAIT) + + private fun touch( + action: Int, + y: Float, + ) { + fingerY = y + val event = MotionEvent.obtain(downTime, SystemClock.uptimeMillis(), action, scroller.width / 2f, y, 0) + scroller.dispatchTouchEvent(event) + event.recycle() + showNextFrame() + } + + private fun showNextFrame() = shadowOf(Looper.getMainLooper()).idleFor(FRAME_INTERVAL) + + private companion object { + const val WIDTH = 1000 + const val HEIGHT = 2000 + val FRAME_INTERVAL: Duration = Duration.ofMillis(16) + val AUTO_HIDE_WAIT: Duration = Duration.ofSeconds(2) + } +} + +private class FixedHeightRowsAdapter( + private val rowHeights: List, +) : RecyclerView.Adapter() { + override fun onCreateViewHolder( + parent: ViewGroup, + viewType: Int, + ) = object : RecyclerView.ViewHolder(View(parent.context)) {} + + override fun onBindViewHolder( + holder: RecyclerView.ViewHolder, + position: Int, + ) { + holder.itemView.layoutParams = RecyclerView.LayoutParams(MATCH_PARENT, rowHeights[position]) + } + + override fun getItemCount() = rowHeights.size +} From 7417b66923d8432d5ac699de888a8155d79ac92d Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:44:08 +0100 Subject: [PATCH 071/163] fix(note-types): make selection actions visible on e-ink Assisted-by: GPT-6-Astra xhigh --- .../main/res/drawable/note_types_selection_background.xml | 3 ++- .../src/main/res/layout/activity_manage_note_types.xml | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/AnkiDroid/src/main/res/drawable/note_types_selection_background.xml b/AnkiDroid/src/main/res/drawable/note_types_selection_background.xml index dde937930c75..dce7a1bbb6ac 100644 --- a/AnkiDroid/src/main/res/drawable/note_types_selection_background.xml +++ b/AnkiDroid/src/main/res/drawable/note_types_selection_background.xml @@ -1,5 +1,6 @@ + - \ No newline at end of file + diff --git a/AnkiDroid/src/main/res/layout/activity_manage_note_types.xml b/AnkiDroid/src/main/res/layout/activity_manage_note_types.xml index 1af245186d7a..0f09af97170b 100644 --- a/AnkiDroid/src/main/res/layout/activity_manage_note_types.xml +++ b/AnkiDroid/src/main/res/layout/activity_manage_note_types.xml @@ -49,7 +49,7 @@ android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginEnd="8dp" - android:textColor="?attr/colorOnPrimary" + android:textColor="?attr/actionBarTextColor" android:textAppearance="?textAppearanceBodyMediumEmphasized" tools:text="5 note types selected" /> @@ -59,7 +59,7 @@ android:layout_height="wrap_content" app:icon="@drawable/ic_delete" style="@style/Widget.Material3.Button.IconButton" - app:iconTint="@color/white"/> + app:iconTint="?attr/actionBarTextColor"/> + app:iconTint="?attr/actionBarTextColor"/> Date: Sun, 18 Oct 2026 18:40:49 +0530 Subject: [PATCH 072/163] fix(import): stop reporting pending or trashed downloads Importing a download that was still in progress or in the trash showed an error, and also sent a crash report: Android refuses to open those files with an IllegalStateException. It is now handled like a deleted file, so the error stays but the report is gone. Fixes 22595 Assisted-by: Claude Opus 5.5 --- .../main/java/com/ichi2/utils/ImportUtils.kt | 10 +++++++- .../java/com/ichi2/utils/ImportUtilsTest.kt | 24 +++++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/ImportUtils.kt b/AnkiDroid/src/main/java/com/ichi2/utils/ImportUtils.kt index 03434a14a076..ff5b282380c4 100644 --- a/AnkiDroid/src/main/java/com/ichi2/utils/ImportUtils.kt +++ b/AnkiDroid/src/main/java/com/ichi2/utils/ImportUtils.kt @@ -430,6 +430,14 @@ object ImportUtils { // The selected file may have been deleted since the provider returned its URI. Timber.w(e, "Import source file is unavailable") CacheFileResult.SourceFileUnavailable(e) + } catch (e: IllegalStateException) { + if (e.message?.contains("pending/trashed") == true) { + Timber.w(e, "Import source file is pending or trashed") + CacheFileResult.SourceFileUnavailable(e) + } else { + Timber.w("Could not open import source") + CacheFileResult.Error(e) + } } catch (e: Exception) { Timber.w("Could not open import source") CacheFileResult.Error(e) @@ -460,7 +468,7 @@ object ImportUtils { ) : CacheFileResult() data class SourceFileUnavailable( - val exception: FileNotFoundException, + val exception: Exception, ) : CacheFileResult() data object ContentProviderCrashed : CacheFileResult() diff --git a/AnkiDroid/src/test/java/com/ichi2/utils/ImportUtilsTest.kt b/AnkiDroid/src/test/java/com/ichi2/utils/ImportUtilsTest.kt index 89558906f83c..afddcf6af34b 100644 --- a/AnkiDroid/src/test/java/com/ichi2/utils/ImportUtilsTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/utils/ImportUtilsTest.kt @@ -85,6 +85,30 @@ class ImportUtilsTest : RobolectricTest() { verify(crashReporter).sendExceptionReport(exception, "ImportUtils", null, false) } + @Test + fun `pending or trashed download returns an import failure without a crash report`() { + val uri = "content://import/pending.apkg".toUri() + val exception = IllegalStateException("Only owner is able to interact with pending/trashed item $uri") + shadowOf(targetContext.contentResolver).registerInputStreamSupplier(uri) { throw exception } + + val result = importerWithFileName("pending.apkg").handleFileImport(targetContext, Intent(Intent.ACTION_VIEW, uri)) + + assertEquals(exception, assertIs(result).exception) + verifyNoInteractions(crashReporter) + } + + @Test + fun `other illegal state errors from the source are still reported`() { + val uri = "content://import/illegal-state.apkg".toUri() + val exception = IllegalStateException("Provider failed") + shadowOf(targetContext.contentResolver).registerInputStreamSupplier(uri) { throw exception } + + val result = importerWithFileName("illegal-state.apkg").handleFileImport(targetContext, Intent(Intent.ACTION_VIEW, uri)) + + assertEquals(exception, assertIs(result).exception) + verify(crashReporter).sendExceptionReport(exception, "ImportUtils", null, false) + } + @Test fun `file not found while reading an open source is still reported`() { val uri = "content://import/unreadable.apkg".toUri() From be0c1fbb3c8dc0c24a99bdfcb4f2dc4d94e79531 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:45:34 +0100 Subject: [PATCH 073/163] perf(tags): avoid temporary set We can use a O(log n) lookup on the original set, significantly less memory allocations, 29% improvement in best case. Part of 18424 Assisted-by: GPT-6-Astra xhigh --- .../java/com/ichi2/anki/dialogs/tags/TagsList.kt | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsList.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsList.kt index 07c928de51d4..cc9787ef021c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsList.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsList.kt @@ -55,17 +55,23 @@ class TagsList( indeterminateTags = TreeSet(java.lang.String.CASE_INSENSITIVE_ORDER) if (uncheckedTags != null) { this.allTags.addAll(uncheckedTags) - // intersection between mCheckedTags and uncheckedTags - indeterminateTags.addAll(this.checkedTags) - val uncheckedSet: MutableSet = TreeSet(java.lang.String.CASE_INSENSITIVE_ORDER) - uncheckedSet.addAll(uncheckedTags) - indeterminateTags.retainAll(uncheckedSet) + for (tag in uncheckedTags) { + val checkedTag = findCheckedTagIgnoringCase(tag) ?: continue + indeterminateTags.add(checkedTag) + } this.checkedTags.removeAll(indeterminateTags) } partiallySelectedTags.addAll(indeterminateTags) prepareTagHierarchy() } + /** Returns the matching checked tag with its stored spelling, or `null` if absent. */ + private fun findCheckedTagIgnoringCase(tag: String): String? { + // O(log n); returns "Biology" for a "biology" search or vice versa + val candidate = checkedTags.ceiling(tag) ?: return null + return candidate.takeIf { it.equals(tag, ignoreCase = true) } + } + /** * Return true if a tag is checked given its index in the list * From aaf59f59e091c429310fe28f5b9e1aff633df7b4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 17:56:33 +0000 Subject: [PATCH 074/163] build(deps): bump follow-redirects in /tools/localization Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.16.0 to 1.16.1. - [Release notes](https://github.com/follow-redirects/follow-redirects/releases) - [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.16.0...v1.16.1) --- updated-dependencies: - dependency-name: follow-redirects dependency-version: 1.16.1 dependency-type: indirect ... Signed-off-by: dependabot[bot] --- tools/localization/yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/localization/yarn.lock b/tools/localization/yarn.lock index eceed408b429..c78910791592 100644 --- a/tools/localization/yarn.lock +++ b/tools/localization/yarn.lock @@ -3206,12 +3206,12 @@ __metadata: linkType: hard "follow-redirects@npm:^1.16.0": - version: 1.16.0 - resolution: "follow-redirects@npm:1.16.0" + version: 1.16.1 + resolution: "follow-redirects@npm:1.16.1" peerDependenciesMeta: debug: optional: true - checksum: 10c0/a1e2900163e6f1b4d1ed5c221b607f41decbab65534c63fe7e287e40a5d552a6496e7d9d7d976fa4ba77b4c51c11e5e9f683f10b43011ea11e442ff128d0e181 + checksum: 10c0/544e24ccb93b7afb5dd5a2199bd556e167b84de2af7c13b2ced08494239677359d7883bae56ae43a9ca98ac5451e75cf4834a5fadd6fa79053b9248fac5447a4 languageName: node linkType: hard From 899c3be1942739e171c43d967c4d09a193d8d168 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 20 Oct 2026 15:14:56 +0100 Subject: [PATCH 075/163] refactor(reviewer): add 'BindingUnicodeCharacter' domain type Ensures the character is valid, so assertions do not need to be reapplied Assisted-by: GPT-6-Astra xhigh --- .../java/com/ichi2/anki/reviewer/Binding.kt | 22 ++++++++------ .../anki/reviewer/BindingUnicodeCharacter.kt | 22 ++++++++++++++ .../ichi2/anki/reviewer/BindingAndroidTest.kt | 13 ++++++++ .../com/ichi2/anki/reviewer/BindingTest.kt | 2 +- .../reviewer/BindingUnicodeCharacterTest.kt | 30 +++++++++++++++++++ .../com/ichi2/ui/BindingPreferenceTest.kt | 18 ++++++++++- 6 files changed, 96 insertions(+), 11 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/reviewer/BindingUnicodeCharacter.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/reviewer/BindingUnicodeCharacterTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/Binding.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/Binding.kt index d7cacd83ed28..20033e564380 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/Binding.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/Binding.kt @@ -95,9 +95,14 @@ sealed interface Binding { override fun hashCode(): Int = Objects.hash(keycode) } + /** + * A keyboard shortcut matched by the character produced by a key press. + * + * [KeyCode] is insufficient: e.g. `@` doesn't have a consistent keypress across keyboards. + */ @Suppress("EqualsOrHashCode") data class UnicodeCharacter( - val unicodeCharacter: Char, + val unicodeCharacter: BindingUnicodeCharacter, override val modifierKeys: ModifierKeys = AppDefinedModifierKeys.allowShift(), ) : KeyBinding { override fun toDisplayString(context: Context): String = @@ -105,18 +110,18 @@ sealed interface Binding { append(KEY_PREFIX) append(' ') append(modifierKeys.toString()) - append(unicodeCharacter) + append(unicodeCharacter.value) } override fun toString(): String = buildString { append(UNICODE_PREFIX) append(modifierKeys.toString()) - append(unicodeCharacter) + append(unicodeCharacter.value) } // don't include the modifierKeys - override fun hashCode(): Int = Objects.hash(unicodeCharacter) + override fun hashCode(): Int = Objects.hash(unicodeCharacter.value) } data object UnknownBinding : Binding { @@ -220,8 +225,6 @@ sealed interface Binding { } companion object { - const val FORBIDDEN_UNICODE_CHAR = MappableBinding.PREF_SEPARATOR - /** * https://www.fileformat.info/info/unicode/char/2328/index.htm (Keyboard) */ @@ -275,7 +278,8 @@ sealed interface Binding { GESTURE_PREFIX -> GestureInput(Gesture.valueOf(from.substring(1))) UNICODE_PREFIX -> { val (modifierKeys, char) = ModifierKeys.parse(from.substring(1)) - UnicodeCharacter(char[0], modifierKeys) + val unicodeChar = BindingUnicodeCharacter.fromCharOrNull(char[0]) ?: return UnknownBinding + UnicodeCharacter(unicodeChar, modifierKeys) } KEY_PREFIX -> { val (modifierKeys, keyCodeAsString) = ModifierKeys.parse(from.substring(1)) @@ -298,8 +302,8 @@ sealed interface Binding { unicodeChar: Char, modifierKeys: ModifierKeys = AppDefinedModifierKeys.allowShift(), ): Binding { - if (unicodeChar == FORBIDDEN_UNICODE_CHAR) return unknown() - return UnicodeCharacter(unicodeChar, modifierKeys) + val character = BindingUnicodeCharacter.fromCharOrNull(unicodeChar) ?: return unknown() + return UnicodeCharacter(character, modifierKeys) } fun keyCode( diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/BindingUnicodeCharacter.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/BindingUnicodeCharacter.kt new file mode 100644 index 000000000000..b08e4e5dcfc2 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/BindingUnicodeCharacter.kt @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.reviewer + +/** @see Binding.UnicodeCharacter */ +@JvmInline +value class BindingUnicodeCharacter( + val value: Char, +) { + init { + require(value != FORBIDDEN_UNICODE_CHAR) { "The preference separator cannot be bound" } + } + + companion object { + /** Separates bindings in preferences; using it as a key would split the binding during deserialization. */ + private const val FORBIDDEN_UNICODE_CHAR = MappableBinding.PREF_SEPARATOR + + /** Returns null for the preference separator, which cannot be stored as a binding. */ + fun fromCharOrNull(value: Char): BindingUnicodeCharacter? = + if (value == FORBIDDEN_UNICODE_CHAR) null else BindingUnicodeCharacter(value) + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/BindingAndroidTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/BindingAndroidTest.kt index e604aa084340..d874090f3595 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/BindingAndroidTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/BindingAndroidTest.kt @@ -50,6 +50,19 @@ class BindingAndroidTest : RobolectricTest() { assertEquals(Binding.gesture(Gesture.TAP_TOP), Binding.fromString(BindingTest.GESTURE_PREFIX + Gesture.TAP_TOP.name)) } + @Test + fun `deserialization rejects the preference separator`() { + assertEquals(Binding.UnknownBinding, Binding.fromString("✅|")) + assertEquals(Binding.UnknownBinding, Binding.fromString("✅Ctrl+|")) + } + + @Test + fun `unicode display unwraps the character`() { + val binding = Binding.UnicodeCharacter(BindingUnicodeCharacter('Ä'), BindingTest.allModifierKeys()) + + assertEquals("⌨ Ctrl+Alt+Shift+Ä", binding.toDisplayString()) + } + @Test fun `motion event serde`() { assertEquals(axis(Axis.X, 1.0f), axisBindingFromString("0 1.0")) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/BindingTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/BindingTest.kt index 636460e0969c..401a113eb0c5 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/BindingTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/BindingTest.kt @@ -29,7 +29,7 @@ class BindingTest { fun unicodeKeyIsLoaded() { val binding = unicodeCharacter('a') - assertThat(binding.unicodeCharacter, equalTo('a')) + assertThat(binding.unicodeCharacter, equalTo(BindingUnicodeCharacter('a'))) } @Test diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/BindingUnicodeCharacterTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/BindingUnicodeCharacterTest.kt new file mode 100644 index 000000000000..5836e66a38fe --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/BindingUnicodeCharacterTest.kt @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.reviewer + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test +import kotlin.test.assertNull + +class BindingUnicodeCharacterTest { + @Test + fun `preference separator cannot be constructed`() { + assertThrows(IllegalArgumentException::class.java) { + BindingUnicodeCharacter(MappableBinding.PREF_SEPARATOR) + } + } + + @Test + fun `preference separator is rejected without throwing`() { + assertNull(BindingUnicodeCharacter.fromCharOrNull(MappableBinding.PREF_SEPARATOR)) + } + + @Test + fun `supported characters retain their values`() { + for (character in listOf('a', 'Ä', ' ', '*')) { + assertEquals(character, BindingUnicodeCharacter(character).value) + assertEquals(BindingUnicodeCharacter(character), BindingUnicodeCharacter.fromCharOrNull(character)) + } + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/ui/BindingPreferenceTest.kt b/AnkiDroid/src/test/java/com/ichi2/ui/BindingPreferenceTest.kt index e02a4742ae6e..221b49d613b9 100644 --- a/AnkiDroid/src/test/java/com/ichi2/ui/BindingPreferenceTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/ui/BindingPreferenceTest.kt @@ -5,6 +5,8 @@ package com.ichi2.ui import androidx.test.ext.junit.runners.AndroidJUnit4 import com.ichi2.anki.cardviewer.Gesture import com.ichi2.anki.reviewer.Binding +import com.ichi2.anki.reviewer.BindingTest +import com.ichi2.anki.reviewer.BindingUnicodeCharacter import com.ichi2.anki.reviewer.CardSide import com.ichi2.anki.reviewer.MappableBinding import com.ichi2.anki.reviewer.MappableBinding.Companion.toPreferenceString @@ -33,12 +35,26 @@ class BindingPreferenceTest { assertEquals(expected, getSampleBindings().toPreferenceString()) } + @Test + fun `unicode characters and modifiers survive preference round trip`() { + val bindings = + listOf( + ReviewerBinding(Binding.UnicodeCharacter(BindingUnicodeCharacter('Ä'), BindingTest.allModifierKeys()), CardSide.QUESTION), + ReviewerBinding(Binding.UnicodeCharacter(BindingUnicodeCharacter(' ')), CardSide.ANSWER), + ReviewerBinding(Binding.UnicodeCharacter(BindingUnicodeCharacter('*')), CardSide.BOTH), + ) + val expected = "1/r✅Ctrl+Alt+Shift+Ä0|r✅ 1|r✅*2" + + assertEquals(expected, bindings.toPreferenceString()) + assertEquals(bindings, ReviewerBinding.fromPreferenceString(expected)) + } + private fun getSampleBindings(): List = listOf( ReviewerBinding(Binding.unicode('a'), CardSide.BOTH), ReviewerBinding(Binding.unicode(' '), CardSide.ANSWER), // this one is important: ensure that "|" as a unicode char can't be used - ReviewerBinding(Binding.unicode(Binding.FORBIDDEN_UNICODE_CHAR), CardSide.QUESTION), + ReviewerBinding(Binding.unicode(MappableBinding.PREF_SEPARATOR), CardSide.QUESTION), ReviewerBinding(Binding.gesture(Gesture.DOUBLE_TAP), CardSide.BOTH), ReviewerBinding(Binding.keyCode(12), CardSide.BOTH), ) From 72f1ef477fb8159e8f03e0daa0dca79f6438ce27 Mon Sep 17 00:00:00 2001 From: Arthur Milchior Date: Mon, 12 May 2025 01:26:14 +0200 Subject: [PATCH 076/163] refactor: validate Unicode input before creating bindings Use `BindingUnicodeCharacter` in more places Extracted from PR 18303: c98108d39a. Assisted-by: GPT-6-Astra xhigh --- .../ichi2/anki/cardviewer/ViewerCommand.kt | 13 +++--- .../anki/preferences/reviewer/ViewerAction.kt | 13 +++--- .../ichi2/anki/previewer/PreviewerAction.kt | 3 +- .../java/com/ichi2/anki/reviewer/Binding.kt | 14 ++---- .../ichi2/anki/reviewer/BindingAndroidTest.kt | 9 ++-- .../com/ichi2/anki/reviewer/BindingTest.kt | 46 +++++++++++++++++-- .../com/ichi2/ui/BindingPreferenceTest.kt | 6 +-- 7 files changed, 68 insertions(+), 36 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/ViewerCommand.kt b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/ViewerCommand.kt index 082f1f1c2826..3cd8dfc6313a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/ViewerCommand.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/ViewerCommand.kt @@ -9,6 +9,7 @@ import com.ichi2.anki.reviewer.Binding.Companion.unicode import com.ichi2.anki.reviewer.Binding.ModifierKeys import com.ichi2.anki.reviewer.Binding.ModifierKeys.Companion.ctrl import com.ichi2.anki.reviewer.Binding.ModifierKeys.Companion.shift +import com.ichi2.anki.reviewer.BindingUnicodeCharacter import com.ichi2.anki.reviewer.CardSide import com.ichi2.anki.reviewer.MappableAction import com.ichi2.anki.reviewer.ReviewerBinding @@ -108,11 +109,11 @@ enum class ViewerCommand : MappableAction { keyCode(KeyEvent.KEYCODE_NUMPAD_4, CardSide.ANSWER), ) EDIT -> listOf(keyCode(KeyEvent.KEYCODE_E, CardSide.BOTH)) - MARK -> listOf(unicode('*', CardSide.BOTH)) - BURY_CARD -> listOf(unicode('-', CardSide.BOTH)) - BURY_NOTE -> listOf(unicode('=', CardSide.BOTH)) - SUSPEND_CARD -> listOf(unicode('@', CardSide.BOTH)) - SUSPEND_NOTE -> listOf(unicode('!', CardSide.BOTH)) + MARK -> listOf(unicode(BindingUnicodeCharacter('*'), CardSide.BOTH)) + BURY_CARD -> listOf(unicode(BindingUnicodeCharacter('-'), CardSide.BOTH)) + BURY_NOTE -> listOf(unicode(BindingUnicodeCharacter('='), CardSide.BOTH)) + SUSPEND_CARD -> listOf(unicode(BindingUnicodeCharacter('@'), CardSide.BOTH)) + SUSPEND_NOTE -> listOf(unicode(BindingUnicodeCharacter('!'), CardSide.BOTH)) PLAY_MEDIA -> listOf(keyCode(KeyEvent.KEYCODE_R, CardSide.BOTH), keyCode(KeyEvent.KEYCODE_F5, CardSide.BOTH)) REPLAY_VOICE -> listOf(keyCode(KeyEvent.KEYCODE_V, CardSide.BOTH)) RECORD_VOICE -> listOf(keyCode(KeyEvent.KEYCODE_V, CardSide.BOTH, shift())) @@ -195,7 +196,7 @@ enum class ViewerCommand : MappableAction { ): ReviewerBinding = ReviewerBinding(keyCode(keycode, keys), side) private fun unicode( - c: Char, + c: BindingUnicodeCharacter, side: CardSide, ): ReviewerBinding = ReviewerBinding(unicode(c), side) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/reviewer/ViewerAction.kt b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/reviewer/ViewerAction.kt index d19849561f62..a07946d2aac3 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/reviewer/ViewerAction.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/reviewer/ViewerAction.kt @@ -19,6 +19,7 @@ import com.ichi2.anki.reviewer.Binding.AppDefinedModifierKeys import com.ichi2.anki.reviewer.Binding.ModifierKeys import com.ichi2.anki.reviewer.Binding.ModifierKeys.Companion.ctrl import com.ichi2.anki.reviewer.Binding.ModifierKeys.Companion.shift +import com.ichi2.anki.reviewer.BindingUnicodeCharacter import com.ichi2.anki.reviewer.CardSide import com.ichi2.anki.reviewer.MappableAction import com.ichi2.anki.reviewer.ReviewerBinding @@ -120,13 +121,13 @@ enum class ViewerAction( when (this) { UNDO -> listOf(keycode(KeyEvent.KEYCODE_Z, ctrl())) REDO -> listOf(keycode(KeyEvent.KEYCODE_Z, ModifierKeys(shift = true, ctrl = true))) - MARK -> listOf(unicode('*')) + MARK -> listOf(unicode(BindingUnicodeCharacter('*'))) EDIT -> listOf(keycode(KeyEvent.KEYCODE_E)) ADD_NOTE -> listOf(keycode(KeyEvent.KEYCODE_A)) - BURY_NOTE -> listOf(unicode('=')) - BURY_CARD -> listOf(unicode('-')) - SUSPEND_NOTE -> listOf(unicode('!')) - SUSPEND_CARD -> listOf(unicode('@')) + BURY_NOTE -> listOf(unicode(BindingUnicodeCharacter('='))) + BURY_CARD -> listOf(unicode(BindingUnicodeCharacter('-'))) + SUSPEND_NOTE -> listOf(unicode(BindingUnicodeCharacter('!'))) + SUSPEND_CARD -> listOf(unicode(BindingUnicodeCharacter('@'))) TOGGLE_AUTO_ADVANCE -> listOf(keycode(KeyEvent.KEYCODE_A, shift())) SHOW_HINT -> listOf(keycode(KeyEvent.KEYCODE_H)) SHOW_ALL_HINTS -> listOf(keycode(KeyEvent.KEYCODE_G)) @@ -321,7 +322,7 @@ enum class ViewerAction( } private fun unicode( - unicodeChar: Char, + unicodeChar: BindingUnicodeCharacter, keys: ModifierKeys = AppDefinedModifierKeys.allowShift(), side: CardSide = CardSide.BOTH, ): ReviewerBinding { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerAction.kt b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerAction.kt index 01657da992fc..60e276c5551a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerAction.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerAction.kt @@ -7,6 +7,7 @@ import android.view.KeyEvent import com.ichi2.anki.reviewer.Binding.Companion.keyCode import com.ichi2.anki.reviewer.Binding.Companion.unicode import com.ichi2.anki.reviewer.Binding.ModifierKeys.Companion.ctrl +import com.ichi2.anki.reviewer.BindingUnicodeCharacter import com.ichi2.anki.reviewer.MappableAction import com.ichi2.anki.reviewer.MappableBinding @@ -41,7 +42,7 @@ enum class PreviewerAction : MappableAction { when (this) { BACK -> keyCode(KeyEvent.KEYCODE_DPAD_LEFT) NEXT -> keyCode(KeyEvent.KEYCODE_DPAD_RIGHT) - MARK -> unicode('*') + MARK -> unicode(BindingUnicodeCharacter('*')) REPLAY_AUDIO -> keyCode(KeyEvent.KEYCODE_R) EDIT -> keyCode(KeyEvent.KEYCODE_E) TOGGLE_BACKSIDE_ONLY -> keyCode(KeyEvent.KEYCODE_B) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/Binding.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/Binding.kt index 20033e564380..cc4474cba956 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/Binding.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewer/Binding.kt @@ -259,11 +259,8 @@ sealed interface Binding { event .getUnicodeChar(event.metaState and (KeyEvent.META_SHIFT_ON or KeyEvent.META_NUM_LOCK_ON)) .ifNotZero { unicodeChar -> - try { - ret.add(unicode(unicodeChar.toChar(), modifiers) as KeyBinding) - } catch (e: Exception) { - // very slight chance it returns unknown() - Timber.w(e) + BindingUnicodeCharacter.fromCharOrNull(unicodeChar.toChar())?.let { character -> + ret.add(unicode(character, modifiers)) } } @@ -299,12 +296,9 @@ sealed interface Binding { * See [AppDefinedModifierKeys] */ fun unicode( - unicodeChar: Char, + unicodeChar: BindingUnicodeCharacter, modifierKeys: ModifierKeys = AppDefinedModifierKeys.allowShift(), - ): Binding { - val character = BindingUnicodeCharacter.fromCharOrNull(unicodeChar) ?: return unknown() - return UnicodeCharacter(character, modifierKeys) - } + ): KeyBinding = UnicodeCharacter(unicodeChar, modifierKeys) fun keyCode( keyCode: Int, diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/BindingAndroidTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/BindingAndroidTest.kt index d874090f3595..3be90ce82cf4 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/BindingAndroidTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/BindingAndroidTest.kt @@ -27,10 +27,11 @@ class BindingAndroidTest : RobolectricTest() { @Test fun testFromString() { - assertEquals(Binding.unicode('Ä'), Binding.fromString(BindingTest.UNICODE_PREFIX + "Ä")) - assertEquals(Binding.unicode('Ä', ctrl()), Binding.fromString(BindingTest.UNICODE_PREFIX + "Ctrl+Ä")) - assertEquals(Binding.unicode('Ä', shift()), Binding.fromString(BindingTest.UNICODE_PREFIX + "Shift+Ä")) - assertEquals(Binding.unicode('Ä', alt()), Binding.fromString(BindingTest.UNICODE_PREFIX + "Alt+Ä")) + val character = BindingUnicodeCharacter('Ä') + assertEquals(Binding.unicode(character), Binding.fromString(BindingTest.UNICODE_PREFIX + "Ä")) + assertEquals(Binding.unicode(character, ctrl()), Binding.fromString(BindingTest.UNICODE_PREFIX + "Ctrl+Ä")) + assertEquals(Binding.unicode(character, shift()), Binding.fromString(BindingTest.UNICODE_PREFIX + "Shift+Ä")) + assertEquals(Binding.unicode(character, alt()), Binding.fromString(BindingTest.UNICODE_PREFIX + "Alt+Ä")) assertEquals( Binding.keyCode(KeyEvent.KEYCODE_MEDIA_NEXT), Binding.fromString(BindingTest.KEY_PREFIX + KeyEvent.keyCodeToString(KeyEvent.KEYCODE_MEDIA_NEXT)), diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/BindingTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/BindingTest.kt index 401a113eb0c5..70de2742a441 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/BindingTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/reviewer/BindingTest.kt @@ -32,6 +32,41 @@ class BindingTest { assertThat(binding.unicodeCharacter, equalTo(BindingUnicodeCharacter('a'))) } + @Test + fun `valid unicode input retains modifiers`() { + val event = + mock { + on { keyCode } doReturn KeyEvent.KEYCODE_8 + on { getUnicodeChar(anyInt()) } doReturn '*'.code + on { isShiftPressed } doReturn true + on { isCtrlPressed } doReturn true + on { isAltPressed } doReturn true + } + val binding = Binding.possibleKeyBindings(event).filterIsInstance().single() + + assertEquals(BindingUnicodeCharacter('*'), binding.unicodeCharacter) + assertTrue(binding.modifierKeys.shift) + assertTrue(binding.modifierKeys.ctrl) + assertTrue(binding.modifierKeys.alt) + } + + @Test + fun `forbidden unicode input retains the keycode binding and modifiers`() { + val event = + mock { + on { keyCode } doReturn KeyEvent.KEYCODE_BACKSLASH + on { getUnicodeChar(anyInt()) } doReturn MappableBinding.PREF_SEPARATOR.code + on { isShiftPressed } doReturn true + on { isCtrlPressed } doReturn true + on { isAltPressed } doReturn true + } + + assertEquals( + listOf(Binding.keyCode(KeyEvent.KEYCODE_BACKSLASH, allModifierKeys())), + Binding.possibleKeyBindings(event), + ) + } + @Test fun keycodeIsLoaded() { val binding = keyCode(KeyEvent.KEYCODE_A) @@ -41,11 +76,12 @@ class BindingTest { @Test fun testUnicodeToString() { - assertEquals(UNICODE_PREFIX + "Ä", Binding.unicode('Ä').toString()) - assertEquals(UNICODE_PREFIX + "Ctrl+Ä", Binding.unicode('Ä', Binding.ModifierKeys.ctrl()).toString()) - assertEquals(UNICODE_PREFIX + "Shift+Ä", Binding.unicode('Ä', Binding.ModifierKeys.shift()).toString()) - assertEquals(UNICODE_PREFIX + "Alt+Ä", Binding.unicode('Ä', Binding.ModifierKeys.alt()).toString()) - assertEquals(UNICODE_PREFIX + "Ctrl+Alt+Shift+Ä", Binding.unicode('Ä', allModifierKeys()).toString()) + val character = BindingUnicodeCharacter('Ä') + assertEquals(UNICODE_PREFIX + "Ä", Binding.unicode(character).toString()) + assertEquals(UNICODE_PREFIX + "Ctrl+Ä", Binding.unicode(character, Binding.ModifierKeys.ctrl()).toString()) + assertEquals(UNICODE_PREFIX + "Shift+Ä", Binding.unicode(character, Binding.ModifierKeys.shift()).toString()) + assertEquals(UNICODE_PREFIX + "Alt+Ä", Binding.unicode(character, Binding.ModifierKeys.alt()).toString()) + assertEquals(UNICODE_PREFIX + "Ctrl+Alt+Shift+Ä", Binding.unicode(character, allModifierKeys()).toString()) } @Test diff --git a/AnkiDroid/src/test/java/com/ichi2/ui/BindingPreferenceTest.kt b/AnkiDroid/src/test/java/com/ichi2/ui/BindingPreferenceTest.kt index 221b49d613b9..d1e96a7c0325 100644 --- a/AnkiDroid/src/test/java/com/ichi2/ui/BindingPreferenceTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/ui/BindingPreferenceTest.kt @@ -51,10 +51,8 @@ class BindingPreferenceTest { private fun getSampleBindings(): List = listOf( - ReviewerBinding(Binding.unicode('a'), CardSide.BOTH), - ReviewerBinding(Binding.unicode(' '), CardSide.ANSWER), - // this one is important: ensure that "|" as a unicode char can't be used - ReviewerBinding(Binding.unicode(MappableBinding.PREF_SEPARATOR), CardSide.QUESTION), + ReviewerBinding(Binding.unicode(BindingUnicodeCharacter('a')), CardSide.BOTH), + ReviewerBinding(Binding.unicode(BindingUnicodeCharacter(' ')), CardSide.ANSWER), ReviewerBinding(Binding.gesture(Gesture.DOUBLE_TAP), CardSide.BOTH), ReviewerBinding(Binding.keyCode(12), CardSide.BOTH), ) From 9159e41bf2dcbdb470922bd658f696ada4beec72 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:43:58 +0100 Subject: [PATCH 077/163] refactor(browser): extract 'BrowserSearchFeedback' Extract the existing search feedback rules into a helper. Part of 22384 Assisted-by: GPT-6-Astra xhigh --- .../anki/browser/BrowserSearchFeedback.kt | 31 +++++ .../ichi2/anki/browser/CardBrowserFragment.kt | 45 +++---- .../anki/browser/CardBrowserViewModel.kt | 37 ++---- .../java/com/ichi2/anki/CardBrowserTest.kt | 3 +- .../anki/browser/BrowserSearchFeedbackTest.kt | 115 ++++++++++++++++++ .../anki/browser/CardBrowserViewModelTest.kt | 81 ++++-------- 6 files changed, 196 insertions(+), 116 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserSearchFeedback.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/browser/BrowserSearchFeedbackTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserSearchFeedback.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserSearchFeedback.kt new file mode 100644 index 000000000000..3990c563ece5 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserSearchFeedback.kt @@ -0,0 +1,31 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.browser + +/** The feedback to display after a browser search completes. */ +internal enum class BrowserSearchFeedback( + val offersSearchAllDecks: Boolean = false, +) { + NONE, + COUNT, + COUNT_WITH_SEARCH_ALL_DECKS(offersSearchAllDecks = true), + NO_CARDS_IN_SELECTED_DECK(offersSearchAllDecks = true), +} + +internal fun browserSearchFeedback( + fromUserSearch: Boolean, + rowCount: Int, + allDecksSelected: Boolean, + isHeaderCountVisible: Boolean, +): BrowserSearchFeedback = + // The search facts come from the completed search; header visibility is checked when displaying it. + when { + // Ordinary opening, deck changes and sorting stay quiet (Issue 21242). + !fromUserSearch -> BrowserSearchFeedback.NONE + // A search within a deck also offers "Search all decks", even with a visible header (Issue 5159). + !allDecksSelected && rowCount == 0 -> BrowserSearchFeedback.NO_CARDS_IN_SELECTED_DECK + !allDecksSelected -> BrowserSearchFeedback.COUNT_WITH_SEARCH_ALL_DECKS + // Submitted searches show the count when the header hides it (Issue 3592). + isHeaderCountVisible -> BrowserSearchFeedback.NONE + else -> BrowserSearchFeedback.COUNT + } 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 d3d951d8f14b..269f84b937d1 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt @@ -79,7 +79,6 @@ import com.ichi2.anki.browser.CardBrowserViewModel.ChangeMultiSelectMode.MultiSe import com.ichi2.anki.browser.CardBrowserViewModel.ChangeMultiSelectMode.SingleSelectCause import com.ichi2.anki.browser.CardBrowserViewModel.ChangeNoteTypeResponse import com.ichi2.anki.browser.CardBrowserViewModel.RowSelection -import com.ichi2.anki.browser.CardBrowserViewModel.SearchResultMessage import com.ichi2.anki.browser.CardBrowserViewModel.SearchState import com.ichi2.anki.browser.CardBrowserViewModel.SearchState.Initializing import com.ichi2.anki.browser.CardBrowserViewModel.SearchState.Searching @@ -1006,39 +1005,27 @@ class CardBrowserFragment : } fun onSearchCompleted(state: SearchState.Completed) { - // #3592: show the number of cards found the number of cards is not visible in the menu - val isMenuSubtitleVisible = legacySearchView != null && legacySearchView!!.isIconified - - fun showSnackbar( - message: String, - searchAllDecks: Boolean, - ) { - // Don't show a snackbar if the results are visible in the header. - // But do show the snackbar if an action is available - if (isMenuSubtitleVisible && !searchAllDecks) return - - showSnackbar(message, Snackbar.LENGTH_SHORT) { - if (!searchAllDecks) return@showSnackbar + val feedback = + browserSearchFeedback( + fromUserSearch = state.fromUserSearch, + rowCount = state.rowCount, + allDecksSelected = state.allDecksSelected, + isHeaderCountVisible = legacySearchView?.isIconified == true, + ) + val message = + when (feedback) { + BrowserSearchFeedback.NONE -> return + BrowserSearchFeedback.COUNT, BrowserSearchFeedback.COUNT_WITH_SEARCH_ALL_DECKS -> state.formatCardCount(resources) + BrowserSearchFeedback.NO_CARDS_IN_SELECTED_DECK -> + getString(CommonString.card_browser_no_cards_in_deck, selectedDeckNameForUi) + } + showSnackbar(message, Snackbar.LENGTH_SHORT) { + if (feedback.offersSearchAllDecks) { setAction(CommonString.card_browser_search_all_decks) { activityViewModel.setSelectedDeck(SelectableDeck.AllDecks) } } } - - when (val result = state.resultMessage) { - // no message for browser open / deck change / order change: only user searches - null -> return - is SearchResultMessage.CardCount -> - showSnackbar( - message = state.formatCardCount(resources), - searchAllDecks = result.includeSearchAllDecksAction, - ) - SearchResultMessage.NoCardsInSelectedDeck -> - showSnackbar( - getString(CommonString.card_browser_no_cards_in_deck, selectedDeckNameForUi), - searchAllDecks = true, - ) - } } fun searchStateChanged(searchState: SearchState) { 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 98a3e0706b0f..5741a1be6e63 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt @@ -186,17 +186,6 @@ class CardBrowserViewModel( companion object } - /** Result of a completed search, used to drive a snackbar in the UI */ - sealed interface SearchResultMessage { - /** "X cards/notes shown | Search all decks" */ - data class CardCount( - val includeSearchAllDecksAction: Boolean, - ) : SearchResultMessage - - /** "No cards in deck X" message; always paired with a "search all decks" action. */ - data object NoCardsInSelectedDeck : SearchResultMessage - } - /** text in the search box (potentially unsubmitted) */ // this does not currently bind to the value in the UI and is only used for posting val flowOfFilterQuery = MutableSharedFlow() @@ -1445,8 +1434,7 @@ class CardBrowserViewModel( /** * @param forceRefresh if `true`, perform a search even if the search query is unchanged - * @param fromUserSearch whether the user explicitly searched for something; controls whether a - * result message (snackbar) is surfaced. See [SearchState.Completed.resultMessage] + * @param fromUserSearch whether the user explicitly submitted this search; controls feedback via [browserSearchFeedback] */ fun launchSearchForCards( searchRequest: SearchRequest, @@ -1611,20 +1599,14 @@ class CardBrowserViewModel( /** * Builds a [SearchState.Completed] event reflecting the current ViewModel state. * - * @param fromUserSearch whether this search was triggered by the user searching for something. + * @param fromUserSearch whether the user explicitly submitted this search. */ private fun SearchState.Completed.Companion.fromCurrentState(fromUserSearch: Boolean = false): SearchState.Completed = SearchState.Completed( rowCount = rowCount, cardsOrNotes = cardsOrNotes, - resultMessage = - when { - !fromUserSearch -> null - // TODO: better message if rowCount == 0 AND hasSelectedAllDecks - hasSelectedAllDecks() -> SearchResultMessage.CardCount(includeSearchAllDecksAction = false) - rowCount == 0 -> SearchResultMessage.NoCardsInSelectedDeck - else -> SearchResultMessage.CardCount(includeSearchAllDecksAction = true) - }, + fromUserSearch = fromUserSearch, + allDecksSelected = hasSelectedAllDecks(), ) /** Builds the post-search trailing-pane command from current ViewModel state (tablet only). */ @@ -1749,13 +1731,10 @@ class CardBrowserViewModel( data class Completed( val rowCount: Int, val cardsOrNotes: CardsOrNotes, - /** - * The message (snackbar) to surface for this search, or `null` if none should be shown. - * - * Only populated for explicit user searches; `null` for browser open, deck change and - * order/direction changes. - */ - val resultMessage: SearchResultMessage?, + /** Whether the user explicitly submitted this search, rather than an automatic refresh. */ + val fromUserSearch: Boolean, + /** The deck scope used by this search, before any subsequent UI changes. */ + val allDecksSelected: Boolean, ) : SearchState { companion object } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt index 17d325e6813f..1ef03796da19 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt @@ -526,7 +526,8 @@ class CardBrowserTest : RobolectricTest() { CardBrowserViewModel.SearchState.Completed( rowCount = count, cardsOrNotes = mode, - resultMessage = CardBrowserViewModel.SearchResultMessage.CardCount(includeSearchAllDecksAction = false), + fromUserSearch = true, + allDecksSelected = true, ) assertThat(search.formatCardCount(targetContext.resources), equalTo(expected)) } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/browser/BrowserSearchFeedbackTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/browser/BrowserSearchFeedbackTest.kt new file mode 100644 index 000000000000..346dda437d6c --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/browser/BrowserSearchFeedbackTest.kt @@ -0,0 +1,115 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.browser + +import com.ichi2.anki.browser.BrowserSearchFeedback.COUNT +import com.ichi2.anki.browser.BrowserSearchFeedback.COUNT_WITH_SEARCH_ALL_DECKS +import com.ichi2.anki.browser.BrowserSearchFeedback.NONE +import com.ichi2.anki.browser.BrowserSearchFeedback.NO_CARDS_IN_SELECTED_DECK +import org.junit.Test +import kotlin.test.assertEquals + +/** Feedback rules tested without Android views or a collection. */ +class BrowserSearchFeedbackTest { + @Test + fun `automatic searches stay quiet even when an empty deck could offer an action`() { + for (headerVisible in listOf(false, true)) { + assertEquals( + NONE, + browserSearchFeedback( + fromUserSearch = false, + rowCount = 0, + allDecksSelected = false, + isHeaderCountVisible = headerVisible, + ), + ) + assertEquals( + NONE, + browserSearchFeedback( + fromUserSearch = false, + rowCount = 2, + allDecksSelected = false, + isHeaderCountVisible = headerVisible, + ), + ) + assertEquals( + NONE, + browserSearchFeedback( + fromUserSearch = false, + rowCount = 0, + allDecksSelected = true, + isHeaderCountVisible = headerVisible, + ), + ) + assertEquals( + NONE, + browserSearchFeedback( + fromUserSearch = false, + rowCount = 2, + allDecksSelected = true, + isHeaderCountVisible = headerVisible, + ), + ) + } + } + + @Test + fun `submitted search in all decks shows the count including zero when the header is hidden`() { + for (count in listOf(0, 2)) { + assertEquals( + COUNT, + browserSearchFeedback( + fromUserSearch = true, + rowCount = count, + allDecksSelected = true, + isHeaderCountVisible = false, + ), + ) + } + } + + @Test + fun `submitted search does not duplicate a visible count when there is no action`() { + for (count in listOf(0, 2)) { + assertEquals( + NONE, + browserSearchFeedback( + fromUserSearch = true, + rowCount = count, + allDecksSelected = true, + isHeaderCountVisible = true, + ), + ) + } + } + + @Test + fun `submitted search with matches in a deck offers search all decks even with a visible header`() { + for (headerVisible in listOf(false, true)) { + val feedback = + browserSearchFeedback( + fromUserSearch = true, + rowCount = 2, + allDecksSelected = false, + isHeaderCountVisible = headerVisible, + ) + assertEquals(COUNT_WITH_SEARCH_ALL_DECKS, feedback) + assertEquals(true, feedback.offersSearchAllDecks) + } + } + + @Test + fun `submitted search with no matches in a deck explains the scope and offers search all decks`() { + for (headerVisible in listOf(false, true)) { + val feedback = + browserSearchFeedback( + fromUserSearch = true, + rowCount = 0, + allDecksSelected = false, + isHeaderCountVisible = headerVisible, + ) + assertEquals(NO_CARDS_IN_SELECTED_DECK, feedback) + assertEquals(true, feedback.offersSearchAllDecks) + } + } +} 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 1d8a64a56273..c2cf00525956 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserViewModelTest.kt @@ -1657,38 +1657,18 @@ class CardBrowserViewModelTest : JvmTest() { } @Test - fun `searchResultMessage - initial search provides rows without a message - Issue 21242`() = + fun `initial search is not a user submission - Issue 21242`() = runViewModelTest(notes = 2, initMode = InitMode.MANUAL) { flowOfSearchState.test { manualInit() val completed = awaitSearchCompleted() assertThat(completed.rowCount, equalTo(2)) - assertThat(completed.resultMessage, nullValue()) + assertThat(completed.fromUserSearch, equalTo(false)) } } @Test - fun `searchResultMessage - sorting and reversing do not produce count messages`() = - runViewModelTest(notes = 2) { - flowOfSearchState.test { - ignoreEventsDuringViewModelInit() - val sortOrders = - listOf( - SortType.CollectionOrdering(BrowserColumnKey("noteCrt"), reverse = false), - SortType.CollectionOrdering(BrowserColumnKey("noteCrt"), reverse = true), - SortType.NoOrdering, - ) - for (sort in sortOrders) { - setSortType(sort).join() - val completed = awaitSearchCompleted() - assertThat("sorting retains the results", completed.rowCount, equalTo(2)) - assertThat("sorting has its own feedback", completed.resultMessage, nullValue()) - } - } - } - - @Test - fun `searchResultMessage - user search, all decks selected, with rows`() = + fun `completed search retains the user submission and deck scope`() = runViewModelTest(notes = 3) { flowOfSearchState.test { ignoreEventsDuringViewModelInit() @@ -1696,58 +1676,45 @@ class CardBrowserViewModelTest : JvmTest() { awaitSearchCompleted() setQuery("", fromUserSearch = true) val completed = awaitSearchCompleted() - val card = completed.resultMessage as CardBrowserViewModel.SearchResultMessage.CardCount assertThat("count", completed.rowCount, equalTo(3)) assertThat("cardsOrNotes", completed.cardsOrNotes, equalTo(CardsOrNotes.CARDS)) - assertThat("no all-decks action when already on all decks", card.includeSearchAllDecksAction, equalTo(false)) + assertThat(completed.fromUserSearch, equalTo(true)) + assertThat(completed.allDecksSelected, equalTo(true)) } } @Test - fun `searchResultMessage - user search, specific deck with cards has all-decks action`() = + fun `deck change is not a user search submission`() = runViewModelTest { val deck = addDeck("Specific") addNoteToDeck(deck) flowOfSearchState.test { ignoreEventsDuringViewModelInit() setSelectedDeck(deck) - awaitSearchCompleted() - setQuery("", fromUserSearch = true) - val card = awaitSearchCompleted().resultMessage as CardBrowserViewModel.SearchResultMessage.CardCount - assertThat("includes all-decks action", card.includeSearchAllDecksAction, equalTo(true)) - } - } - - @Test - fun `searchResultMessage - user search, specific deck with no cards`() = - runViewModelTest { - val deck = addDeck("Empty") - flowOfSearchState.test { - ignoreEventsDuringViewModelInit() - setSelectedDeck(deck) - awaitSearchCompleted() - setQuery("", fromUserSearch = true) - assertThat( - "empty deck → no-cards-in-selected-deck", - awaitSearchCompleted().resultMessage, - equalTo(CardBrowserViewModel.SearchResultMessage.NoCardsInSelectedDeck), - ) + val completed = awaitSearchCompleted() + assertThat(completed.rowCount, equalTo(1)) + assertThat(completed.fromUserSearch, equalTo(false)) + assertThat(completed.allDecksSelected, equalTo(false)) } } @Test - fun `searchResultMessage - no message on deck change`() = - runViewModelTest { - val deck = addDeck("Specific") - addNoteToDeck(deck) + fun `sorting and reversing are not user search submissions`() = + runViewModelTest(notes = 2) { flowOfSearchState.test { ignoreEventsDuringViewModelInit() - setSelectedDeck(deck) - assertThat( - "changing deck does not surface a result message", - awaitSearchCompleted().resultMessage, - nullValue(), - ) + val sortOrders = + listOf( + SortType.CollectionOrdering(BrowserColumnKey("noteCrt"), reverse = false), + SortType.CollectionOrdering(BrowserColumnKey("noteCrt"), reverse = true), + SortType.NoOrdering, + ) + for (sort in sortOrders) { + setSortType(sort).join() + val completed = awaitSearchCompleted() + assertThat("sorting retains the results", completed.rowCount, equalTo(2)) + assertThat("sorting has its own feedback", completed.fromUserSearch, equalTo(false)) + } } } From 3ef05ac6cffaf061988f87087f1af88bb927f006 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:46:13 +0100 Subject: [PATCH 078/163] refactor(browser): add 'BrowserSearchFeedbackMode' This is a better abstraction than `fromUserSearch`: callers specify the feedback they request, rather than the cause of the search. No behavioral change COUNT_ONLY is currently unused, to be implemented in a follow-up. Part of 22384 Assisted-by: GPT-6-Astra xhigh --- .../anki/browser/BrowserSearchFeedback.kt | 37 ++++++++++++---- .../ichi2/anki/browser/CardBrowserFragment.kt | 6 +-- .../anki/browser/CardBrowserViewModel.kt | 26 ++++++----- .../java/com/ichi2/anki/CardBrowserTest.kt | 3 +- .../anki/browser/BrowserSearchFeedbackTest.kt | 44 +++++++++++++++---- .../browser/CardBrowserSearchFeedbackTest.kt | 2 +- .../anki/browser/CardBrowserViewModelTest.kt | 18 ++++---- 7 files changed, 92 insertions(+), 44 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserSearchFeedback.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserSearchFeedback.kt index 3990c563ece5..d573dc06deaf 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserSearchFeedback.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserSearchFeedback.kt @@ -2,6 +2,18 @@ package com.ichi2.anki.browser +/** The feedback requested for a completed browser search. */ +enum class BrowserSearchFeedbackMode { + /** Suppress search feedback, including for operations which provide their own message. */ + NONE, + + /** Report search results, offering "Search all decks" when applicable. */ + SEARCH_RESULTS, + + /** Show the result count when it is hidden in the header. */ + COUNT_ONLY, +} + /** The feedback to display after a browser search completes. */ internal enum class BrowserSearchFeedback( val offersSearchAllDecks: Boolean = false, @@ -13,19 +25,26 @@ internal enum class BrowserSearchFeedback( } internal fun browserSearchFeedback( - fromUserSearch: Boolean, + feedbackMode: BrowserSearchFeedbackMode, rowCount: Int, allDecksSelected: Boolean, isHeaderCountVisible: Boolean, ): BrowserSearchFeedback = // The search facts come from the completed search; header visibility is checked when displaying it. - when { + when (feedbackMode) { // Ordinary opening, deck changes and sorting stay quiet (Issue 21242). - !fromUserSearch -> BrowserSearchFeedback.NONE - // A search within a deck also offers "Search all decks", even with a visible header (Issue 5159). - !allDecksSelected && rowCount == 0 -> BrowserSearchFeedback.NO_CARDS_IN_SELECTED_DECK - !allDecksSelected -> BrowserSearchFeedback.COUNT_WITH_SEARCH_ALL_DECKS - // Submitted searches show the count when the header hides it (Issue 3592). - isHeaderCountVisible -> BrowserSearchFeedback.NONE - else -> BrowserSearchFeedback.COUNT + // Operations with their own feedback use this mode. + BrowserSearchFeedbackMode.NONE -> BrowserSearchFeedback.NONE + // User actions without their own message report the hidden count, without an action (Issue 22384). + BrowserSearchFeedbackMode.COUNT_ONLY -> + if (isHeaderCountVisible) BrowserSearchFeedback.NONE else BrowserSearchFeedback.COUNT + BrowserSearchFeedbackMode.SEARCH_RESULTS -> + when { + // A search within a deck also offers "Search all decks", even with a visible header (Issue 5159). + !allDecksSelected && rowCount == 0 -> BrowserSearchFeedback.NO_CARDS_IN_SELECTED_DECK + !allDecksSelected -> BrowserSearchFeedback.COUNT_WITH_SEARCH_ALL_DECKS + // Submitted searches show the count when the header hides it (Issue 3592). + isHeaderCountVisible -> BrowserSearchFeedback.NONE + else -> BrowserSearchFeedback.COUNT + } } 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 269f84b937d1..719660a7d4ac 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt @@ -598,7 +598,7 @@ class CardBrowserFragment : } override fun onQueryTextSubmit(query: String): Boolean { - vm.setQuery(query, fromUserSearch = true) + vm.setQuery(query, feedbackMode = BrowserSearchFeedbackMode.SEARCH_RESULTS) legacySearchView!!.clearFocus() return true } @@ -1007,7 +1007,7 @@ class CardBrowserFragment : fun onSearchCompleted(state: SearchState.Completed) { val feedback = browserSearchFeedback( - fromUserSearch = state.fromUserSearch, + feedbackMode = state.feedbackMode, rowCount = state.rowCount, allDecksSelected = state.allDecksSelected, isHeaderCountVisible = legacySearchView?.isIconified == true, @@ -1142,7 +1142,7 @@ class CardBrowserFragment : launchCatchingTask { searchBar?.setText(value.toUserSpannable()) } Timber.i("relaying submitted search to activity") - activityViewModel.launchSearchForCards(value, forceRefresh = false, fromUserSearch = true) + activityViewModel.launchSearchForCards(value, forceRefresh = false, feedbackMode = BrowserSearchFeedbackMode.SEARCH_RESULTS) } fun onUserMessage(message: UserMessage) = 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 5741a1be6e63..37e9aa3ab1a3 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt @@ -1263,7 +1263,7 @@ class CardBrowserViewModel( fun setQuery( query: String, forceRefresh: Boolean = true, - fromUserSearch: Boolean = false, + feedbackMode: BrowserSearchFeedbackMode = BrowserSearchFeedbackMode.NONE, ) = viewModelScope.launch { val newValue = searchRequestFlow.value.copy(query = query) if (!forceRefresh && withCol { searchRequestFlow.value.toSearchString() == newValue.toSearchString() }) { @@ -1272,7 +1272,7 @@ class CardBrowserViewModel( } searchRequestFlow.value = newValue - launchSearchForCards(fromUserSearch = fromUserSearch) + launchSearchForCards(feedbackMode = feedbackMode) } /** @@ -1434,12 +1434,12 @@ class CardBrowserViewModel( /** * @param forceRefresh if `true`, perform a search even if the search query is unchanged - * @param fromUserSearch whether the user explicitly submitted this search; controls feedback via [browserSearchFeedback] + * @param feedbackMode the feedback requested for this search, resolved by [browserSearchFeedback] */ fun launchSearchForCards( searchRequest: SearchRequest, forceRefresh: Boolean, - fromUserSearch: Boolean = false, + feedbackMode: BrowserSearchFeedbackMode = BrowserSearchFeedbackMode.NONE, ) = viewModelScope.launch { Timber.d("launching search [new syntax]: '%s'", searchRequest) @@ -1451,7 +1451,7 @@ class CardBrowserViewModel( } searchRequestFlow.value = searchRequest - launchSearchForCards(fromUserSearch = fromUserSearch) + launchSearchForCards(feedbackMode = feedbackMode) } /** @@ -1460,7 +1460,7 @@ class CardBrowserViewModel( * @see com.ichi2.anki.searchForRows */ @NeedsTest("Invalid searches are handled. For instance: 'and'") - fun launchSearchForCards(fromUserSearch: Boolean = false) { + fun launchSearchForCards(feedbackMode: BrowserSearchFeedbackMode = BrowserSearchFeedbackMode.NONE) { if (!initCompleted) return viewModelScope.launch { @@ -1486,7 +1486,7 @@ class CardBrowserViewModel( } ensurePaneRowValid() if (isFragmented) flowOfNoteEditorCommand.emit(NoteEditorCommand.fromCurrentSearchState()) - flowOfSearchState.emit(SearchState.Completed.fromCurrentState(fromUserSearch)) + flowOfSearchState.emit(SearchState.Completed.fromCurrentState(feedbackMode)) selectUnvalidatedRowIds(pendingSelectionRestore) pendingSelectionRestore = emptyList() } @@ -1599,13 +1599,15 @@ class CardBrowserViewModel( /** * Builds a [SearchState.Completed] event reflecting the current ViewModel state. * - * @param fromUserSearch whether the user explicitly submitted this search. + * @param feedbackMode the feedback requested for this search. */ - private fun SearchState.Completed.Companion.fromCurrentState(fromUserSearch: Boolean = false): SearchState.Completed = + private fun SearchState.Completed.Companion.fromCurrentState( + feedbackMode: BrowserSearchFeedbackMode = BrowserSearchFeedbackMode.NONE, + ): SearchState.Completed = SearchState.Completed( rowCount = rowCount, cardsOrNotes = cardsOrNotes, - fromUserSearch = fromUserSearch, + feedbackMode = feedbackMode, allDecksSelected = hasSelectedAllDecks(), ) @@ -1731,8 +1733,8 @@ class CardBrowserViewModel( data class Completed( val rowCount: Int, val cardsOrNotes: CardsOrNotes, - /** Whether the user explicitly submitted this search, rather than an automatic refresh. */ - val fromUserSearch: Boolean, + /** The feedback requested by the caller for this search. */ + val feedbackMode: BrowserSearchFeedbackMode, /** The deck scope used by this search, before any subsequent UI changes. */ val allDecksSelected: Boolean, ) : SearchState { diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt index 1ef03796da19..6db8de823d27 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt @@ -47,6 +47,7 @@ import com.ichi2.anki.RobolectricTest.Companion.advanceRobolectricLooperUntil import com.ichi2.anki.browser.BrowserColumnKey import com.ichi2.anki.browser.BrowserMultiColumnAdapter import com.ichi2.anki.browser.BrowserMultiColumnAdapter.Companion.LINES_VISIBLE_WHEN_COLLAPSED +import com.ichi2.anki.browser.BrowserSearchFeedbackMode import com.ichi2.anki.browser.CardBrowserColumn import com.ichi2.anki.browser.CardBrowserColumn.DECK import com.ichi2.anki.browser.CardBrowserColumn.QUESTION @@ -526,7 +527,7 @@ class CardBrowserTest : RobolectricTest() { CardBrowserViewModel.SearchState.Completed( rowCount = count, cardsOrNotes = mode, - fromUserSearch = true, + feedbackMode = BrowserSearchFeedbackMode.SEARCH_RESULTS, allDecksSelected = true, ) assertThat(search.formatCardCount(targetContext.resources), equalTo(expected)) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/browser/BrowserSearchFeedbackTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/browser/BrowserSearchFeedbackTest.kt index 346dda437d6c..a692f0a9eaa5 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/browser/BrowserSearchFeedbackTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/browser/BrowserSearchFeedbackTest.kt @@ -12,12 +12,38 @@ import kotlin.test.assertEquals /** Feedback rules tested without Android views or a collection. */ class BrowserSearchFeedbackTest { @Test - fun `automatic searches stay quiet even when an empty deck could offer an action`() { + fun `count-only feedback shows a hidden count regardless of scope or number of results`() { + for (allDecks in listOf(false, true)) { + for (count in listOf(0, 2)) { + assertEquals( + COUNT, + browserSearchFeedback( + feedbackMode = BrowserSearchFeedbackMode.COUNT_ONLY, + rowCount = count, + allDecksSelected = allDecks, + isHeaderCountVisible = false, + ), + ) + assertEquals( + NONE, + browserSearchFeedback( + feedbackMode = BrowserSearchFeedbackMode.COUNT_ONLY, + rowCount = count, + allDecksSelected = allDecks, + isHeaderCountVisible = true, + ), + ) + } + } + } + + @Test + fun `disabled feedback stays quiet even when an empty deck could offer an action`() { for (headerVisible in listOf(false, true)) { assertEquals( NONE, browserSearchFeedback( - fromUserSearch = false, + feedbackMode = BrowserSearchFeedbackMode.NONE, rowCount = 0, allDecksSelected = false, isHeaderCountVisible = headerVisible, @@ -26,7 +52,7 @@ class BrowserSearchFeedbackTest { assertEquals( NONE, browserSearchFeedback( - fromUserSearch = false, + feedbackMode = BrowserSearchFeedbackMode.NONE, rowCount = 2, allDecksSelected = false, isHeaderCountVisible = headerVisible, @@ -35,7 +61,7 @@ class BrowserSearchFeedbackTest { assertEquals( NONE, browserSearchFeedback( - fromUserSearch = false, + feedbackMode = BrowserSearchFeedbackMode.NONE, rowCount = 0, allDecksSelected = true, isHeaderCountVisible = headerVisible, @@ -44,7 +70,7 @@ class BrowserSearchFeedbackTest { assertEquals( NONE, browserSearchFeedback( - fromUserSearch = false, + feedbackMode = BrowserSearchFeedbackMode.NONE, rowCount = 2, allDecksSelected = true, isHeaderCountVisible = headerVisible, @@ -59,7 +85,7 @@ class BrowserSearchFeedbackTest { assertEquals( COUNT, browserSearchFeedback( - fromUserSearch = true, + feedbackMode = BrowserSearchFeedbackMode.SEARCH_RESULTS, rowCount = count, allDecksSelected = true, isHeaderCountVisible = false, @@ -74,7 +100,7 @@ class BrowserSearchFeedbackTest { assertEquals( NONE, browserSearchFeedback( - fromUserSearch = true, + feedbackMode = BrowserSearchFeedbackMode.SEARCH_RESULTS, rowCount = count, allDecksSelected = true, isHeaderCountVisible = true, @@ -88,7 +114,7 @@ class BrowserSearchFeedbackTest { for (headerVisible in listOf(false, true)) { val feedback = browserSearchFeedback( - fromUserSearch = true, + feedbackMode = BrowserSearchFeedbackMode.SEARCH_RESULTS, rowCount = 2, allDecksSelected = false, isHeaderCountVisible = headerVisible, @@ -103,7 +129,7 @@ class BrowserSearchFeedbackTest { for (headerVisible in listOf(false, true)) { val feedback = browserSearchFeedback( - fromUserSearch = true, + feedbackMode = BrowserSearchFeedbackMode.SEARCH_RESULTS, rowCount = 0, allDecksSelected = false, isHeaderCountVisible = headerVisible, diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserSearchFeedbackTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserSearchFeedbackTest.kt index cc6109955e70..3d129724b606 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserSearchFeedbackTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserSearchFeedbackTest.kt @@ -46,7 +46,7 @@ class CardBrowserSearchFeedbackTest : RobolectricTest() { val header = requireActivity().findViewById(R.id.subtitle) assertThat(header?.isShown == true, equalTo(!useSearchView)) - activityViewModel.setQuery("", fromUserSearch = true).join() + activityViewModel.setQuery("", feedbackMode = BrowserSearchFeedbackMode.SEARCH_RESULTS).join() awaitSearch() assertThat(snackbarText, equalTo(if (useSearchView) "1 card shown" else null)) 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 c2cf00525956..8f1e3aa61a80 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/browser/CardBrowserViewModelTest.kt @@ -1657,34 +1657,34 @@ class CardBrowserViewModelTest : JvmTest() { } @Test - fun `initial search is not a user submission - Issue 21242`() = + fun `initial search does not request feedback - Issue 21242`() = runViewModelTest(notes = 2, initMode = InitMode.MANUAL) { flowOfSearchState.test { manualInit() val completed = awaitSearchCompleted() assertThat(completed.rowCount, equalTo(2)) - assertThat(completed.fromUserSearch, equalTo(false)) + assertThat(completed.feedbackMode, equalTo(BrowserSearchFeedbackMode.NONE)) } } @Test - fun `completed search retains the user submission and deck scope`() = + fun `completed search retains the feedback mode and deck scope`() = runViewModelTest(notes = 3) { flowOfSearchState.test { ignoreEventsDuringViewModelInit() setSelectedDeck(SelectableDeck.AllDecks) awaitSearchCompleted() - setQuery("", fromUserSearch = true) + setQuery("", feedbackMode = BrowserSearchFeedbackMode.SEARCH_RESULTS) val completed = awaitSearchCompleted() assertThat("count", completed.rowCount, equalTo(3)) assertThat("cardsOrNotes", completed.cardsOrNotes, equalTo(CardsOrNotes.CARDS)) - assertThat(completed.fromUserSearch, equalTo(true)) + assertThat(completed.feedbackMode, equalTo(BrowserSearchFeedbackMode.SEARCH_RESULTS)) assertThat(completed.allDecksSelected, equalTo(true)) } } @Test - fun `deck change is not a user search submission`() = + fun `deck change does not request search feedback`() = runViewModelTest { val deck = addDeck("Specific") addNoteToDeck(deck) @@ -1693,13 +1693,13 @@ class CardBrowserViewModelTest : JvmTest() { setSelectedDeck(deck) val completed = awaitSearchCompleted() assertThat(completed.rowCount, equalTo(1)) - assertThat(completed.fromUserSearch, equalTo(false)) + assertThat(completed.feedbackMode, equalTo(BrowserSearchFeedbackMode.NONE)) assertThat(completed.allDecksSelected, equalTo(false)) } } @Test - fun `sorting and reversing are not user search submissions`() = + fun `sorting and reversing do not request search feedback`() = runViewModelTest(notes = 2) { flowOfSearchState.test { ignoreEventsDuringViewModelInit() @@ -1713,7 +1713,7 @@ class CardBrowserViewModelTest : JvmTest() { setSortType(sort).join() val completed = awaitSearchCompleted() assertThat("sorting retains the results", completed.rowCount, equalTo(2)) - assertThat("sorting has its own feedback", completed.fromUserSearch, equalTo(false)) + assertThat("sorting has its own feedback", completed.feedbackMode, equalTo(BrowserSearchFeedbackMode.NONE)) } } } From d804402ea21cc6cc37461c43285aa98bf1f21e8c Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 17 Oct 2026 15:47:08 +0100 Subject: [PATCH 079/163] fix(browser): only show count snackbar when necessary Check the subtitle's actual visibility before suppressing count feedback. * Ctrl+Alt+S during multi-select could cause this to fire Part of 22384 Assisted-by: GPT-6-Astra xhigh --- .../ichi2/anki/browser/CardBrowserFragment.kt | 3 +- .../BrowserHeaderVisibilityRegressionTest.kt | 70 +++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/browser/BrowserHeaderVisibilityRegressionTest.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 719660a7d4ac..f5b797fee444 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt @@ -1005,12 +1005,13 @@ class CardBrowserFragment : } fun onSearchCompleted(state: SearchState.Completed) { + val subtitle = legacySubtitle ?: requireActivity().findViewById(R.id.subtitle) val feedback = browserSearchFeedback( feedbackMode = state.feedbackMode, rowCount = state.rowCount, allDecksSelected = state.allDecksSelected, - isHeaderCountVisible = legacySearchView?.isIconified == true, + isHeaderCountVisible = subtitle?.isShown == true && legacySearchView?.isIconified != false, ) val message = when (feedback) { diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/browser/BrowserHeaderVisibilityRegressionTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/browser/BrowserHeaderVisibilityRegressionTest.kt new file mode 100644 index 000000000000..e06d05123850 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/browser/BrowserHeaderVisibilityRegressionTest.kt @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.browser + +import android.view.KeyEvent +import android.view.View +import android.widget.TextView +import androidx.recyclerview.widget.RecyclerView +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.CardBrowser +import com.ichi2.anki.R +import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.browser.search.SavedSearch +import com.ichi2.anki.dialogs.SavedBrowserSearchesDialogFragment +import com.ichi2.anki.model.SelectableDeck +import com.ichi2.testutils.ext.snackbarAction +import com.ichi2.testutils.ext.snackbarText +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +class BrowserHeaderVisibilityRegressionTest : RobolectricTest() { + @Test + fun `saved search opened by shortcut reports its count during multi-select`() { + addBasicNote("cat", "meows") + addBasicNote("dog", "barks") + withCardBrowserFragment { + activityViewModel.setSelectedDeck(SelectableDeck.AllDecks) + awaitSearch() + assertEquals(SaveSearchResult.SUCCESS, activityViewModel.saveSearch(SavedSearch("Cats", "cat"))) + activityViewModel.selectAll()?.join() + advanceRobolectricLooper() + assertTrue(activityViewModel.isInMultiSelectMode) + assertFalse(requireActivity().findViewById(R.id.subtitle).isShown) + + val shortcut = + KeyEvent( + 0, + 0, + KeyEvent.ACTION_UP, + KeyEvent.KEYCODE_S, + 0, + KeyEvent.META_CTRL_ON or KeyEvent.META_ALT_ON, + ) + assertTrue((requireActivity() as CardBrowser).onKeyUp(KeyEvent.KEYCODE_S, shortcut)) + advanceRobolectricLooper() + val dialog = parentFragmentManager.fragments.filterIsInstance().single() + val list = assertNotNull(dialog.requireDialog().findViewById(R.id.dialog_recycler_view)) + assertNotNull(list.findViewHolderForAdapterPosition(0)).itemView.performClick() + awaitSearch() + + assertEquals("cat", activityViewModel.searchTerms) + assertEquals(1, activityViewModel.rowCount) + assertTrue(activityViewModel.isInMultiSelectMode) + assertTrue(assertNotNull(legacySearchView).isIconified) + assertFalse(requireActivity().findViewById(R.id.subtitle).isShown) + assertEquals("1 card shown", snackbarText) + assertEquals(View.GONE, snackbarAction?.visibility) + } + } + + private suspend fun CardBrowserFragment.awaitSearch() { + activityViewModel.searchJob?.join() + advanceRobolectricLooper() + } +} From 6e6e6581e4b704521dd6804d79fc02136efe26fa Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 17 Oct 2026 18:46:48 +0100 Subject: [PATCH 080/163] fix(tests): await filtered deck context menu results Fixes flaky tests, and adds defensive code changes: * Prepare the collection first * Wait for the expected deck IDs * Await card movement after empty/rebuild Fixes 22574 Assisted-by: GPT-6-Astra default --- .../java/com/ichi2/anki/DeckPickerTest.kt | 39 ++++++++++--------- .../com/ichi2/anki/DeckPickerTestFixture.kt | 28 +++++++++++++ 2 files changed, 49 insertions(+), 18 deletions(-) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt index 43a69b7a0055..cb3ea0bd650d 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt @@ -690,26 +690,23 @@ class DeckPickerTest : RobolectricTest() { } @Test - fun `ContextMenu testDynRebuildAndEmpty`() = - deckPicker { - val cardIds = - (0..3) - .map { addBasicNote("$it", "").firstCard().id } - assertTrue(allCardsInSameDeck(cardIds, 1)) - val deckId = addDynamicDeck("Deck 1") - getColUnsafe.sched.rebuildFilteredDeck(deckId) - assertTrue(allCardsInSameDeck(cardIds, deckId)) - updateDeckList() - assertEquals(1, visibleDeckCount) - - selectContextMenuOption(ContextMenuOption.CUSTOM_STUDY_EMPTY, deckId) - - assertTrue(allCardsInSameDeck(cardIds, 1)) - - selectContextMenuOption(ContextMenuOption.CUSTOM_STUDY_REBUILD, deckId) + fun `ContextMenu testDynRebuildAndEmpty`() { + val cardIds = (0..3).map { addBasicNote("$it", "").firstCard().id } + assertTrue(allCardsInSameDeck(cardIds, 1)) + val deckId = addDynamicDeck("Deck 1") + col.sched.rebuildFilteredDeck(deckId) + assertTrue(allCardsInSameDeck(cardIds, deckId)) + + deckPicker(expectedDeckIds = listOf(deckId)) { + selectContextMenuOptionAndAwait(ContextMenuOption.CUSTOM_STUDY_EMPTY, deckId) { + allCardsInSameDeck(cardIds, 1) + } - assertTrue(allCardsInSameDeck(cardIds, deckId)) + selectContextMenuOptionAndAwait(ContextMenuOption.CUSTOM_STUDY_REBUILD, deckId) { + allCardsInSameDeck(cardIds, deckId) + } } + } private suspend fun TestScope.withQueuedCollectionAccess(block: suspend () -> T): T { val previousQueue = CollectionManager.setTestDispatcher(StandardTestDispatcher(testScheduler), useReentrantLock = false) @@ -1964,9 +1961,14 @@ fun RobolectricTest.setIntroductionSlidesShown(shown: Boolean) { } } +/** + * Launches the deck picker, waiting for [expectedDeckIds] in adapter order before running [function] + * when supplied. Prepare collection data before calling this helper. + */ fun RobolectricTest.deckPicker( exposeTestData: Boolean = false, skipIntroduction: Boolean = true, + expectedDeckIds: List? = null, function: suspend DeckPicker.() -> Unit, ) = runTest { setIntroductionSlidesShown(skipIntroduction) @@ -1975,6 +1977,7 @@ fun RobolectricTest.deckPicker( if (exposeTestData) DeckPickerTest.DeckPickerEx::class.java else DeckPicker::class.java, Intent(), ) + expectedDeckIds?.let { deckPicker.awaitVisibleDecks(it) } function(deckPicker) } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTestFixture.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTestFixture.kt index 88f3b2fdc0bf..8459ba78acf8 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTestFixture.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTestFixture.kt @@ -9,6 +9,11 @@ import com.ichi2.anki.RobolectricTest.Companion.advanceRobolectricLooperUntil import com.ichi2.anki.common.preferences.sharedPrefs import com.ichi2.anki.deckpicker.DeckPickerViewModel import com.ichi2.anki.deckpicker.DeckPickerViewModel.StartupResponse +import com.ichi2.anki.dialogs.DeckPickerContextMenu.DeckPickerContextMenuOption +import com.ichi2.anki.dialogs.DeckPickerContextMenuResult +import com.ichi2.anki.dialogs.setDeckPickerContextMenuResult +import com.ichi2.anki.libanki.DeckId +import com.ichi2.anki.widgets.DeckAdapter import com.ichi2.testutils.BackupManagerTestUtilities // TODO: move to testFixtures once RobolectricTest is moved @@ -41,3 +46,26 @@ fun DeckPickerViewModel.awaitStartup() = advanceRobolectricLooperUntil { startupJob?.isActive != true && flowOfStartupResponse.value !is StartupResponse.Success } + +/** Waits for the adapter to display the expected decks, including background list diffs. */ +fun DeckPicker.awaitVisibleDecks(expectedDeckIds: List) { + val adapter = deckPickerBinding.decks.adapter as DeckAdapter + advanceRobolectricLooperUntil( + lazyMessage = { "Expected visible decks $expectedDeckIds, but found ${adapter.currentList.map { it.did }}" }, + ) { + adapter.currentList.map { it.did } == expectedDeckIds + } +} + +/** Selects a context-menu action and waits for its observable result before returning. */ +fun DeckPicker.selectContextMenuOptionAndAwait( + option: DeckPickerContextMenuOption, + deckId: DeckId, + condition: () -> Boolean, +) { + supportFragmentManager.setDeckPickerContextMenuResult(DeckPickerContextMenuResult(deckId = deckId, option = option)) + advanceRobolectricLooperUntil( + lazyMessage = { "Context-menu action $option on deck $deckId did not reach its expected state" }, + condition = condition, + ) +} From 93e92b5fb9669b7fcd2eaa0e6518fa206dbf9937 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Mon, 19 Oct 2026 21:57:57 +0100 Subject: [PATCH 081/163] fix: ensure shutdown of smoke test emulator QEMU kept on running after `emu kill`. If it doesn't work after 20 second, kill it. Fixes 22613 Assisted-by: GPT-6-Astra default --- .github/with_emulator_cleanup.sh | 27 +++++++++++++++++++++++++++ .github/workflows/tests_emulator.yml | 2 +- 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 .github/with_emulator_cleanup.sh diff --git a/.github/with_emulator_cleanup.sh b/.github/with_emulator_cleanup.sh new file mode 100644 index 000000000000..1152597454cb --- /dev/null +++ b/.github/with_emulator_cleanup.sh @@ -0,0 +1,27 @@ +#!/bin/bash +# SPDX-License-Identifier: GPL-3.0-or-later + +set -euo pipefail + +# Only for dedicated Linux CI runners with one emulator and -no-snapshot-save. +# Forced shutdown must not interrupt saving a snapshot for subsequent jobs. +stop_emulator() { + # #22613 `emu kill` can report success while QEMU keeps running and + # holds the emulator action's output pipes open. Bound both ADB and QEMU. + timeout -s KILL 5s "$ANDROID_HOME/platform-tools/adb" -s "emulator-$EMULATOR_PORT" emu kill || true + + local emulator_pattern="^$ANDROID_HOME/emulator/qemu/" + local deadline=$((SECONDS + 20)) + while pgrep -f "$emulator_pattern" > /dev/null; do + if ((SECONDS >= deadline)); then + echo "Emulator did not stop within 20 seconds; killing QEMU." >&2 + pkill -KILL -f "$emulator_pattern" || true + break + fi + sleep 1 + done +} + +# Preserve the exit status +trap stop_emulator EXIT +"$@" diff --git a/.github/workflows/tests_emulator.yml b/.github/workflows/tests_emulator.yml index c6ed46ca5f78..afbc5a28a8b7 100644 --- a/.github/workflows/tests_emulator.yml +++ b/.github/workflows/tests_emulator.yml @@ -316,7 +316,7 @@ jobs: script: | bash .github/disable_art_metrics.sh bash .github/adb_retry.sh shell settings put global hide_error_dialogs 1 - TEST_RELEASE_BUILD=false ./gradlew :baselineprofile:connectedBenchmarkBenchmarkAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.ichi2.anki.baselineprofile.ReleaseSmokeTest -Pandroid.testInstrumentationRunnerArguments.releaseSmokeTest=true --daemon + TEST_RELEASE_BUILD=false bash .github/with_emulator_cleanup.sh ./gradlew :baselineprofile:connectedBenchmarkBenchmarkAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=com.ichi2.anki.baselineprofile.ReleaseSmokeTest -Pandroid.testInstrumentationRunnerArguments.releaseSmokeTest=true --daemon - name: Configure NDK tools PATH for alignment checking run: | From ca47c2a805857b8a89a7434c7c076b263f342d79 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Mon, 19 Oct 2026 22:41:23 +0100 Subject: [PATCH 082/163] fix(test): race condition in dispatcher for leases The following line wasn't synchronized, which could potentially lose an operation, which caused an infinite hang/flaky test ``` if (dispatchBrief == null) { dispatchBrief = { queue.dispatch(context, block) } } ``` to fix: explicitly pause the intended task and ensure the 'pause' is not stolen by unrelated work Fixes 22616 Assisted-by: GPT-6-Astra default --- .../test/java/com/ichi2/anki/CollectionLeaseTest.kt | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionLeaseTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionLeaseTest.kt index 8c56fa4b4368..bdf4f1550c65 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionLeaseTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionLeaseTest.kt @@ -11,6 +11,7 @@ import com.ichi2.anki.CollectionManager.withLeaseForTest import com.ichi2.anki.exception.CollectionLockedException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.async @@ -144,6 +145,7 @@ class CollectionLeaseTest : RobolectricTest() { fun `a lease cannot overtake brief work paused before queue dispatch`() = runTest { val queue = StandardTestDispatcher(testScheduler) + val pausedBrief = CoroutineName("paused brief") var dispatchBrief: (() -> Unit)? = null val pausedQueue = object : CoroutineDispatcher() { @@ -151,8 +153,10 @@ class CollectionLeaseTest : RobolectricTest() { context: CoroutineContext, block: Runnable, ) { - if (dispatchBrief == null) { + if (context[CoroutineName] === pausedBrief) { // Pause after admission but before submitting to the serial queue. + // Only this coroutine may save dispatchBrief: application background + // work can dispatch concurrently and must not overwrite the saved task. dispatchBrief = { queue.dispatch(context, block) } } else { queue.dispatch(context, block) @@ -161,8 +165,11 @@ class CollectionLeaseTest : RobolectricTest() { } withCollectionQueue(pausedQueue) { val events = mutableListOf() + // Application startup can submit unrelated work (for example, the rollover alarm). + // It must not steal the pause intended for the brief call below. + launch(start = CoroutineStart.UNDISPATCHED) { withCol { } } val brief = - async(start = CoroutineStart.UNDISPATCHED) { + async(pausedBrief, start = CoroutineStart.UNDISPATCHED) { tryWithCol { events.add("brief") } } val otherBrief = From 062aaaa6184a54793a88ceb14aa1e4a396305feb Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:59:30 +0100 Subject: [PATCH 083/163] refactor(preferences): add CommonPreferenceKey alias Alias for `R.string` preference keys. To be linted in a later commit. Assisted-by: GPT-6-Astra xhigh --- .../src/main/java/com/ichi2/anki/CommonPreferenceKey.kt | 6 ++++++ .../com/ichi2/anki/preferences/DeveloperOptionsFragment.kt | 3 ++- .../ichi2/anki/preferences/NotificationsSettingsFragment.kt | 3 ++- .../src/main/java/com/ichi2/anki/services/BootService.kt | 4 ++-- .../java/com/ichi2/anki/services/NotificationService.kt | 3 ++- AnkiDroid/src/main/java/com/ichi2/anki/settings/Prefs.kt | 3 ++- AnkiDroid/src/main/java/com/ichi2/widget/WidgetStatus.kt | 4 ++-- .../com/ichi2/anki/analytics/PreferencesAnalyticsTest.kt | 3 ++- .../test/java/com/ichi2/anki/settings/PrefsSingletonTest.kt | 6 +++--- 9 files changed, 23 insertions(+), 12 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/CommonPreferenceKey.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CommonPreferenceKey.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CommonPreferenceKey.kt new file mode 100644 index 000000000000..4eb9250ba99a --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CommonPreferenceKey.kt @@ -0,0 +1,6 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki + +/** Non-translatable preference keys: `getString(CommonPreferenceKey.pref_new_review_reminders)`. */ +typealias CommonPreferenceKey = com.ichi2.anki.R.string diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/DeveloperOptionsFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/DeveloperOptionsFragment.kt index c879fc2a4517..1f2111311d30 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/DeveloperOptionsFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/DeveloperOptionsFragment.kt @@ -10,6 +10,7 @@ import androidx.preference.SwitchPreferenceCompat import com.ichi2.anki.AnkiDroidApp import com.ichi2.anki.BuildConfig import com.ichi2.anki.CollectionManager.withCol +import com.ichi2.anki.CommonPreferenceKey import com.ichi2.anki.CommonString import com.ichi2.anki.R import com.ichi2.anki.analytics.AnkiDroidUsageAnalytics @@ -173,7 +174,7 @@ class DeveloperOptionsFragment : SettingsFragment() { * with a "Review reminders" button, so we need to immediately reload the settings activity * to make this change show up. */ - requirePreference(R.string.pref_new_review_reminders).setOnPreferenceChangeListener { _, _ -> + requirePreference(CommonPreferenceKey.pref_new_review_reminders).setOnPreferenceChangeListener { _, _ -> ActivityCompat.recreate(requireActivity()) true } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/NotificationsSettingsFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/NotificationsSettingsFragment.kt index 0fa56bebdcbe..e52c9eda0b02 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/preferences/NotificationsSettingsFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/preferences/NotificationsSettingsFragment.kt @@ -8,6 +8,7 @@ import android.content.Intent import androidx.core.app.PendingIntentCompat import androidx.preference.ListPreference import androidx.preference.SwitchPreferenceCompat +import com.ichi2.anki.CommonPreferenceKey import com.ichi2.anki.R import com.ichi2.anki.common.android.AdaptionUtil import com.ichi2.anki.common.time.TimeManager @@ -32,7 +33,7 @@ class NotificationsSettingsFragment : SettingsFragment() { } // Minimum cards due // The number of cards that should be due today in a deck to justify adding a notification. - requirePreference(R.string.pref_notifications_minimum_cards_due_key).apply { + requirePreference(CommonPreferenceKey.pref_notifications_minimum_cards_due_key).apply { updateNotificationPreference(this) setOnPreferenceChangeListener { preference, newValue -> updateNotificationPreference(preference as ListPreference) 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 9e31c80617a8..5ba25539a05c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/services/BootService.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/services/BootService.kt @@ -9,9 +9,9 @@ import android.content.Context import android.content.Intent import androidx.core.app.PendingIntentCompat import com.ichi2.anki.CollectionManager +import com.ichi2.anki.CommonPreferenceKey import com.ichi2.anki.CommonString import com.ichi2.anki.IntentHandler.Companion.grantedStoragePermissions -import com.ichi2.anki.R import com.ichi2.anki.common.android.AnkiBroadcastReceiver import com.ichi2.anki.common.annotations.LegacyNotifications import com.ichi2.anki.common.annotations.NeedsTest @@ -149,7 +149,7 @@ class BootService : AnkiBroadcastReceiver() { // Don't schedule a notification if the due reminders setting is not enabled if (sp .getString( - context.getString(R.string.pref_notifications_minimum_cards_due_key), + context.getString(CommonPreferenceKey.pref_notifications_minimum_cards_due_key), PENDING_NOTIFICATIONS_ONLY.toString(), )!! .toInt() >= PENDING_NOTIFICATIONS_ONLY 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 ff5683440a44..86a914c00cec 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/services/NotificationService.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/services/NotificationService.kt @@ -13,6 +13,7 @@ import androidx.core.app.PendingIntentCompat import androidx.core.content.getSystemService import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.CommonPlurals +import com.ichi2.anki.CommonPreferenceKey import com.ichi2.anki.CommonString import com.ichi2.anki.DeckPicker import com.ichi2.anki.IntentHandler @@ -362,7 +363,7 @@ class NotificationService : AnkiBroadcastReceiver() { val minCardsDue = preferences .getString( - context.getString(R.string.pref_notifications_minimum_cards_due_key), + context.getString(CommonPreferenceKey.pref_notifications_minimum_cards_due_key), PENDING_NOTIFICATIONS_ONLY.toString(), )!! .toInt() 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 3b2a89ebf967..a7ab769e78b0 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/settings/Prefs.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/settings/Prefs.kt @@ -10,6 +10,7 @@ import androidx.annotation.VisibleForTesting import androidx.core.content.edit import com.ichi2.anki.AnkiDroidApp import com.ichi2.anki.BuildConfig +import com.ichi2.anki.CommonPreferenceKey import com.ichi2.anki.R import com.ichi2.anki.cardviewer.TapGestureMode import com.ichi2.anki.common.preferences.AnimationPreferences @@ -276,7 +277,7 @@ open class PrefsRepository protected constructor( /** * Whether to enable the new review reminders notification system. */ - var newReviewRemindersEnabled by booleanPref(R.string.pref_new_review_reminders, false) + var newReviewRemindersEnabled by booleanPref(CommonPreferenceKey.pref_new_review_reminders, false) /** * Review reminder IDs are unique, starting at 0 and climbing upwards by one each time a new one is created. diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/WidgetStatus.kt b/AnkiDroid/src/main/java/com/ichi2/widget/WidgetStatus.kt index 539cbb8f4b78..06989b438654 100644 --- a/AnkiDroid/src/main/java/com/ichi2/widget/WidgetStatus.kt +++ b/AnkiDroid/src/main/java/com/ichi2/widget/WidgetStatus.kt @@ -4,7 +4,7 @@ package com.ichi2.widget import android.content.Context import com.ichi2.anki.CollectionManager.withCol -import com.ichi2.anki.R +import com.ichi2.anki.CommonPreferenceKey import com.ichi2.anki.common.preferences.sharedPrefs import com.ichi2.anki.common.utils.android.SdCard import com.ichi2.anki.common.utils.ext.allDecksCounts @@ -43,7 +43,7 @@ object WidgetStatus { } else { val notificationEnabled = preferences - .getString(context.getString(R.string.pref_notifications_minimum_cards_due_key), "1000001")!! + .getString(context.getString(CommonPreferenceKey.pref_notifications_minimum_cards_due_key), "1000001")!! .toInt() < 1000000 if ((smallWidgetEnabled || notificationEnabled) && canExecuteTask) { Timber.d("WidgetStatus.update(): updating") diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/analytics/PreferencesAnalyticsTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/analytics/PreferencesAnalyticsTest.kt index a60648e2bf22..5cfd493f3107 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/analytics/PreferencesAnalyticsTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/analytics/PreferencesAnalyticsTest.kt @@ -3,6 +3,7 @@ package com.ichi2.anki.analytics import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.CommonPreferenceKey import com.ichi2.anki.EmptyApplicationCategory import com.ichi2.anki.R import com.ichi2.anki.RobolectricTest @@ -71,7 +72,7 @@ class PreferencesAnalyticsTest : RobolectricTest() { R.string.thirdparty_apps_key, // thirdpartyapps_link // will be reworked in the future // Notify when - R.string.pref_notifications_minimum_cards_due_key, // minimumCardsDueForNotification + CommonPreferenceKey.pref_notifications_minimum_cards_due_key, // minimumCardsDueForNotification // Vibrate R.string.pref_notifications_vibrate_key, // widgetVibrate // Blink light diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/settings/PrefsSingletonTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/settings/PrefsSingletonTest.kt index 98961c35ecae..476c0175c7fd 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/settings/PrefsSingletonTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/settings/PrefsSingletonTest.kt @@ -5,7 +5,7 @@ package com.ichi2.anki.settings import androidx.test.ext.junit.runners.AndroidJUnit4 import com.github.ivanshafran.sharedpreferencesmock.SPMockBuilder import com.ichi2.anki.AnkiDroidApp -import com.ichi2.anki.R +import com.ichi2.anki.CommonPreferenceKey import com.ichi2.anki.RobolectricTest import com.ichi2.testutils.withBooleanPreference import org.junit.Test @@ -17,11 +17,11 @@ import kotlin.test.assertTrue class PrefsSingletonTest : RobolectricTest() { @Test fun `reads and writes follow the current application preferences`() = - withBooleanPreference(R.string.pref_new_review_reminders, true) { + withBooleanPreference(CommonPreferenceKey.pref_new_review_reminders, true) { val original = Prefs.sharedPrefs val originalOverride = AnkiDroidApp.sharedPreferencesTestingOverride val replacement = SPMockBuilder().createSharedPreferences() - val key = targetContext.getString(R.string.pref_new_review_reminders) + val key = targetContext.getString(CommonPreferenceKey.pref_new_review_reminders) // Replace SharedPreferences to simulate starting another Robolectric test. // Verify that Prefs uses the provider instead of keeping the previous instance. From e922791e36f547070cc4d072d49c1e910a256c77 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:17:49 +0100 Subject: [PATCH 084/163] refactor(preferences): move notification preferences to `common:android` Preferences are accessed by the `NotificationPreferences` interface. Part ot 20737 - `:widgets` extraction Assisted-by: GPT-6-Astra xhigh --- .../com/ichi2/anki/CommonPreferenceKey.kt | 6 --- .../java/com/ichi2/anki/settings/Prefs.kt | 11 ++++- .../java/com/ichi2/widget/WidgetStatus.kt | 13 +++-- AnkiDroid/src/main/res/values/preferences.xml | 2 - .../settings/NotificationPreferencesTest.kt | 47 +++++++++++++++++++ .../ichi2/anki/settings/PrefsSingletonTest.kt | 7 ++- common/android/README.md | 16 +++++++ .../java/com/ichi2/anki/StringResAliases.kt | 7 +++ .../res/values/preferences_notifications.xml | 6 +++ .../preferences/NotificationPreferences.kt | 12 +++++ 10 files changed, 108 insertions(+), 19 deletions(-) delete mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/CommonPreferenceKey.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/settings/NotificationPreferencesTest.kt create mode 100644 common/android/src/main/res/values/preferences_notifications.xml create mode 100644 common/src/main/java/com/ichi2/anki/common/preferences/NotificationPreferences.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CommonPreferenceKey.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CommonPreferenceKey.kt deleted file mode 100644 index 4eb9250ba99a..000000000000 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CommonPreferenceKey.kt +++ /dev/null @@ -1,6 +0,0 @@ -// SPDX-License-Identifier: GPL-3.0-or-later - -package com.ichi2.anki - -/** Non-translatable preference keys: `getString(CommonPreferenceKey.pref_new_review_reminders)`. */ -typealias CommonPreferenceKey = com.ichi2.anki.R.string 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 a7ab769e78b0..cb4f2e2d0f8d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/settings/Prefs.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/settings/Prefs.kt @@ -14,6 +14,7 @@ import com.ichi2.anki.CommonPreferenceKey import com.ichi2.anki.R import com.ichi2.anki.cardviewer.TapGestureMode import com.ichi2.anki.common.preferences.AnimationPreferences +import com.ichi2.anki.common.preferences.NotificationPreferences import com.ichi2.anki.common.preferences.sharedPrefs import com.ichi2.anki.common.utils.isRunningAsUnitTest import com.ichi2.anki.preferences.SharedPreferencesProvider @@ -37,7 +38,8 @@ object Prefs : PrefsRepository(AnkiDroidApp.sharedPreferencesProvider, AnkiDroid open class PrefsRepository protected constructor( private val preferences: SharedPreferencesProvider, private val resources: Resources, -) : AnimationPreferences { +) : AnimationPreferences, + NotificationPreferences { constructor(sharedPrefs: SharedPreferences, resources: Resources) : this(SharedPreferencesProvider { sharedPrefs }, resources) constructor(context: Context) : this(context.sharedPrefs(), context.resources) @@ -277,7 +279,12 @@ open class PrefsRepository protected constructor( /** * Whether to enable the new review reminders notification system. */ - var newReviewRemindersEnabled by booleanPref(CommonPreferenceKey.pref_new_review_reminders, false) + override var newReviewRemindersEnabled by booleanPref(CommonPreferenceKey.pref_new_review_reminders, defaultValue = false) + + private val minimumCardsDueForNotification by stringPref(CommonPreferenceKey.pref_notifications_minimum_cards_due_key, "1000000") + + override val legacyNotificationsEnabled: Boolean + get() = minimumCardsDueForNotification!!.toInt() < 1000000 /** * Review reminder IDs are unique, starting at 0 and climbing upwards by one each time a new one is created. diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/WidgetStatus.kt b/AnkiDroid/src/main/java/com/ichi2/widget/WidgetStatus.kt index 06989b438654..607a9f0a5d25 100644 --- a/AnkiDroid/src/main/java/com/ichi2/widget/WidgetStatus.kt +++ b/AnkiDroid/src/main/java/com/ichi2/widget/WidgetStatus.kt @@ -4,7 +4,7 @@ package com.ichi2.widget import android.content.Context import com.ichi2.anki.CollectionManager.withCol -import com.ichi2.anki.CommonPreferenceKey +import com.ichi2.anki.common.preferences.NotificationPreferences import com.ichi2.anki.common.preferences.sharedPrefs import com.ichi2.anki.common.utils.android.SdCard import com.ichi2.anki.common.utils.ext.allDecksCounts @@ -19,6 +19,8 @@ import timber.log.Timber * The status of the widget. */ object WidgetStatus { + private val notificationPreferences: NotificationPreferences = Prefs + private var smallWidgetEnabled = false private var smallWidgetUpdateJob: Job? = null @@ -33,7 +35,7 @@ object WidgetStatus { smallWidgetEnabled = preferences.getBoolean("widgetSmallEnabled", false) val canExecuteTask = smallWidgetUpdateJob == null || smallWidgetUpdateJob?.isActive == false - if (Prefs.newReviewRemindersEnabled) { + if (notificationPreferences.newReviewRemindersEnabled) { if (smallWidgetEnabled && canExecuteTask) { Timber.d("WidgetStatus.update(): updating") smallWidgetUpdateJob = launchSmallWidgetUpdateJob(context) @@ -41,10 +43,7 @@ object WidgetStatus { Timber.d("WidgetStatus.update(): already running or not enabled") } } else { - val notificationEnabled = - preferences - .getString(context.getString(CommonPreferenceKey.pref_notifications_minimum_cards_due_key), "1000001")!! - .toInt() < 1000000 + val notificationEnabled = notificationPreferences.legacyNotificationsEnabled if ((smallWidgetEnabled || notificationEnabled) && canExecuteTask) { Timber.d("WidgetStatus.update(): updating") smallWidgetUpdateJob = launchSmallWidgetUpdateJob(context) @@ -76,7 +75,7 @@ object WidgetStatus { Timber.i("triggering small widget UI update") AnkiDroidWidgetSmall.UpdateService().doUpdate(context) } - if (!Prefs.newReviewRemindersEnabled) { + if (!notificationPreferences.newReviewRemindersEnabled) { widgetNotificationScheduler.scheduleNotification() } } diff --git a/AnkiDroid/src/main/res/values/preferences.xml b/AnkiDroid/src/main/res/values/preferences.xml index 9d79985089ed..f5a27977dc47 100644 --- a/AnkiDroid/src/main/res/values/preferences.xml +++ b/AnkiDroid/src/main/res/values/preferences.xml @@ -212,7 +212,6 @@ resetLanguages notificationsScreen - minimumCardsDueForNotification widgetVibrate widgetBlink @@ -233,7 +232,6 @@ debug_corrupt_fsrs_params new_congrats_screen newReviewerOptions - newReviewReminders devOptionsEnabledByUser workInProgressDevOptions reviewerPort diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/settings/NotificationPreferencesTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/settings/NotificationPreferencesTest.kt new file mode 100644 index 000000000000..6e56433b2e28 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/settings/NotificationPreferencesTest.kt @@ -0,0 +1,47 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.settings + +import androidx.core.content.edit +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.EmptyApplicationCategory +import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.common.preferences.sharedPrefs +import com.ichi2.testutils.EmptyApplication +import org.junit.Test +import org.junit.experimental.categories.Category +import org.junit.runner.RunWith +import org.robolectric.annotation.Config +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +@Config(application = EmptyApplication::class) +@Category(EmptyApplicationCategory::class) +class NotificationPreferencesTest : RobolectricTest() { + private val preferences = targetContext.sharedPrefs() + private val notifications = PrefsRepository(targetContext) + + @Test + fun `existing reminder preference is read and updated without changing its key`() { + preferences.edit { putBoolean("newReviewReminders", true) } + assertTrue(notifications.newReviewRemindersEnabled) + notifications.newReviewRemindersEnabled = false + assertFalse(preferences.getBoolean("newReviewReminders", true)) + } + + @Test + fun `both notification systems default to disabled`() { + preferences.edit { clear() } + assertFalse(notifications.newReviewRemindersEnabled) + assertFalse(notifications.legacyNotificationsEnabled) + } + + @Test + fun `legacy notification threshold retains its disabled boundary`() { + preferences.edit { putString("minimumCardsDueForNotification", "999999") } + assertTrue(notifications.legacyNotificationsEnabled) + preferences.edit { putString("minimumCardsDueForNotification", "1000000") } + assertFalse(notifications.legacyNotificationsEnabled) + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/settings/PrefsSingletonTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/settings/PrefsSingletonTest.kt index 476c0175c7fd..c630429d1269 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/settings/PrefsSingletonTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/settings/PrefsSingletonTest.kt @@ -7,6 +7,7 @@ import com.github.ivanshafran.sharedpreferencesmock.SPMockBuilder import com.ichi2.anki.AnkiDroidApp import com.ichi2.anki.CommonPreferenceKey import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.common.preferences.NotificationPreferences import com.ichi2.testutils.withBooleanPreference import org.junit.Test import org.junit.runner.RunWith @@ -18,6 +19,8 @@ class PrefsSingletonTest : RobolectricTest() { @Test fun `reads and writes follow the current application preferences`() = withBooleanPreference(CommonPreferenceKey.pref_new_review_reminders, true) { + val notifications: NotificationPreferences = Prefs + assertTrue(notifications.newReviewRemindersEnabled) val original = Prefs.sharedPrefs val originalOverride = AnkiDroidApp.sharedPreferencesTestingOverride val replacement = SPMockBuilder().createSharedPreferences() @@ -27,7 +30,7 @@ class PrefsSingletonTest : RobolectricTest() { // Verify that Prefs uses the provider instead of keeping the previous instance. AnkiDroidApp.sharedPreferencesTestingOverride = replacement try { - assertFalse(Prefs.newReviewRemindersEnabled) + assertFalse(notifications.newReviewRemindersEnabled) Prefs.newReviewRemindersEnabled = true assertTrue(replacement.getBoolean(key, false)) Prefs.newReviewRemindersEnabled = false @@ -37,6 +40,6 @@ class PrefsSingletonTest : RobolectricTest() { AnkiDroidApp.sharedPreferencesTestingOverride = originalOverride } - assertTrue(Prefs.newReviewRemindersEnabled) + assertTrue(notifications.newReviewRemindersEnabled) } } diff --git a/common/android/README.md b/common/android/README.md index 0fe9dcf500bc..e175750ab8ff 100644 --- a/common/android/README.md +++ b/common/android/README.md @@ -44,6 +44,22 @@ getQuantityString(CommonPlurals.widget_cards_due, n, n) Files in package `com.ichi2.anki` need no import. +### Preference keys (`CommonPreferenceKey`) + +Use `CommonPreferenceKey` for non-translatable preference key resources shared by +Kotlin code and preference XML: + +```kotlin +import com.ichi2.anki.CommonPreferenceKey + +getString(CommonPreferenceKey.pref_new_review_reminders) +``` + +The key remains defined once in XML with `translatable="false"` and referenced +as `@string/pref_new_review_reminders` by the preference UI. `CommonPreferenceKey` +and `CommonString` alias the same generated `R.string` class, so the distinction +is a naming convention rather than a compiler-enforced restriction. + ### Other resource types (`CommonR`) Use `CommonR` as an alias when outside this module. Inside this module, `R` should be used as the alias. diff --git a/common/android/src/main/java/com/ichi2/anki/StringResAliases.kt b/common/android/src/main/java/com/ichi2/anki/StringResAliases.kt index 58a47ef8f43b..6081f746ea8f 100644 --- a/common/android/src/main/java/com/ichi2/anki/StringResAliases.kt +++ b/common/android/src/main/java/com/ichi2/anki/StringResAliases.kt @@ -10,5 +10,12 @@ package com.ichi2.anki */ typealias CommonString = com.ichi2.anki.common.android.R.string +/** + * Non-translatable preference keys: `getString(CommonPreferenceKey.pref_new_review_reminders)`. + * + * Shares the generated resource type with [CommonString]; the distinction is a naming convention. + */ +typealias CommonPreferenceKey = com.ichi2.anki.common.android.R.string + /** Plural resources: `getQuantityString(CommonPlurals.widget_cards_due, n, n)`. */ typealias CommonPlurals = com.ichi2.anki.common.android.R.plurals diff --git a/common/android/src/main/res/values/preferences_notifications.xml b/common/android/src/main/res/values/preferences_notifications.xml new file mode 100644 index 000000000000..e45ba36bb9b0 --- /dev/null +++ b/common/android/src/main/res/values/preferences_notifications.xml @@ -0,0 +1,6 @@ + + + + minimumCardsDueForNotification + newReviewReminders + diff --git a/common/src/main/java/com/ichi2/anki/common/preferences/NotificationPreferences.kt b/common/src/main/java/com/ichi2/anki/common/preferences/NotificationPreferences.kt new file mode 100644 index 000000000000..d277705cc65f --- /dev/null +++ b/common/src/main/java/com/ichi2/anki/common/preferences/NotificationPreferences.kt @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.common.preferences + +/** Preferences shared by review reminders and the legacy widget notifications. */ +interface NotificationPreferences { + /** Whether to enable the new review reminders notification system. */ + val newReviewRemindersEnabled: Boolean + + /** Whether the legacy notification threshold allows due-card notifications. */ + val legacyNotificationsEnabled: Boolean +} From a1295da4bbcbef6d25672a5c5410834c189e1d9b Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Mon, 19 Oct 2026 00:19:01 +0100 Subject: [PATCH 085/163] refactor(widgets): move WidgetStatus to `:widgets` Part of 20737 Assisted-by: GPT-6-Astra xhigh --- .../main/java/com/ichi2/anki/AnkiDroidApp.kt | 3 + .../testutils/TestApplicationSingletons.kt | 3 + .../java/com/ichi2/widget/WidgetStatusTest.kt | 122 ++++++++++++++++++ .../java/com/ichi2/widget/WidgetStatus.kt | 17 ++- 4 files changed, 142 insertions(+), 3 deletions(-) create mode 100644 AnkiDroid/src/test/java/com/ichi2/widget/WidgetStatusTest.kt rename {AnkiDroid => widgets}/src/main/java/com/ichi2/widget/WidgetStatus.kt (87%) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt index a244962e9e31..40fcf73244e4 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt @@ -75,8 +75,10 @@ import com.ichi2.utils.ExceptionUtil import com.ichi2.utils.LanguageUtil import com.ichi2.utils.measureTime import com.ichi2.utils.setWebContentsDebuggingEnabled +import com.ichi2.widget.AnkiDroidWidgetSmall import com.ichi2.widget.DayRolloverAlarm import com.ichi2.widget.WidgetNotificationScheduler +import com.ichi2.widget.WidgetStatus import com.ichi2.widget.cardanalysis.CardAnalysisWidget import com.ichi2.widget.deckpicker.DeckPickerWidget import com.ichi2.widget.restoreRecurringAlarms @@ -189,6 +191,7 @@ open class AnkiDroidApp : initializeNavigator() initializeWidgetRepository() WidgetNotificationScheduler.register { scheduleNotification() } + WidgetStatus.register(Prefs) { context -> AnkiDroidWidgetSmall.UpdateService().doUpdate(context) } Animations.setPreferencesProvider { context -> PrefsRepository(context) } CollectionLockedException.messageProvider = { getString(CommonString.database_locked_summary_new, getString(CommonString.col_path)) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/testutils/TestApplicationSingletons.kt b/AnkiDroid/src/test/java/com/ichi2/anki/testutils/TestApplicationSingletons.kt index 68aa42330ffe..6adddaa2e6be 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/testutils/TestApplicationSingletons.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/testutils/TestApplicationSingletons.kt @@ -9,6 +9,8 @@ import com.ichi2.anki.navigation.initializeNavigator import com.ichi2.anki.settings.PrefsRepository import com.ichi2.anki.startup.ensureCollectionPathSet import com.ichi2.testutils.EmptyApplication +import com.ichi2.widget.AnkiDroidWidgetSmall +import com.ichi2.widget.WidgetStatus /** * Registers the global singletons that [com.ichi2.anki.AnkiDroidApp.onCreate] wires up, for tests @@ -18,6 +20,7 @@ context(application: Application) fun registerTestApplicationSingletons() { initializeNavigator() initializeWidgetRepository() + WidgetStatus.register(PrefsRepository(application)) { context -> AnkiDroidWidgetSmall.UpdateService().doUpdate(context) } Animations.setPreferencesProvider { context -> PrefsRepository(context) } ensureCollectionPathSet(application) } diff --git a/AnkiDroid/src/test/java/com/ichi2/widget/WidgetStatusTest.kt b/AnkiDroid/src/test/java/com/ichi2/widget/WidgetStatusTest.kt new file mode 100644 index 000000000000..b79ffca7d426 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/widget/WidgetStatusTest.kt @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.widget + +import android.app.Application +import android.content.Context +import android.os.Environment +import androidx.core.content.edit +import androidx.test.core.app.ApplicationProvider.getApplicationContext +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.CollectionManager +import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.common.preferences.NotificationPreferences +import com.ichi2.anki.common.preferences.sharedPrefs +import com.ichi2.anki.settings.Prefs +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.shadows.ShadowEnvironment +import kotlin.test.assertFalse + +@RunWith(AndroidJUnit4::class) +@OptIn(DelicateCoroutinesApi::class) +class WidgetStatusTest : RobolectricTest() { + private val preferences = + object : NotificationPreferences { + override var newReviewRemindersEnabled = false + override var legacyNotificationsEnabled = false + } + private val repository = mockk(relaxed = true) + private val scheduler = mockk(relaxed = true) + private val updater = mockk<(Context) -> Unit>(relaxed = true) + + @Test + fun `disabled widgets and notifications do not open the collection`() = + withWidgetStatus { + WidgetStatus.updateInBackground(targetContext) + advanceUntilIdle() + + assertFalse(CollectionManager.isOpenUnsafe()) + verify(exactly = 0) { repository.storeSmallWidgetStatus(any()) } + verify(exactly = 0) { scheduler.scheduleNotification() } + verify(exactly = 0) { updater(any()) } + } + + @Test + fun `legacy notifications follow changes to the registered preferences`() = + withWidgetStatus { + addBasicNote("front", "back") + preferences.legacyNotificationsEnabled = true + WidgetStatus.updateInBackground(targetContext) + advanceUntilIdle() + verify(exactly = 1) { repository.storeSmallWidgetStatus(match { it.dueCardsCount == 1 }) } + verify(exactly = 1) { scheduler.scheduleNotification() } + + preferences.legacyNotificationsEnabled = false + WidgetStatus.updateInBackground(targetContext) + advanceUntilIdle() + preferences.legacyNotificationsEnabled = true + preferences.newReviewRemindersEnabled = true + WidgetStatus.updateInBackground(targetContext) + advanceUntilIdle() + verify(exactly = 1) { repository.storeSmallWidgetStatus(any()) } + verify(exactly = 1) { scheduler.scheduleNotification() } + verify(exactly = 0) { updater(any()) } + } + + @Test + fun `enabled widgets invoke the registered updater with either reminder system`() = + withWidgetStatus { + targetContext.sharedPrefs().edit { putBoolean("widgetSmallEnabled", true) } + preferences.newReviewRemindersEnabled = true + WidgetStatus.updateInBackground(targetContext) + advanceUntilIdle() + verify(exactly = 1) { repository.storeSmallWidgetStatus(any()) } + verify(exactly = 1) { updater(targetContext) } + verify(exactly = 0) { scheduler.scheduleNotification() } + + preferences.newReviewRemindersEnabled = false + WidgetStatus.updateInBackground(targetContext) + advanceUntilIdle() + verify(exactly = 2) { repository.storeSmallWidgetStatus(any()) } + verify(exactly = 2) { updater(targetContext) } + verify(exactly = 1) { scheduler.scheduleNotification() } + } + + private fun withWidgetStatus(block: suspend TestScope.() -> Unit) = + runTest { + val originalRepository = widgetRepository + val originalScheduler = widgetNotificationScheduler + val application = getApplicationContext() + val originalStorageState = Environment.getExternalStorageState() + ShadowEnvironment.setExternalStorageState(Environment.MEDIA_MOUNTED) + targetContext.sharedPrefs().edit { putBoolean("widgetSmallEnabled", false) } + mockkObject(GlobalScope) + // Run the real background job on this test's scheduler, and await it before cleanup. + every { GlobalScope.coroutineContext } returns coroutineContext + WidgetRepository.register(repository) + WidgetNotificationScheduler.register(scheduler) + with(application) { WidgetStatus.register(preferences, updater) } + try { + block() + } finally { + advanceUntilIdle() + ShadowEnvironment.setExternalStorageState(originalStorageState) + unmockkObject(GlobalScope) + WidgetRepository.register(originalRepository) + WidgetNotificationScheduler.register(originalScheduler) + with(application) { + WidgetStatus.register(Prefs) { context -> AnkiDroidWidgetSmall.UpdateService().doUpdate(context) } + } + } + } +} diff --git a/AnkiDroid/src/main/java/com/ichi2/widget/WidgetStatus.kt b/widgets/src/main/java/com/ichi2/widget/WidgetStatus.kt similarity index 87% rename from AnkiDroid/src/main/java/com/ichi2/widget/WidgetStatus.kt rename to widgets/src/main/java/com/ichi2/widget/WidgetStatus.kt index 607a9f0a5d25..84cd1398fdea 100644 --- a/AnkiDroid/src/main/java/com/ichi2/widget/WidgetStatus.kt +++ b/widgets/src/main/java/com/ichi2/widget/WidgetStatus.kt @@ -2,13 +2,13 @@ package com.ichi2.widget +import android.app.Application import android.content.Context import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.common.preferences.NotificationPreferences import com.ichi2.anki.common.preferences.sharedPrefs import com.ichi2.anki.common.utils.android.SdCard import com.ichi2.anki.common.utils.ext.allDecksCounts -import com.ichi2.anki.settings.Prefs import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job @@ -19,7 +19,18 @@ import timber.log.Timber * The status of the widget. */ object WidgetStatus { - private val notificationPreferences: NotificationPreferences = Prefs + private lateinit var notificationPreferences: NotificationPreferences + private lateinit var updateWidget: (Context) -> Unit + + /** Registers notification preferences and the small widget UI updater during app startup. */ + context(_: Application) + fun register( + preferences: NotificationPreferences, + updater: (Context) -> Unit, + ) { + notificationPreferences = preferences + updateWidget = updater + } private var smallWidgetEnabled = false private var smallWidgetUpdateJob: Job? = null @@ -73,7 +84,7 @@ object WidgetStatus { widgetRepository.storeSmallWidgetStatus(status) if (smallWidgetEnabled) { Timber.i("triggering small widget UI update") - AnkiDroidWidgetSmall.UpdateService().doUpdate(context) + updateWidget(context) } if (!notificationPreferences.newReviewRemindersEnabled) { widgetNotificationScheduler.scheduleNotification() From 6b88a9d140782590cf634a66b813331d73fcea41 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:30:41 +0100 Subject: [PATCH 086/163] refactor(deck-picker): add `CloseAnimation` class for FAB animations Assisted-by: GPT-6-Astra default --- .../main/java/com/ichi2/anki/DeckPicker.kt | 5 +- .../anki/DeckPickerFloatingActionMenu.kt | 55 ++++++++++--------- 2 files changed, 33 insertions(+), 27 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt index 7510a57c1cce..df7eb9c41f6d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt @@ -79,6 +79,7 @@ import com.google.android.material.snackbar.Snackbar import com.ichi2.anki.BottomNavController.NavigationItem import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CollectionManager.withCol +import com.ichi2.anki.DeckPickerFloatingActionMenu.CloseAnimation import com.ichi2.anki.DeckPickerFloatingActionMenu.FloatingActionBarToggleListener import com.ichi2.anki.InitialActivity.StartupFailure import com.ichi2.anki.InitialActivity.StartupFailure.DBError @@ -434,7 +435,7 @@ open class DeckPicker : private val closeFloatingActionBarBackPressCallback = object : OnBackPressedCallback(enabled = false) { override fun handleOnBackPressed() { - floatingActionMenu.closeFloatingActionMenu(applyRiseAndShrinkAnimation = true) + floatingActionMenu.closeFloatingActionMenu(style = CloseAnimation.SHORT) } } @@ -1248,7 +1249,7 @@ open class DeckPicker : Timber.d("onCreateOptionsMenu()") if (isDrawerOpen) { - floatingActionMenu.closeFloatingActionMenu(applyRiseAndShrinkAnimation = false) + floatingActionMenu.closeFloatingActionMenu(style = CloseAnimation.LONG) } // Fragments own their menus: each fragment registers a MenuProvider against this // activity (see StudyOptionsFragment), and the menu host dispatches creation, diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt index 27e27392dd54..be8f48551156 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt @@ -124,18 +124,11 @@ class DeckPickerFloatingActionMenu( } /** - * This function takes a parameter which decides if we want to apply the rise and shrink animation - * for FAB or not. - * - * Case 1: When the FAB is already opened and we close it by pressing the back button then we need to show - * the rise and shrink animation and get back to the FAB with `+` icon. - * - * Case 2: When the user opens the side navigation drawer (without touching the FAB). In that case we don't - * want to show any type of rise and shrink animation for the FAB so we put the value `false` for the parameter. + * Closes the menu using the selected animation [style]. */ - fun closeFloatingActionMenu(applyRiseAndShrinkAnimation: Boolean) { + fun closeFloatingActionMenu(style: CloseAnimation) { toggleListener.onBeginToggle(isOpening = false) - if (applyRiseAndShrinkAnimation) { + if (style == CloseAnimation.SHORT) { linearLayout.alpha = 1f studyOptionsFrame?.let { it.alpha = 1f } isFABOpen = false @@ -151,10 +144,10 @@ class DeckPickerFloatingActionMenu( addSharedButton.animate().alpha(0f).duration = 50 addDeckButton.animate().alpha(0f).duration = 100 addFilteredDeckButton.animate().alpha(0f).duration = 100 - addSharedButton.animate().translationY(400f).duration = 100 + addSharedButton.animate().translationY(style.otherOffset).duration = 100 addDeckButton .animate() - .translationY(300f) + .translationY(style.deckOffset) .setDuration(50) .setListener( object : Animator.AnimatorListener { @@ -220,10 +213,10 @@ class DeckPickerFloatingActionMenu( addSharedButton.animate().alpha(0f).duration = 70 addDeckButton.animate().alpha(0f).duration = 50 addFilteredDeckButton.animate().alpha(0f).duration = 50 - addSharedButton.animate().translationY(600f).duration = 100 + addSharedButton.animate().translationY(style.otherOffset).duration = 100 addDeckButton .animate() - .translationY(400f) + .translationY(style.deckOffset) .setDuration(50) .setListener( object : Animator.AnimatorListener { @@ -286,7 +279,7 @@ class DeckPickerFloatingActionMenu( fun hideFloatingActionButton() { if (binding.fabMain.isShown) { // close the menu if it's open so the other menu items are hidden as well - closeFloatingActionMenu(false) + closeFloatingActionMenu(style = CloseAnimation.LONG) Timber.i("DeckPicker:: hideFloatingActionButton()") binding.fabMain.visibility = View.GONE } @@ -343,7 +336,7 @@ class DeckPickerFloatingActionMenu( KeyEvent.KEYCODE_ESCAPE -> { if (isFABOpen) { Timber.d("FAB main button: ESC key pressed - closing menu") - closeFloatingActionMenu(applyRiseAndShrinkAnimation = true) + closeFloatingActionMenu(style = CloseAnimation.SHORT) return@setOnKeyListener true } } @@ -353,20 +346,20 @@ class DeckPickerFloatingActionMenu( } // Close the FAB menu when clicking outside the menu container - binding.fabBGLayout.setOnClickListener { closeFloatingActionMenu(applyRiseAndShrinkAnimation = true) } + binding.fabBGLayout.setOnClickListener { closeFloatingActionMenu(style = CloseAnimation.SHORT) } // Close the FAB menu when clicking the background inside the menu container // (e.g., when tapping to the left of narrower buttons such as the "Add" button) binding.fabLinearLayout.setOnClickListener { if (isFABOpen) { - closeFloatingActionMenu(applyRiseAndShrinkAnimation = true) + closeFloatingActionMenu(style = CloseAnimation.SHORT) } } val addDeckListener = View.OnClickListener { if (isFABOpen) { - closeFloatingActionMenu(applyRiseAndShrinkAnimation = false) + closeFloatingActionMenu(style = CloseAnimation.LONG) deckPicker.showCreateDeckDialog() } } @@ -377,7 +370,7 @@ class DeckPickerFloatingActionMenu( val addDeckKeyListener = createActivationKeyListener("Add Deck button: ENTER key pressed") { if (isFABOpen) { - closeFloatingActionMenu(applyRiseAndShrinkAnimation = false) + closeFloatingActionMenu(style = CloseAnimation.LONG) deckPicker.showCreateDeckDialog() } } @@ -385,7 +378,7 @@ class DeckPickerFloatingActionMenu( val addFilteredDeckListener = View.OnClickListener { if (isFABOpen) { - closeFloatingActionMenu(applyRiseAndShrinkAnimation = false) + closeFloatingActionMenu(style = CloseAnimation.LONG) deckPicker.showCreateFilteredDeckDialog() } } @@ -395,7 +388,7 @@ class DeckPickerFloatingActionMenu( val addFilteredDeckKeyListener = createActivationKeyListener("Add Filtered Deck button: ENTER key pressed") { if (isFABOpen) { - closeFloatingActionMenu(applyRiseAndShrinkAnimation = false) + closeFloatingActionMenu(style = CloseAnimation.LONG) deckPicker.showCreateFilteredDeckDialog() } } @@ -403,7 +396,7 @@ class DeckPickerFloatingActionMenu( val addSharedListener = View.OnClickListener { if (isFABOpen) { - closeFloatingActionMenu(applyRiseAndShrinkAnimation = false) + closeFloatingActionMenu(style = CloseAnimation.LONG) Timber.d("configureFloatingActionsMenu::addSharedButton::onClickListener - Adding Shared Deck") deckPicker.openAnkiWebSharedDecks() } @@ -414,7 +407,7 @@ class DeckPickerFloatingActionMenu( val addSharedKeyListener = createActivationKeyListener("Add Shared Deck button: ENTER key pressed") { if (isFABOpen) { - closeFloatingActionMenu(applyRiseAndShrinkAnimation = false) + closeFloatingActionMenu(style = CloseAnimation.LONG) deckPicker.openAnkiWebSharedDecks() } } @@ -443,7 +436,7 @@ class DeckPickerFloatingActionMenu( * @see DeckPicker.addNote */ private fun addNote() { - closeFloatingActionMenu(applyRiseAndShrinkAnimation = false) + closeFloatingActionMenu(style = CloseAnimation.LONG) deckPicker.addNote() } @@ -458,6 +451,18 @@ class DeckPickerFloatingActionMenu( } } + /** Animation styles for closing the FAB menu, with offsets in pixels. */ + enum class CloseAnimation( + val deckOffset: Float, + val otherOffset: Float, + ) { + /** Back or an outside tap moves the menu buttons a shorter distance. */ + SHORT(deckOffset = 300f, otherOffset = 400f), + + /** Menu actions, the navigation drawer, or hiding the FAB move the buttons farther. */ + LONG(deckOffset = 400f, otherOffset = 600f), + } + fun interface FloatingActionBarToggleListener { /** Triggered when the drawer is starting to open/close */ fun onBeginToggle(isOpening: Boolean) From c23a1a7ba07dd61a1d9b71375732f390bef1e6b7 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:56:50 +0100 Subject: [PATCH 087/163] refactor(deck-picker): simplify FAB listeners Assisted-by: GPT-6-Astra default --- .../anki/DeckPickerFloatingActionMenu.kt | 41 ++++--------------- 1 file changed, 9 insertions(+), 32 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt index be8f48551156..413968a819d2 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt @@ -4,6 +4,7 @@ package com.ichi2.anki import android.animation.Animator +import android.animation.AnimatorListenerAdapter import android.content.Context import android.content.res.ColorStateList import android.view.KeyEvent @@ -150,20 +151,14 @@ class DeckPickerFloatingActionMenu( .translationY(style.deckOffset) .setDuration(50) .setListener( - object : Animator.AnimatorListener { - override fun onAnimationStart(animator: Animator) {} - - override fun onAnimationEnd(animator: Animator) { + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { if (!isFABOpen) { addSharedButton.visibility = View.GONE addDeckButton.visibility = View.GONE addFilteredDeckButton.visibility = View.GONE } } - - override fun onAnimationCancel(animator: Animator) {} - - override fun onAnimationRepeat(animator: Animator) {} }, ) addFilteredDeckButton @@ -171,20 +166,14 @@ class DeckPickerFloatingActionMenu( .translationY(400f) .setDuration(100) .setListener( - object : Animator.AnimatorListener { - override fun onAnimationStart(animator: Animator) {} - - override fun onAnimationEnd(animator: Animator) { + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { if (!isFABOpen) { addSharedButton.visibility = View.GONE addDeckButton.visibility = View.GONE addFilteredDeckButton.visibility = View.GONE } } - - override fun onAnimationCancel(animator: Animator) {} - - override fun onAnimationRepeat(animator: Animator) {} }, ) } @@ -219,20 +208,14 @@ class DeckPickerFloatingActionMenu( .translationY(style.deckOffset) .setDuration(50) .setListener( - object : Animator.AnimatorListener { - override fun onAnimationStart(animator: Animator) {} - - override fun onAnimationEnd(animator: Animator) { + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { if (!isFABOpen) { addSharedButton.visibility = View.GONE addDeckButton.visibility = View.GONE addFilteredDeckButton.visibility = View.GONE } } - - override fun onAnimationCancel(animator: Animator) {} - - override fun onAnimationRepeat(animator: Animator) {} }, ) addFilteredDeckButton @@ -240,20 +223,14 @@ class DeckPickerFloatingActionMenu( .translationY(600f) .setDuration(100) .setListener( - object : Animator.AnimatorListener { - override fun onAnimationStart(animator: Animator) {} - - override fun onAnimationEnd(animator: Animator) { + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { if (!isFABOpen) { addSharedButton.visibility = View.GONE addDeckButton.visibility = View.GONE addFilteredDeckButton.visibility = View.GONE } } - - override fun onAnimationCancel(animator: Animator) {} - - override fun onAnimationRepeat(animator: Animator) {} }, ) } From 0a61558271dc0f5534980c561a8b5fddeda92821 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:47:40 +0100 Subject: [PATCH 088/163] refactor(deck-picker): extract FAB button hiding Assisted-by: GPT-6-Astra default --- .../anki/DeckPickerFloatingActionMenu.kt | 31 ++++++++----------- 1 file changed, 13 insertions(+), 18 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt index 413968a819d2..1fb6813b09b8 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt @@ -154,9 +154,7 @@ class DeckPickerFloatingActionMenu( object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { if (!isFABOpen) { - addSharedButton.visibility = View.GONE - addDeckButton.visibility = View.GONE - addFilteredDeckButton.visibility = View.GONE + hideMenuButtons() } } }, @@ -169,9 +167,7 @@ class DeckPickerFloatingActionMenu( object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { if (!isFABOpen) { - addSharedButton.visibility = View.GONE - addDeckButton.visibility = View.GONE - addFilteredDeckButton.visibility = View.GONE + hideMenuButtons() } } }, @@ -179,9 +175,7 @@ class DeckPickerFloatingActionMenu( } } else { // Close without animation - binding.addSharedButton.visibility = View.GONE - binding.addDeckButton.visibility = View.GONE - binding.addFilteredDeckButton.visibility = View.GONE + hideMenuButtons() binding.fabMain.isExtended = false binding.fabMain.setIconResource(addWhiteIcon) @@ -211,9 +205,7 @@ class DeckPickerFloatingActionMenu( object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { if (!isFABOpen) { - addSharedButton.visibility = View.GONE - addDeckButton.visibility = View.GONE - addFilteredDeckButton.visibility = View.GONE + hideMenuButtons() } } }, @@ -226,9 +218,7 @@ class DeckPickerFloatingActionMenu( object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { if (!isFABOpen) { - addSharedButton.visibility = View.GONE - addDeckButton.visibility = View.GONE - addFilteredDeckButton.visibility = View.GONE + hideMenuButtons() } } }, @@ -236,9 +226,7 @@ class DeckPickerFloatingActionMenu( } } else { // Close without animation - binding.addSharedButton.visibility = View.GONE - binding.addDeckButton.visibility = View.GONE - binding.addFilteredDeckButton.visibility = View.GONE + hideMenuButtons() binding.fabMain.isExtended = false binding.fabMain.setIconResource(addWhiteIcon) @@ -246,6 +234,13 @@ class DeckPickerFloatingActionMenu( } } + private fun hideMenuButtons() = + with(binding) { + addSharedButton.visibility = View.GONE + addDeckButton.visibility = View.GONE + addFilteredDeckButton.visibility = View.GONE + } + fun showFloatingActionButton() { if (!binding.fabMain.isShown) { Timber.i("DeckPicker:: showFloatingActionButton()") From 7251e14be30cff5394608141b5908de7797129fd Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 17 Oct 2026 20:48:02 +0100 Subject: [PATCH 089/163] refactor(deck-picker): extract FAB closing animation Assisted-by: GPT-6-Astra default --- .../anki/DeckPickerFloatingActionMenu.kt | 34 ++++++++----------- 1 file changed, 14 insertions(+), 20 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt index 1fb6813b09b8..a9845da17e88 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt @@ -142,14 +142,9 @@ class DeckPickerFloatingActionMenu( binding.fabMain.shrink() with(binding) { - addSharedButton.animate().alpha(0f).duration = 50 - addDeckButton.animate().alpha(0f).duration = 100 - addFilteredDeckButton.animate().alpha(0f).duration = 100 - addSharedButton.animate().translationY(style.otherOffset).duration = 100 + addSharedButton.animateClose(style.otherOffset, 100) addDeckButton - .animate() - .translationY(style.deckOffset) - .setDuration(50) + .animateClose(style.deckOffset, 50) .setListener( object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { @@ -160,9 +155,7 @@ class DeckPickerFloatingActionMenu( }, ) addFilteredDeckButton - .animate() - .translationY(400f) - .setDuration(100) + .animateClose(style.otherOffset, 100) .setListener( object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { @@ -193,14 +186,9 @@ class DeckPickerFloatingActionMenu( binding.fabMain.shrink() with(binding) { - addSharedButton.animate().alpha(0f).duration = 70 - addDeckButton.animate().alpha(0f).duration = 50 - addFilteredDeckButton.animate().alpha(0f).duration = 50 - addSharedButton.animate().translationY(style.otherOffset).duration = 100 + addSharedButton.animateClose(style.otherOffset, 100) addDeckButton - .animate() - .translationY(style.deckOffset) - .setDuration(50) + .animateClose(style.deckOffset, 50) .setListener( object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { @@ -211,9 +199,7 @@ class DeckPickerFloatingActionMenu( }, ) addFilteredDeckButton - .animate() - .translationY(600f) - .setDuration(100) + .animateClose(style.otherOffset, 100) .setListener( object : AnimatorListenerAdapter() { override fun onAnimationEnd(animation: Animator) { @@ -241,6 +227,14 @@ class DeckPickerFloatingActionMenu( addFilteredDeckButton.visibility = View.GONE } + private fun View.animateClose( + offset: Float, + duration: Long, + ) = animate() + .alpha(0f) + .translationY(offset) + .setDuration(duration) + fun showFloatingActionButton() { if (!binding.fabMain.isShown) { Timber.i("DeckPicker:: showFloatingActionButton()") From 3d6db8f3003496f71053e7451bd7df0ff0ee68ba Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:58:32 +0100 Subject: [PATCH 090/163] refactor(deck-picker): share FAB dismissal logic * Replace both branches with one, using the offsets in the enum * Use one listener for both buttons. * Return early when animations are disabled Assisted-by: GPT-6-Astra default --- .../anki/DeckPickerFloatingActionMenu.kt | 108 ++++-------------- 1 file changed, 23 insertions(+), 85 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt index a9845da17e88..133bc9c5307c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt @@ -129,94 +129,32 @@ class DeckPickerFloatingActionMenu( */ fun closeFloatingActionMenu(style: CloseAnimation) { toggleListener.onBeginToggle(isOpening = false) - if (style == CloseAnimation.SHORT) { - linearLayout.alpha = 1f - studyOptionsFrame?.let { it.alpha = 1f } - isFABOpen = false - binding.fabBGLayout.visibility = View.GONE - if (deckPicker.animationEnabled()) { - // Changes the background color of FAB to default - binding.fabMain.backgroundTintList = ColorStateList.valueOf(fabNormalColor) - // Close with animation - binding.fabMain.setIconResource(addWhiteIcon) - binding.fabMain.shrink() - - with(binding) { - addSharedButton.animateClose(style.otherOffset, 100) - addDeckButton - .animateClose(style.deckOffset, 50) - .setListener( - object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - if (!isFABOpen) { - hideMenuButtons() - } - } - }, - ) - addFilteredDeckButton - .animateClose(style.otherOffset, 100) - .setListener( - object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - if (!isFABOpen) { - hideMenuButtons() - } - } - }, - ) - } - } else { - // Close without animation - hideMenuButtons() - binding.fabMain.isExtended = false + linearLayout.alpha = 1f + studyOptionsFrame?.let { it.alpha = 1f } + isFABOpen = false + with(binding) { + fabBGLayout.visibility = View.GONE + fabMain.setIconResource(addWhiteIcon) - binding.fabMain.setIconResource(addWhiteIcon) - } - } else { - linearLayout.alpha = 1f - studyOptionsFrame?.let { it.alpha = 1f } - isFABOpen = false - binding.fabBGLayout.visibility = View.GONE - if (deckPicker.animationEnabled()) { - // Changes the background color of FAB to default - binding.fabMain.backgroundTintList = ColorStateList.valueOf(fabNormalColor) - // Close with animation - binding.fabMain.setIconResource(addWhiteIcon) - binding.fabMain.shrink() - - with(binding) { - addSharedButton.animateClose(style.otherOffset, 100) - addDeckButton - .animateClose(style.deckOffset, 50) - .setListener( - object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - if (!isFABOpen) { - hideMenuButtons() - } - } - }, - ) - addFilteredDeckButton - .animateClose(style.otherOffset, 100) - .setListener( - object : AnimatorListenerAdapter() { - override fun onAnimationEnd(animation: Animator) { - if (!isFABOpen) { - hideMenuButtons() - } - } - }, - ) - } - } else { - // Close without animation + if (!deckPicker.animationEnabled()) { hideMenuButtons() - binding.fabMain.isExtended = false - - binding.fabMain.setIconResource(addWhiteIcon) + fabMain.isExtended = false + return } + + fabMain.backgroundTintList = ColorStateList.valueOf(fabNormalColor) + fabMain.shrink() + val hideButtons = + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + if (!isFABOpen) { + hideMenuButtons() + } + } + } + addSharedButton.animateClose(style.otherOffset, 100) + addDeckButton.animateClose(style.deckOffset, 50).setListener(hideButtons) + addFilteredDeckButton.animateClose(style.otherOffset, 100).setListener(hideButtons) } } From 7feae25322a73e0f0fddc7d3fc49d827226674b6 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:20:16 +0100 Subject: [PATCH 091/163] fix(deck-picker): skip FAB collapse on selection It looked bad: color change, collapsing, etc... and clear stale FAB dismissal listeners `addNote()` now: * Sets `pendingMenuReset` - ensures the code runs once * Sets `!isFABOpen` for saved state * Opens the editor, without changing appearance The reset either occurs: * `onStop` when the deck screen becomes hidden. * `onResume` - workaround for multi-window mode Fixes 22580 Assisted-by: GPT-6-Astra default --- .../anki/DeckPickerFloatingActionMenu.kt | 45 +++++++++-- .../java/com/ichi2/anki/DeckPickerTest.kt | 77 +++++++++++++++++-- 2 files changed, 111 insertions(+), 11 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt index 133bc9c5307c..3020e2013045 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPickerFloatingActionMenu.kt @@ -12,6 +12,8 @@ import android.view.MotionEvent import android.view.View import android.widget.LinearLayout import androidx.annotation.VisibleForTesting +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner import com.google.android.material.color.MaterialColors import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.common.android.animationEnabled @@ -50,12 +52,15 @@ class DeckPickerFloatingActionMenu( var isFABOpen = false private set + private var pendingMenuReset = false + @Suppress("unused") val isFragmented: Boolean get() = studyOptionsFrame != null @VisibleForTesting fun showFloatingActionMenu() { + if (pendingMenuReset) return toggleListener.onBeginToggle(isOpening = true) deckPicker.activeSnackBar?.dismiss() linearLayout.alpha = 0.5f @@ -75,6 +80,9 @@ class DeckPickerFloatingActionMenu( extendMainButton() with(binding) { + // Dismissal listeners must not run when these opening animations finish. + addDeckButton.animate().setListener(null) + addFilteredDeckButton.animate().setListener(null) addSharedButton.animate().translationY(0f).duration = 100 addDeckButton.animate().translationY(0f).duration = 70 addFilteredDeckButton.animate().translationY(0f).duration = 100 @@ -125,24 +133,32 @@ class DeckPickerFloatingActionMenu( } /** - * Closes the menu using the selected animation [style]. + * Dismisses the menu, or resets it off-screen after opening the editor. + * [style] selects the closing animation for Back/outside taps or menu actions. + * [animate] is false for the off-screen reset. */ - fun closeFloatingActionMenu(style: CloseAnimation) { + fun closeFloatingActionMenu( + style: CloseAnimation, + animate: Boolean = deckPicker.animationEnabled(), + ) { toggleListener.onBeginToggle(isOpening = false) linearLayout.alpha = 1f studyOptionsFrame?.let { it.alpha = 1f } isFABOpen = false with(binding) { fabBGLayout.visibility = View.GONE + fabMain.backgroundTintList = ColorStateList.valueOf(fabNormalColor) fabMain.setIconResource(addWhiteIcon) - if (!deckPicker.animationEnabled()) { + if (!animate) { + addSharedButton.animate().cancel() + addDeckButton.animate().cancel() + addFilteredDeckButton.animate().cancel() hideMenuButtons() fabMain.isExtended = false return } - fabMain.backgroundTintList = ColorStateList.valueOf(fabNormalColor) fabMain.shrink() val hideButtons = object : AnimatorListenerAdapter() { @@ -205,6 +221,14 @@ class DeckPickerFloatingActionMenu( } init { + deckPicker.lifecycle.addObserver( + object : DefaultLifecycleObserver { + override fun onStop(owner: LifecycleOwner) = resetMenuAfterAdding() + + // In multi-window mode the deck screen may remain visible behind the editor. + override fun onResume(owner: LifecycleOwner) = resetMenuAfterAdding() + }, + ) binding.fabMain.isExtended = false binding.fabMain.setOnTouchListener( object : DoubleTapListener(context) { @@ -336,14 +360,23 @@ class DeckPickerFloatingActionMenu( } /** - * Closes the FAB menu and opens the [NoteEditorFragment] + * Opens the [NoteEditorFragment], preserving the menu during the outgoing transition. * @see DeckPicker.addNote */ private fun addNote() { - closeFloatingActionMenu(style = CloseAnimation.LONG) + if (pendingMenuReset) return + pendingMenuReset = true + // Save a closed menu state without changing its appearance during the transition. + isFABOpen = false deckPicker.addNote() } + private fun resetMenuAfterAdding() { + if (!pendingMenuReset) return + pendingMenuReset = false + closeFloatingActionMenu(style = CloseAnimation.LONG, animate = false) + } + /** * Sets the label of the 'Create deck' button from the backend translation. */ diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt index cb3ea0bd650d..7b75863d8849 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt @@ -34,6 +34,8 @@ import androidx.core.view.size import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager import androidx.fragment.app.commitNow +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver import androidx.test.core.app.ActivityScenario import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions.click @@ -45,6 +47,7 @@ import anki.scheduler.CardAnswer.Rating import app.cash.turbine.test import com.google.android.material.bottomnavigation.BottomNavigationView import com.ichi2.anki.CollectionManager.TR +import com.ichi2.anki.DeckPickerFloatingActionMenu.CloseAnimation import com.ichi2.anki.browser.CardBrowserFragment import com.ichi2.anki.browser.CardBrowserViewModel.RowSelection import com.ichi2.anki.common.preferences.sharedPrefs @@ -1367,12 +1370,12 @@ class DeckPickerTest : RobolectricTest() { } @Test - fun `adding a note by single tap closes the FAB menu`() = assertAddingNoteByTouchClosesMenu(doubleTap = false) + fun `adding a note by single tap preserves the visible FAB menu`() = assertAddingNoteByTouchPreservesMenu(doubleTap = false) @Test - fun `adding a note by double tap closes the FAB menu`() = assertAddingNoteByTouchClosesMenu(doubleTap = true) + fun `adding a note by double tap preserves the visible FAB menu`() = assertAddingNoteByTouchPreservesMenu(doubleTap = true) - private fun assertAddingNoteByTouchClosesMenu(doubleTap: Boolean) = + private fun assertAddingNoteByTouchPreservesMenu(doubleTap: Boolean) = deckPicker { val fab = floatingActionButtonBinding.fabMain val startTime = SystemClock.uptimeMillis() @@ -1399,11 +1402,75 @@ class DeckPickerTest : RobolectricTest() { tap(100) val intent = AnkiDroidNavigator.toIntent(awaitItem()) assertEquals("com.ichi2.anki.NoteEditorActivity", intent.component!!.className) - assertFalse(floatingActionMenu.isFABOpen, "Adding a note must close the FAB menu") - assertEquals(View.GONE, floatingActionButtonBinding.fabBGLayout.visibility) + assertFalse(floatingActionMenu.isFABOpen, "Adding a note must save a closed menu state") + assertTrue(floatingActionButtonBinding.fabMain.isExtended) + assertEquals(View.VISIBLE, floatingActionButtonBinding.fabBGLayout.visibility) } } + @Test + fun `selecting Add during FAB reopening keeps menu buttons visible`() = + deckPicker { + floatingActionMenu.showFloatingActionMenu() + shadowOf(mainLooper).idleFor(java.time.Duration.ofMillis(200)) + floatingActionMenu.closeFloatingActionMenu(style = CloseAnimation.SHORT) + shadowOf(mainLooper).idleFor(java.time.Duration.ofMillis(200)) + assertEquals(View.GONE, floatingActionButtonBinding.addDeckButton.visibility) + + floatingActionMenu.showFloatingActionMenu() + // Select Add before the opening animations have finished. + floatingActionButtonBinding.fabMain.performClick() + shadowOf(mainLooper).idleFor(java.time.Duration.ofMillis(200)) + + with(floatingActionButtonBinding) { + assertTrue(fabMain.isExtended) + assertEquals(View.VISIBLE, addDeckButton.visibility) + assertEquals(View.VISIBLE, addFilteredDeckButton.visibility) + assertEquals(View.VISIBLE, addSharedButton.visibility) + } + } + + @Test + fun `Add menu resets on resume without stopping in multi-window`() { + ActivityScenario.launch(DeckPicker::class.java).use { scenario -> + val events = mutableListOf() + scenario.onActivity { deckPicker -> + deckPicker.lifecycle.addObserver(LifecycleEventObserver { _, event -> events.add(event) }) + deckPicker.floatingActionMenu.showFloatingActionMenu() + advanceRobolectricLooper() + with(deckPicker.floatingActionButtonBinding) { + val expandedColor = fabMain.backgroundTintList + fabMain.performClick() + assertTrue(fabMain.isExtended) + assertEquals(expandedColor, fabMain.backgroundTintList) + assertEquals(View.VISIBLE, fabBGLayout.visibility) + } + } + + // A visible activity can be paused without being stopped in multi-window mode. + scenario.moveToState(Lifecycle.State.STARTED) + scenario.onActivity { deckPicker -> + assertTrue(deckPicker.floatingActionButtonBinding.fabMain.isExtended) + } + scenario.moveToState(Lifecycle.State.RESUMED) + + scenario.onActivity { deckPicker -> + assertTrue(Lifecycle.Event.ON_PAUSE in events) + assertFalse(Lifecycle.Event.ON_STOP in events) + with(deckPicker.floatingActionButtonBinding) { + assertFalse(fabMain.isExtended) + assertEquals(View.GONE, fabBGLayout.visibility) + assertEquals(View.GONE, addDeckButton.visibility) + assertEquals(View.GONE, addFilteredDeckButton.visibility) + assertEquals(View.GONE, addSharedButton.visibility) + fabMain.performClick() + assertTrue(deckPicker.floatingActionMenu.isFABOpen) + assertEquals(View.VISIBLE, fabBGLayout.visibility) + } + } + } + } + @Test fun `FAB menu opens on ENTER key`() = deckPicker { From 336355eadf9309c77abea3d3efca84d51b1b3d8f Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:01:37 +0100 Subject: [PATCH 092/163] fix(ci): stop merge requirement check cancellation Allow simultaneous runs to occur: * very fast check * can cause a required check to show as canceled, wasting dev time Fixes 22619 Assisted-by: GPT-6-Astra default --- .github/workflows/merge_requirements.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/merge_requirements.yml b/.github/workflows/merge_requirements.yml index 81302dff8745..afec2c2b02fd 100644 --- a/.github/workflows/merge_requirements.yml +++ b/.github/workflows/merge_requirements.yml @@ -9,9 +9,7 @@ on: permissions: {} -concurrency: - group: ${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true +# Don't cancel runs: this is a fast check which shows as a failing check when canceled. jobs: squashMerge: From 8ef397e1d7c4bfd0051520f6bd4b580419e8442e Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:55:17 +0100 Subject: [PATCH 093/163] feat(coroutines): add `launchCatchingViewTask` helper For work belonging to a fragment's current view which should be canceled/skipped if the view is destroyed. Part of 22065 Assisted-by: GPT-6 Assisted-by: GPT-6-Astra xhigh --- .../java/com/ichi2/anki/CoroutineHelpers.kt | 52 +++++++++++++----- .../anki/CoroutineHelpersRobolectricTest.kt | 53 +++++++++++++++++++ 2 files changed, 91 insertions(+), 14 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt index be57b28f4648..998e21fe9817 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt @@ -17,8 +17,10 @@ import androidx.appcompat.app.AlertDialog import androidx.core.net.toUri import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.Lifecycle import androidx.lifecycle.ViewModel import androidx.lifecycle.coroutineScope +import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewModelScope import anki.collection.Progress import com.ichi2.anki.CollectionManager.TR @@ -243,23 +245,25 @@ fun Fragment.launchCatchingTask( block: suspend CoroutineScope.() -> Unit, ): Job = lifecycle.coroutineScope.launch { - // It's a bug to call this if the activity is invalid or finishing. - val validActivity = - kotlin - .runCatching { - requireActivity(simpleName = true).also { activity -> - check(!activity.isFinishing) { - "Cannot launch task for ${this@launchCatchingTask.javaClass.simpleName}: activity is finishing" - } - } - }.getOrElse { - Timber.w(it, "Fragment.launchCatchingTask") - if (isRobolectric) throw it - return@launch - } + val validActivity = validActivityForTask() ?: return@launch validActivity.runCatching(errorMessage, skipCrashReport = skipCrashReport) { block() } } +private fun Fragment.validActivityForTask(): FragmentActivity? = + // It's a bug to call this if the activity is invalid or finishing. + kotlin + .runCatching { + requireActivity(simpleName = true).also { activity -> + check(!activity.isFinishing) { + "Cannot launch task for ${this@validActivityForTask.javaClass.simpleName}: activity is finishing" + } + } + }.getOrElse { + Timber.w(it, "Fragment.launchCatchingTask") + if (isRobolectric) throw it + null + } + /** * [Fragment.requireActivity], with a short stable name so crash reports can be grouped together. * @@ -275,6 +279,26 @@ fun Fragment.requireActivity(simpleName: Boolean): FragmentActivity = requireActivity() } +/** + * Like [Fragment.launchCatchingTask], but tied to the current view's lifecycle. + * Skips [block] if the view is destroyed before the job starts. + * + * @return the launched job, or `null` if the fragment has no view. + */ +fun Fragment.launchCatchingViewTask( + errorMessage: String? = null, + skipCrashReport: ((Exception) -> Boolean)? = null, + block: suspend CoroutineScope.() -> Unit, +): Job? { + val owner = viewLifecycleOwnerLiveData.value ?: return null + return owner.lifecycleScope.launch { + // A synchronous replacement can destroy the view before lifecycleScope registers cancellation. + if (owner.lifecycle.currentState == Lifecycle.State.DESTROYED) return@launch + val validActivity = validActivityForTask() ?: return@launch + validActivity.runCatching(errorMessage, skipCrashReport = skipCrashReport) { block() } + } +} + /** * Displays an error dialog with title 'Error' and provided [message]. * May report the error when the dialog is dismissed diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CoroutineHelpersRobolectricTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CoroutineHelpersRobolectricTest.kt index a62291e9bde4..482e8b0f511a 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CoroutineHelpersRobolectricTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CoroutineHelpersRobolectricTest.kt @@ -17,6 +17,7 @@ import com.ichi2.anki.exception.CollectionLockedException import com.ichi2.anki.exception.StorageNotConfiguredException import com.ichi2.anki.preferences.PreferencesActivity import com.ichi2.testutils.BackendEmulatingOpenConflict +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest import org.hamcrest.CoreMatchers.containsString @@ -38,6 +39,8 @@ import kotlin.test.assertEquals import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertSame import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) @@ -137,6 +140,56 @@ class CoroutineHelpersRobolectricTest : RobolectricTest() { verify(crashReporter, never()).sendExceptionReport(any(), anyOrNull(), anyOrNull(), any()) } + @Test + fun `view task is cancelled when the view is destroyed but the fragment remains attached`() { + val controller = Robolectric.buildActivity(FragmentActivity::class.java).setup() + saveControllerForCleanup(controller) + val activity = controller.get() + val fragment = Fragment(android.R.layout.simple_list_item_1) + activity.supportFragmentManager.commitNow { add(android.R.id.content, fragment) } + + var cancelled = false + val job = + assertNotNull( + fragment.launchCatchingViewTask { + try { + awaitCancellation() + } finally { + cancelled = true + } + }, + ) + assertTrue(job.isActive) + + activity.supportFragmentManager.commitNow { detach(fragment) } + advanceRobolectricLooper() + + assertNull(fragment.view) + assertSame(activity, fragment.activity) + assertTrue(job.isCancelled) + assertTrue(cancelled) + } + + @Test + fun `finishing activity fails view task under Robolectric`() = + assertLifecycleFailure("activity is finishing") { + val controller = Robolectric.buildActivity(FragmentActivity::class.java).setup() + saveControllerForCleanup(controller) + val activity = controller.get() + val fragment = Fragment(android.R.layout.simple_list_item_1) + activity.supportFragmentManager.commitNow { add(android.R.id.content, fragment) } + var ran = false + + activity.lifecycleScope.launch { + fragment.launchCatchingViewTask { ran = true } + activity.finish() + } + advanceRobolectricLooper() + + assertTrue(activity.isFinishing) + assertFalse(ran) + } + /** * A [StorageNotConfiguredException] escaping a coroutine means an activity raced or * outlived its [ensureStorageIsReady][com.ichi2.anki.startup.ensureStorageIsReady] check: From 8eb5bd1c298feb357f023b155a2a53d3e6c4cadd Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:55:47 +0100 Subject: [PATCH 094/163] fix(study-options): make `refreshInterface` view-lifecycle aware A fragment replacement can detach the options before the refresh starts. Part of 22065 Assisted-by: GPT-6 Assisted-by: GPT-6-Astra xhigh --- .../com/ichi2/anki/StudyOptionsFragment.kt | 2 +- .../ichi2/anki/StudyOptionsFragmentTest.kt | 27 +++++++++++++++++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt index 73023aa76ccc..3361d594d594 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt @@ -356,7 +356,7 @@ class StudyOptionsFragment : fun refreshInterface() { Timber.d("Refreshing StudyOptionsFragment") - launchCatchingTask { viewModel.refreshData() } + launchCatchingViewTask { viewModel.refreshData() } } private fun rebuildUi(state: StudyOptionsState) { diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/StudyOptionsFragmentTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/StudyOptionsFragmentTest.kt index 30a3e8b20f77..632ad4197a02 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/StudyOptionsFragmentTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/StudyOptionsFragmentTest.kt @@ -8,6 +8,7 @@ import android.widget.ScrollView import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.appcompat.view.menu.MenuBuilder +import androidx.fragment.app.FragmentActivity import androidx.fragment.app.commitNow import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope @@ -85,8 +86,8 @@ class StudyOptionsFragmentTest : RobolectricTest() { /** Issue 22348: replacing the tablet panel can detach it before its initial refresh starts. */ @Test @Config(qualifiers = "xlarge") - fun `replacing study options before its refresh starts exposes lifecycle misuse`() = - assertLifecycleFailure("not attached to an activity") { + fun `replacing study options before its refresh starts skips the stale refresh`() = + runTest { setIntroductionSlidesShown(true) addBasicNote() val controller = Robolectric.buildActivity(DeckPicker::class.java).setup() @@ -152,6 +153,28 @@ class StudyOptionsFragmentTest : RobolectricTest() { assertSame(initialState, fragment.viewModel.state) } + /** Issue 22065: the host can replace the fragment before its queued refresh starts. */ + @Test + fun `refresh queued by view creation is skipped after synchronous replacement`() { + withNullCollection { + val controller = Robolectric.buildActivity(FragmentActivity::class.java).setup() + saveControllerForCleanup(controller) + val activity = controller.get() + val first = StudyOptionsFragment() + val second = StudyOptionsFragment() + + activity.lifecycleScope.launch { + activity.supportFragmentManager.commitNow { replace(android.R.id.content, first) } + activity.supportFragmentManager.commitNow { replace(android.R.id.content, second) } + } + advanceRobolectricLooper() + + assertNull(first.activity) + assertSame(second, activity.supportFragmentManager.findFragmentById(android.R.id.content)) + assertTrue(second.isAdded) + } + } + /** Issue 21981: a refresh must offer recovery when the deck hierarchy is corrupt. */ @Test fun `corrupt deck hierarchy offers Check database instead of crashing`() = From 31a3fe468e295848e852ef3e01e1b910de6ba02c Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 1 Oct 2026 19:56:21 +0100 Subject: [PATCH 095/163] fix(study-options): cancel 'rebuildUi' on view destruction Part of 22065 Assisted-by: GPT-6 Assisted-by: GPT-6-Astra xhigh --- .../java/com/ichi2/anki/StudyOptionsFragment.kt | 4 ++-- .../src/main/java/com/ichi2/anki/utils/ext/Flow.kt | 14 ++++++++++++++ .../com/ichi2/anki/FragmentLifecycleFixture.kt | 2 +- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt index 3361d594d594..172540ae087f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt @@ -51,7 +51,7 @@ import com.ichi2.anki.observability.ChangeManager import com.ichi2.anki.settings.Prefs import com.ichi2.anki.ui.internationalization.sentenceCase import com.ichi2.anki.utils.doOnApplyWindowInsets -import com.ichi2.anki.utils.ext.launchCollectionInLifecycleScope +import com.ichi2.anki.utils.ext.launchCollectionInViewLifecycleScope import com.ichi2.anki.utils.ext.setFragmentResultListener import com.ichi2.anki.utils.ext.showDialogFragment import com.ichi2.ui.CollectionMediaImageGetter @@ -114,7 +114,6 @@ class StudyOptionsFragment : displayedDeckId = savedInstanceState?.getLong(STATE_DISPLAYED_DECK_ID)?.takeIf { it != 0L } // Subscribe once per fragment: returning from another panel can recreate its view. ChangeManager.subscribe(this) - viewModel.flowOfState.launchCollectionInLifecycleScope(::rebuildUi) } override fun onCreateView( @@ -140,6 +139,7 @@ class StudyOptionsFragment : requireAnkiActivity().setToolbarText(title = "") } setupContentInsets(view) + viewModel.flowOfState.launchCollectionInViewLifecycleScope(::rebuildUi) refreshInterface() } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Flow.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Flow.kt index 5dcd982c60a8..33b471acaf44 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Flow.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ext/Flow.kt @@ -49,6 +49,20 @@ fun Flow.launchCollectionInLifecycleScope(block: suspend (T) -> Unit) { } } +/** + * Collects while the fragment's current view is started. Call from [Fragment.onViewCreated]. + */ +context(fragment: Fragment) +fun Flow.launchCollectionInViewLifecycleScope(block: suspend (T) -> Unit): Job { + // Capture the owner before launching: the view may be destroyed or recreated before the job starts. + val owner = fragment.viewLifecycleOwner + return owner.lifecycleScope.launch { + owner.repeatOnLifecycle(Lifecycle.State.STARTED) { + this@launchCollectionInViewLifecycleScope.collect(block) + } + } +} + // Workaround a bug in overload resolution. Replace with the following when the compiler is fixed: // state: Lifecycle.State = Lifecycle.State.STARTED context(activity: AnkiActivity) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/FragmentLifecycleFixture.kt b/AnkiDroid/src/test/java/com/ichi2/anki/FragmentLifecycleFixture.kt index 6935ec49a536..813a24dc34ad 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/FragmentLifecycleFixture.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/FragmentLifecycleFixture.kt @@ -44,7 +44,7 @@ class FragmentLifecycleFixture( fun fragmentLifecycleFixtures(): List = listOf( - FragmentLifecycleFixture("StudyOptions", knownFailures = setOf(REMOVE)) { StudyOptionsFragment() }, + FragmentLifecycleFixture("StudyOptions") { StudyOptionsFragment() }, FragmentLifecycleFixture("AppearanceSettings", knownFailures = setOf(REMOVE)) { AppearanceSettingsFragment() }, FragmentLifecycleFixture("GeneralSettings", knownFailures = setOf(REMOVE)) { GeneralSettingsFragment() }, FragmentLifecycleFixture("ReviewerOptions", knownFailures = setOf(REMOVE)) { ReviewerOptionsFragment() }, From 05722f8c277700e0de5a419462950f2297b30018 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:51:31 +0100 Subject: [PATCH 096/163] fix(deck-picker): stop display refreshes reopening the collection A deck list refresh could outlive the screen and cause a collection to be reopened which is held by another backend. Fix this by not requesting that the collection is reopened. Part of 20986 Assisted-by: GPT-6-Astra xhigh --- .../anki/deckpicker/DeckPickerViewModel.kt | 7 +- .../DeckPickerCollectionLockTest.kt | 79 +++++++++++++++++++ 2 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/DeckPickerCollectionLockTest.kt 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 7275d8faf50f..97a877ec296e 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt @@ -118,7 +118,10 @@ class DeckPickerViewModel : if (tree == null) return@combine FlattenedDeckList.empty // TODO: use flowOfFocusedDeck once it's set on all instances - val currentDeckId = withCol { decks.current().getLong("id") } + // This flow can outlive the visible screen. Do not reopen the collection just to redraw it. + val currentDeckId = + withOpenColOrNull { decks.current().getLong("id") } + ?: return@combine FlattenedDeckList.empty Timber.i("currentDeckId: %d", currentDeckId) FlattenedDeckList( @@ -490,6 +493,8 @@ class DeckPickerViewModel : focusedDeck = withCol { decks.current().id } refreshUndoMenuState() + // Reopening can leave the counts and selection unchanged after a skipped display refresh. + flowOfRefreshDeckList.emit(Unit) flowOfDecksReloaded.emit(Unit) } this.loadDeckCounts = loadDeckCounts diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/DeckPickerCollectionLockTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/DeckPickerCollectionLockTest.kt new file mode 100644 index 000000000000..8cbb3ba400b4 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/DeckPickerCollectionLockTest.kt @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.deckpicker + +import androidx.lifecycle.ViewModelStore +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.CollectionManager +import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.deckpicker.DeckPickerViewModel.FlattenedDeckList +import com.ichi2.anki.exception.CollectionLockedException +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runCurrent +import net.ankiweb.rsdroid.Backend +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse + +@RunWith(AndroidJUnit4::class) +class DeckPickerCollectionLockTest : RobolectricTest() { + override fun getCollectionStorageMode() = CollectionStorageMode.ON_DISK + + private val viewModels = ViewModelStore() + private val viewModel by lazy { + DeckPickerViewModel().also { viewModels.put("deckPicker", it) } + } + + @After + fun clearViewModels() { + viewModels.clear() + } + + @Test + fun `filtering a stale deck list does not reopen the collection`() = + runTest { + val deckId = addDeck("Kept") + viewModel.reloadDeckCounts().join() + viewModel.flowOfDeckList.first { list -> list.data.any { it.did == deckId } } + + CollectionManager.ensureClosed() + viewModel.updateDeckFilter("Kept") + runCurrent() + + assertFalse(CollectionManager.isOpenUnsafe()) + assertEquals(FlattenedDeckList.empty, viewModel.flowOfDeckList.value) + } + + /** Issue 20986: a display refresh must not reopen a collection held by another app. */ + @Test + fun `deck list survives an external collection lock and can refresh afterwards`() = + runTest { + val kept = addDeck("Kept") + addDeck("Other") + viewModel.reloadDeckCounts().join() + viewModel.flowOfDeckList.first { list -> list.data.any { it.did == kept } } + val collectionPath = col.colDb.path + + CollectionManager.ensureClosed() + Backend().use { conflictingBackend -> + conflictingBackend.openCollection(collectionPath) + assertFailsWith { CollectionManager.ensureOpen() } + + viewModel.updateDeckFilter("Kept") + runCurrent() + + assertFalse(CollectionManager.isOpenUnsafe()) + assertEquals(FlattenedDeckList.empty, viewModel.flowOfDeckList.value) + } + + CollectionManager.ensureOpen() + viewModel.reloadDeckCounts().join() + runCurrent() + + val refreshed = viewModel.flowOfDeckList.value + assertEquals(listOf(kept), refreshed.data.map { it.did }) + } +} From 1c27acc64854d37d4fa57a53ab551731394f15a6 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 20 Sep 2026 23:30:18 +0100 Subject: [PATCH 097/163] refactor(config): move `SavedSearch` to `:anki-common` Prep for a `Config` delegation refactor Assisted-by: GPT-6-Astra xhigh --- .../anki/browser/search/SavedSearches.kt | 18 ------------------ .../ichi2/anki/browser/search/SavedSearch.kt | 19 +++++++++++++++++++ 2 files changed, 19 insertions(+), 18 deletions(-) create mode 100644 anki-common/src/main/kotlin/com/ichi2/anki/browser/search/SavedSearch.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/SavedSearches.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/SavedSearches.kt index bad2c71e6e1d..b790c19e6240 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/SavedSearches.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/SavedSearches.kt @@ -6,22 +6,6 @@ import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.libanki.Config import timber.log.Timber -/** - * A named query for the Card Browser - * - * Selecting a saved search quickly allows a user to either: - * - search the given query - * - add additional terms to the query before searching - * - * @see SavedSearches - */ -data class SavedSearch( - val name: String, - val query: String, -) { - fun normalize() = SavedSearch(name = this.name, query = this.query.trim()) -} - /** * Manages saved searches (named search queries in the Card Browser) * @@ -103,5 +87,3 @@ var Config.savedFilters: List set(value) { set("savedFilters", value.toMap()) } - -fun List.toMap(): Map = associate { it.name to it.query } diff --git a/anki-common/src/main/kotlin/com/ichi2/anki/browser/search/SavedSearch.kt b/anki-common/src/main/kotlin/com/ichi2/anki/browser/search/SavedSearch.kt new file mode 100644 index 000000000000..a7f6a740d7b9 --- /dev/null +++ b/anki-common/src/main/kotlin/com/ichi2/anki/browser/search/SavedSearch.kt @@ -0,0 +1,19 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.browser.search + +/** + * A named query for the Card Browser + * + * Selecting a saved search quickly allows a user to either: + * - search the given query + * - add additional terms to the query before searching + */ +data class SavedSearch( + val name: String, + val query: String, +) { + fun normalize() = SavedSearch(name = this.name, query = this.query.trim()) +} + +fun List.toMap(): Map = associate { it.name to it.query } From f4d78c9c84838e33dc65741f8e1bf71cb7d134b0 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 20 Sep 2026 23:30:18 +0100 Subject: [PATCH 098/163] refactor(config): delegate Config.savedFilters Assisted-by: GPT-6-Astra xhigh --- .../ichi2/anki/browser/CardBrowserFragment.kt | 2 +- .../ichi2/anki/browser/search/SavedSearches.kt | 17 +---------------- .../ichi2/anki/browser/search/SavedSearch.kt | 2 ++ .../com/ichi2/anki/common/utils/ext/Config.kt | 13 +++++++++++++ .../ichi2/anki/common/utils/ext/ConfigTest.kt | 14 ++++++++++++++ 5 files changed, 31 insertions(+), 17 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt index f5b797fee444..a73890177f63 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserFragment.kt @@ -100,12 +100,12 @@ import com.ichi2.anki.browser.search.StandardSearchFragment import com.ichi2.anki.browser.search.formatChipDescription import com.ichi2.anki.browser.search.getIcon import com.ichi2.anki.browser.search.iconRes -import com.ichi2.anki.browser.search.savedFilters import com.ichi2.anki.common.ALL_DECKS_ID import com.ichi2.anki.common.annotations.NeedsTest import com.ichi2.anki.common.destinations.NoteEditorDestination import com.ichi2.anki.common.destinations.navigate import com.ichi2.anki.common.utils.ext.ifNotZero +import com.ichi2.anki.common.utils.ext.savedFilters import com.ichi2.anki.dialogs.BrowserOptionsDialog import com.ichi2.anki.dialogs.ChangeNoteTypeDialog import com.ichi2.anki.dialogs.DeckSelectionDialog diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/SavedSearches.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/SavedSearches.kt index b790c19e6240..c6820608468e 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/SavedSearches.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/search/SavedSearches.kt @@ -3,7 +3,7 @@ package com.ichi2.anki.browser.search import com.ichi2.anki.CollectionManager.withCol -import com.ichi2.anki.libanki.Config +import com.ichi2.anki.common.utils.ext.savedFilters import timber.log.Timber /** @@ -72,18 +72,3 @@ object SavedSearches { /** Removes all saved searches from the Anki collection */ suspend fun clear() = saveToConfig(emptyList()) } - -/** - * The list of saved searches in the Anki Collection - * - * Ordering is NOT preserved in Anki Desktop. Searches are ordered based on the name and are - * case-sensitive - */ -var Config.savedFilters: List - get() = - get>("savedFilters") - .orEmpty() - .map { (key, value) -> SavedSearch(name = key, query = value) } - set(value) { - set("savedFilters", value.toMap()) - } diff --git a/anki-common/src/main/kotlin/com/ichi2/anki/browser/search/SavedSearch.kt b/anki-common/src/main/kotlin/com/ichi2/anki/browser/search/SavedSearch.kt index a7f6a740d7b9..3f08865c4a91 100644 --- a/anki-common/src/main/kotlin/com/ichi2/anki/browser/search/SavedSearch.kt +++ b/anki-common/src/main/kotlin/com/ichi2/anki/browser/search/SavedSearch.kt @@ -8,6 +8,8 @@ package com.ichi2.anki.browser.search * Selecting a saved search quickly allows a user to either: * - search the given query * - add additional terms to the query before searching + * + * @see com.ichi2.anki.common.utils.ext.savedFilters */ data class SavedSearch( val name: String, diff --git a/anki-common/src/main/kotlin/com/ichi2/anki/common/utils/ext/Config.kt b/anki-common/src/main/kotlin/com/ichi2/anki/common/utils/ext/Config.kt index 032979053854..959920681f1c 100644 --- a/anki-common/src/main/kotlin/com/ichi2/anki/common/utils/ext/Config.kt +++ b/anki-common/src/main/kotlin/com/ichi2/anki/common/utils/ext/Config.kt @@ -3,6 +3,8 @@ package com.ichi2.anki.common.utils.ext import anki.config.ConfigKey +import com.ichi2.anki.browser.search.SavedSearch +import com.ichi2.anki.browser.search.toMap import com.ichi2.anki.common.utils.ConfigProperty import com.ichi2.anki.common.utils.configProperty import com.ichi2.anki.common.utils.ext.AddingDefaultsMode.DECIDE_BY_NOTE_TYPE @@ -91,3 +93,14 @@ var Config.flagLabels by ConfigProperty( read = { getObject("flagLabels", JSONObject()) }, write = { set("flagLabels", it) }, ) + +/** + * Saved searches in the collection. Names are case-sensitive. + * Anki Desktop orders them by name, regardless of the order written here. + * + * @see SavedSearch + */ +var Config.savedFilters by jsonConfigProperty>("savedFilters").orDefault(emptyMap()).mapped( + decode = { values -> values.map { (name, query) -> SavedSearch(name, query) } }, + encode = { values -> values.toMap() }, +) diff --git a/anki-common/src/test/kotlin/com/ichi2/anki/common/utils/ext/ConfigTest.kt b/anki-common/src/test/kotlin/com/ichi2/anki/common/utils/ext/ConfigTest.kt index c8dd8ac86873..65944d6d3747 100644 --- a/anki-common/src/test/kotlin/com/ichi2/anki/common/utils/ext/ConfigTest.kt +++ b/anki-common/src/test/kotlin/com/ichi2/anki/common/utils/ext/ConfigTest.kt @@ -2,6 +2,7 @@ package com.ichi2.anki.common.utils.ext +import com.ichi2.anki.browser.search.SavedSearch import com.ichi2.anki.libanki.testutils.InMemoryAnkiTest import com.ichi2.anki.model.CardsOrNotes import org.json.JSONObject @@ -88,6 +89,19 @@ class ConfigTest : InMemoryAnkiTest() { assertEquals(0, col.config.flagLabels.length()) } + @Test + fun `saved searches preserve the backend map format and recover from invalid values`() { + col.config.savedFilters = listOf(SavedSearch("A", "deck:Default"), SavedSearch("a", "is:new")) + assertEquals(mapOf("A" to "deck:Default", "a" to "is:new"), col.config.get>("savedFilters")) + + col.config.set("savedFilters", mapOf("external" to "is:due")) + assertEquals(listOf(SavedSearch("external", "is:due")), col.config.savedFilters) + col.config.set("savedFilters", listOf("invalid")) + assertEquals(emptyList(), col.config.savedFilters) + col.config.remove("savedFilters") + assertEquals(emptyList(), col.config.savedFilters) + } + @Test fun `browser mode helpers use the mapped config property`() { CardsOrNotes.NOTES.saveToCollection(col) From 37100e1aaca72f6d86145af76ddf1e4b1542d066 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 20 Oct 2026 00:34:13 +0100 Subject: [PATCH 099/163] refactor(deck-picker): use selected deck in keyboard shortcuts Part of 22249 Assisted-by: GPT-6 Assisted-by: GPT-6-Astra default Assisted-by: GPT-6-Astra xhigh --- .../src/main/java/com/ichi2/anki/DeckPicker.kt | 13 ++++++++----- .../ichi2/anki/deckpicker/DeckPickerViewModel.kt | 16 ++++++---------- .../anki/deckpicker/DeckPickerViewModelTest.kt | 16 +++------------- 3 files changed, 17 insertions(+), 28 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt index df7eb9c41f6d..b2ad11248452 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt @@ -1526,9 +1526,7 @@ open class DeckPicker : return true } R.id.action_deck_delete -> { - launchCatchingTask { - viewModel.deleteSelectedDeck().join() - } + deleteSelectedDeck() return true } R.id.action_export_collection -> { @@ -1805,7 +1803,7 @@ open class DeckPicker : if (event.isShiftPressed) { // Shortcut: Shift + DEL - Delete deck without confirmation dialog Timber.i("Shift+DEL: Deck deck without confirmation") - viewModel.focusedDeck?.let { did -> deleteDeck(did) } + deleteSelectedDeck() } else { // Shortcut: DEL Timber.i("Delete Deck from keypress") @@ -1820,7 +1818,7 @@ open class DeckPicker : // that is, when it appears in the trailing study option fragment if (fragmented) { Timber.i("Rename Deck from keypress") - viewModel.focusedDeck?.let { did -> renameDeckDialog(did) } + viewModel.requestRenameSelectedDeck() return true } } @@ -2387,6 +2385,11 @@ open class DeckPicker : viewModel.deleteDeck(did).join() } + private fun deleteSelectedDeck() = + launchCatchingTask { + viewModel.deleteSelectedDeck().join() + } + @NeedsTest("14285: regression test to ensure UI is updated after this call") fun rebuildFiltered(did: DeckId) { launchCatchingTask { 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 97a877ec296e..c25b02d5c60a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/deckpicker/DeckPickerViewModel.kt @@ -210,21 +210,17 @@ class DeckPickerViewModel : val isFilteredDeck: Boolean, ) - /** Prepares a confirmation dialog for deleting the focused deck. */ + /** Prepares a confirmation dialog for deleting the selected deck. */ fun requestDeleteDeckConfirmation() = launchCatchingIO { - val deckId = - focusedDeck ?: run { - Timber.w("no focused deck") - return@launchCatchingIO - } val request = withCol { + val deck = decks.current() DeleteDeckConfirmationRequest( - deckId = deckId, - deckName = decks.name(deckId), - totalCards = decks.cardCount(deckId, includeSubdecks = true), - isFilteredDeck = decks.isFiltered(deckId), + deckId = deck.id, + deckName = deck.name, + totalCards = decks.cardCount(deck.id, includeSubdecks = true), + isFilteredDeck = deck.isFiltered, ) } flowOfDeleteDeckConfirmation.emit(request) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/DeckPickerViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/DeckPickerViewModelTest.kt index 163734298c60..b8f927f936e3 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/DeckPickerViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/deckpicker/DeckPickerViewModelTest.kt @@ -57,7 +57,7 @@ class DeckPickerViewModelTest : RobolectricTest() { private val viewModel: DeckPickerViewModel by lazy { DeckPickerViewModel() } @Test - fun `delete confirmation uses focused deck and includes cards in subdecks`() = + fun `delete confirmation includes cards in subdecks`() = runTest { val deckId = addDeck("Parent") val childDeckId = addDeck("Parent::Child") @@ -66,7 +66,7 @@ class DeckPickerViewModelTest : RobolectricTest() { addBasicNote("Unrelated card") col.setDeck(listOf(parentCard.id), deckId) col.setDeck(listOf(childCard.id), childDeckId) - selectDefaultDeck() + col.decks.select(deckId) viewModel.focusedDeck = deckId viewModel.flowOfDeleteDeckConfirmation.test { @@ -98,7 +98,7 @@ class DeckPickerViewModelTest : RobolectricTest() { @Test fun `delete confirmation supports an empty deck`() = runTest { - val deckId = addDeck("Empty") + val deckId = addDeck("Empty", setAsSelected = true) viewModel.focusedDeck = deckId viewModel.flowOfDeleteDeckConfirmation.test { @@ -108,16 +108,6 @@ class DeckPickerViewModelTest : RobolectricTest() { } } - @Test - fun `delete confirmation without a focused deck emits nothing`() = - runTest { - viewModel.flowOfDeleteDeckConfirmation.test { - viewModel.requestDeleteDeckConfirmation().join() - - expectNoEvents() - } - } - @Test fun `rename and study requests use the resolved selection despite focus search and collapsed parents`() = runTest { From d1d60ea82e2a2d99a70c55d7c4f1884969c38690 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:28:08 +0100 Subject: [PATCH 100/163] refactor(congrats): extract 'StudyCompletionUi' For: * completion navigation * preference handling * messages (legacy) Extracted from `CongratsPage` Part of 22249 Assisted-by: GPT-6-Astra xhigh --- .../com/ichi2/anki/AbstractFlashcardViewer.kt | 4 +- .../main/java/com/ichi2/anki/DeckPicker.kt | 7 +- .../java/com/ichi2/anki/pages/CongratsPage.kt | 68 ---------------- .../com/ichi2/anki/study/StudyCompletionUi.kt | 79 +++++++++++++++++++ 4 files changed, 84 insertions(+), 74 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/study/StudyCompletionUi.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt index f9dd109ff94e..b6d67c01c7d6 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt @@ -130,7 +130,6 @@ import com.ichi2.anki.multimedia.getAvTag import com.ichi2.anki.observability.ChangeManager import com.ichi2.anki.observability.undoableOp import com.ichi2.anki.pages.AnkiServer -import com.ichi2.anki.pages.CongratsPage import com.ichi2.anki.pages.PostRequestHandler import com.ichi2.anki.pages.PostRequestUri import com.ichi2.anki.reviewer.AutomaticAnswer @@ -149,6 +148,7 @@ import com.ichi2.anki.settings.Prefs import com.ichi2.anki.snackbar.BaseSnackbarBuilderProvider import com.ichi2.anki.snackbar.SnackbarBuilder import com.ichi2.anki.snackbar.showSnackbar +import com.ichi2.anki.study.StudyCompletionUi import com.ichi2.anki.ui.windows.reviewer.StudyScreenRepository import com.ichi2.anki.utils.OnlyOnce.Method.ANSWER_CARD import com.ichi2.anki.utils.OnlyOnce.preventSimultaneousExecutions @@ -525,7 +525,7 @@ abstract class AbstractFlashcardViewer : closeReviewer(RESULT_NO_MORE_CARDS) // When launched with a shortcut, we want to display a message when finishing if (intent.getBooleanExtra(EXTRA_STARTED_WITH_SHORTCUT, false)) { - CongratsPage.display(this) + StudyCompletionUi.displayFromShortcut(this) } return } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt index b2ad11248452..d8ffa6ecf25a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt @@ -167,8 +167,6 @@ import com.ichi2.anki.libanki.sched.DeckNode import com.ichi2.anki.mediacheck.MediaCheckFragment import com.ichi2.anki.observability.ChangeManager import com.ichi2.anki.pages.AnkiPackageImporterFragment -import com.ichi2.anki.pages.CongratsPage -import com.ichi2.anki.pages.CongratsPage.Companion.onDeckCompleted import com.ichi2.anki.progress.observeProgress import com.ichi2.anki.receiver.SdCardReceiver import com.ichi2.anki.reviewreminders.ReviewReminderScope @@ -181,6 +179,7 @@ import com.ichi2.anki.shareddeck.SharedDecksActivity import com.ichi2.anki.snackbar.BaseSnackbarBuilderProvider import com.ichi2.anki.snackbar.SnackbarBuilder import com.ichi2.anki.snackbar.showSnackbar +import com.ichi2.anki.study.StudyCompletionUi import com.ichi2.anki.sync.MeteredSyncPolicy import com.ichi2.anki.sync.launchCatchingRequiringOneWaySyncDiscardUndo import com.ichi2.anki.sync.syncStatus @@ -1566,7 +1565,7 @@ open class DeckPicker : private fun processReviewResults(resultCode: Int) { if (resultCode == AbstractFlashcardViewer.RESULT_NO_MORE_CARDS) { - CongratsPage.onReviewsCompleted(this, getColUnsafe.sched.totalCount() == 0) + StudyCompletionUi.onReviewsCompleted(this, getColUnsafe.sched.totalCount() == 0) fragment?.refreshInterface() } } @@ -2268,7 +2267,7 @@ open class DeckPicker : showEmptyDeckSnackbar() updateUi() } else { - onDeckCompleted() + StudyCompletionUi.openCongratsPage(this) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/pages/CongratsPage.kt b/AnkiDroid/src/main/java/com/ichi2/anki/pages/CongratsPage.kt index 3231227bdd5e..fba402ab99d0 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/pages/CongratsPage.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/pages/CongratsPage.kt @@ -7,7 +7,6 @@ import android.content.Intent import android.os.Bundle import android.view.View import androidx.appcompat.app.AlertDialog -import androidx.fragment.app.FragmentActivity import androidx.fragment.app.setFragmentResultListener import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle @@ -21,25 +20,18 @@ import com.google.android.material.appbar.MaterialToolbar import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.CommonString -import com.ichi2.anki.DeckPicker import com.ichi2.anki.OnErrorListener import com.ichi2.anki.R import com.ichi2.anki.SingleFragmentActivity import com.ichi2.anki.common.destinations.DeckOptionsDestination import com.ichi2.anki.common.destinations.StudyOptionsDestination import com.ichi2.anki.common.destinations.navigate -import com.ichi2.anki.common.preferences.sharedPrefs -import com.ichi2.anki.common.time.SECONDS_PER_DAY -import com.ichi2.anki.common.time.TIME_HOUR -import com.ichi2.anki.common.time.TIME_MINUTE -import com.ichi2.anki.common.utils.android.showThemedToast import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog import com.ichi2.anki.dialogs.customstudy.CustomStudyDialog.CustomStudyAction import com.ichi2.anki.launchCatchingIO import com.ichi2.anki.launchCatchingTask import com.ichi2.anki.observability.ChangeManager import com.ichi2.anki.observability.undoableOp -import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.ui.internationalization.sentenceCase import com.ichi2.utils.listItems import com.ichi2.utils.negativeButton @@ -49,7 +41,6 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import timber.log.Timber -import kotlin.math.round class CongratsPage : PageFragment(), @@ -191,65 +182,6 @@ class CongratsPage : companion object { fun getIntent(context: Context): Intent = SingleFragmentActivity.getIntent(context, fragmentClass = CongratsPage::class) - - private fun displayNewCongratsScreen(context: Context): Boolean = context.sharedPrefs().getBoolean("new_congrats_screen", false) - - fun display(activity: FragmentActivity) { - if (displayNewCongratsScreen(activity)) { - activity.startActivity(getIntent(activity)) - } else { - activity.launchCatchingTask { - val message = getDeckFinishedMessage(activity) - showThemedToast(activity, message, false) - } - } - } - - fun onReviewsCompleted( - activity: FragmentActivity, - cardsInDeck: Boolean, - ) { - if (displayNewCongratsScreen(activity)) { - activity.startActivity(getIntent(activity)) - return - } - - // Show a message when reviewing has finished - if (cardsInDeck) { - activity.launchCatchingTask { - val message = getDeckFinishedMessage(activity) - activity.showSnackbar(message) - } - } else { - activity.showSnackbar(CommonString.studyoptions_no_cards_due) - } - } - - // based in https://github.com/ankitects/anki/blob/9b4dd54312de8798a3f2bee07892bb3a488d1f9b/ts/routes/congrats/lib.ts#L8C17-L8C34 - private suspend fun getDeckFinishedMessage(activity: FragmentActivity): String { - val info = withCol { sched.congratulationsInfo() } - val secsUntilNextLearn = info.secsUntilNextLearn - if (secsUntilNextLearn >= SECONDS_PER_DAY) { - return activity.getString(CommonString.studyoptions_congrats_finished) - } - // https://github.com/ankitects/anki/blob/9b4dd54312de8798a3f2bee07892bb3a488d1f9b/ts/lib/tslib/time.ts#L22 - val (unit, amount) = - if (secsUntilNextLearn < TIME_MINUTE) { - "seconds" to secsUntilNextLearn.toDouble() - } else if (secsUntilNextLearn < TIME_HOUR) { - "minutes" to secsUntilNextLearn / TIME_MINUTE - } else { - "hours" to secsUntilNextLearn / TIME_HOUR - } - - val nextLearnDue = TR.schedulingNextLearnDue(unit, round(amount).toInt()) - return activity.getString(CommonString.studyoptions_congrats_next_due_in, nextLearnDue) - } - - fun DeckPicker.onDeckCompleted() { - Timber.i("Opening CongratsPage") - startActivity(getIntent(this)) - } } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/study/StudyCompletionUi.kt b/AnkiDroid/src/main/java/com/ichi2/anki/study/StudyCompletionUi.kt new file mode 100644 index 000000000000..e5f801abef9b --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/study/StudyCompletionUi.kt @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.study + +import android.content.Context +import androidx.fragment.app.FragmentActivity +import com.ichi2.anki.CollectionManager.TR +import com.ichi2.anki.CollectionManager.withCol +import com.ichi2.anki.CommonString +import com.ichi2.anki.common.preferences.sharedPrefs +import com.ichi2.anki.common.time.SECONDS_PER_DAY +import com.ichi2.anki.common.time.TIME_HOUR +import com.ichi2.anki.common.time.TIME_MINUTE +import com.ichi2.anki.common.utils.android.showThemedToast +import com.ichi2.anki.launchCatchingTask +import com.ichi2.anki.pages.CongratsPage +import com.ichi2.anki.snackbar.showSnackbar +import kotlin.math.round + +/** Presents the end of a review session in the activity that owns its result. */ +object StudyCompletionUi { + fun openCongratsPage(activity: FragmentActivity) { + activity.startActivity(CongratsPage.getIntent(activity)) + } + + private fun displayNewCongratsScreen(context: Context): Boolean = context.sharedPrefs().getBoolean("new_congrats_screen", false) + + fun displayFromShortcut(activity: FragmentActivity) { + if (displayNewCongratsScreen(activity)) { + openCongratsPage(activity) + } else { + activity.launchCatchingTask { + val message = getDeckFinishedMessage(activity) + showThemedToast(activity, message, false) + } + } + } + + fun onReviewsCompleted( + activity: FragmentActivity, + cardsInDeck: Boolean, + ) { + if (displayNewCongratsScreen(activity)) { + openCongratsPage(activity) + return + } + + // Show a message when reviewing has finished + if (cardsInDeck) { + activity.launchCatchingTask { + val message = getDeckFinishedMessage(activity) + activity.showSnackbar(message) + } + } else { + activity.showSnackbar(CommonString.studyoptions_no_cards_due) + } + } + + // based in https://github.com/ankitects/anki/blob/9b4dd54312de8798a3f2bee07892bb3a488d1f9b/ts/routes/congrats/lib.ts#L8C17-L8C34 + private suspend fun getDeckFinishedMessage(activity: FragmentActivity): String { + val info = withCol { sched.congratulationsInfo() } + val secsUntilNextLearn = info.secsUntilNextLearn + if (secsUntilNextLearn >= SECONDS_PER_DAY) { + return activity.getString(CommonString.studyoptions_congrats_finished) + } + // https://github.com/ankitects/anki/blob/9b4dd54312de8798a3f2bee07892bb3a488d1f9b/ts/lib/tslib/time.ts#L22 + val (unit, amount) = + if (secsUntilNextLearn < TIME_MINUTE) { + "seconds" to secsUntilNextLearn.toDouble() + } else if (secsUntilNextLearn < TIME_HOUR) { + "minutes" to secsUntilNextLearn / TIME_MINUTE + } else { + "hours" to secsUntilNextLearn / TIME_HOUR + } + + val nextLearnDue = TR.schedulingNextLearnDue(unit, round(amount).toInt()) + return activity.getString(CommonString.studyoptions_congrats_next_due_in, nextLearnDue) + } +} From 748cbea16ab639c48685d898e5eb7f5e6398b281 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:29:00 +0100 Subject: [PATCH 101/163] refactor(congrats): define `DeckStudyState` Combine details of the study state in one object, produced while in `withCol`. Make a distinction between an empty deck and a non-empty deck which can't be studied. Use this to determine if the `Congrats` page should be shown, and to replace a legacy `cardsInDeck` boolean for a snackbar. Part of 22249 Assisted-by: GPT-6-Astra xhigh --- .../main/java/com/ichi2/anki/DeckPicker.kt | 6 +- .../java/com/ichi2/anki/pages/CongratsPage.kt | 8 +- .../com/ichi2/anki/study/DeckStudyState.kt | 66 +++++++ .../com/ichi2/anki/study/StudyCompletionUi.kt | 49 ++--- .../com/ichi2/anki/ReviewCompletionTest.kt | 169 ++++++++++++++++++ .../ichi2/anki/study/DeckStudyStateTest.kt | 128 +++++++++++++ 6 files changed, 392 insertions(+), 34 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/study/DeckStudyState.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/ReviewCompletionTest.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/study/DeckStudyStateTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt index d8ffa6ecf25a..d2022c7df327 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/DeckPicker.kt @@ -180,6 +180,7 @@ import com.ichi2.anki.snackbar.BaseSnackbarBuilderProvider import com.ichi2.anki.snackbar.SnackbarBuilder import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.study.StudyCompletionUi +import com.ichi2.anki.study.currentDeckStudyState import com.ichi2.anki.sync.MeteredSyncPolicy import com.ichi2.anki.sync.launchCatchingRequiringOneWaySyncDiscardUndo import com.ichi2.anki.sync.syncStatus @@ -1565,7 +1566,10 @@ open class DeckPicker : private fun processReviewResults(resultCode: Int) { if (resultCode == AbstractFlashcardViewer.RESULT_NO_MORE_CARDS) { - StudyCompletionUi.onReviewsCompleted(this, getColUnsafe.sched.totalCount() == 0) + launchCatchingTask { + val state = withCol { currentDeckStudyState() } + StudyCompletionUi.onReviewsCompleted(this@DeckPicker, state) + } fragment?.refreshInterface() } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/pages/CongratsPage.kt b/AnkiDroid/src/main/java/com/ichi2/anki/pages/CongratsPage.kt index fba402ab99d0..a13fa289e088 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/pages/CongratsPage.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/pages/CongratsPage.kt @@ -32,6 +32,8 @@ import com.ichi2.anki.launchCatchingIO import com.ichi2.anki.launchCatchingTask import com.ichi2.anki.observability.ChangeManager import com.ichi2.anki.observability.undoableOp +import com.ichi2.anki.study.StudyAvailability +import com.ichi2.anki.study.currentDeckStudyState import com.ichi2.anki.ui.internationalization.sentenceCase import com.ichi2.utils.listItems import com.ichi2.utils.negativeButton @@ -242,8 +244,8 @@ class CongratsViewModel : } } - /** Called every time the screen becomes STARTED. [isDeckFinished] is cheap so it's always - * run, but the (latency-sensitive) reload/navigation only happens if something was actually + /** Called every time the screen becomes STARTED. [isDeckFinished] is always checked, + * but reload/navigation only happens if something was actually * missed while unwatched, or the deck no longer needs the congrats page. */ suspend fun refreshIfNeeded() { // desktop's Overview re-checks sched._is_finished() on every refresh instead of @@ -264,7 +266,7 @@ class CongratsViewModel : ) } - private suspend fun isDeckFinished(): Boolean = withCol { sched.counts().count() == 0 } + private suspend fun isDeckFinished(): Boolean = withCol { currentDeckStudyState().availability == StudyAvailability.NoCardsAvailable } } sealed class UnburyState { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/study/DeckStudyState.kt b/AnkiDroid/src/main/java/com/ichi2/anki/study/DeckStudyState.kt new file mode 100644 index 000000000000..ae27cedbc06d --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/study/DeckStudyState.kt @@ -0,0 +1,66 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.study + +import anki.scheduler.CongratsInfoResponse +import com.ichi2.anki.common.time.SECONDS_PER_DAY +import com.ichi2.anki.libanki.Collection +import com.ichi2.anki.libanki.DeckId +import kotlin.time.Duration +import kotlin.time.Duration.Companion.seconds + +/** + * Collection facts about the selected deck, independent of the screen displaying them. + * + * Contents and availability are separate: cards can be buried, limited, scheduled for later, + * or temporarily borrowed by a filtered deck. Completion details retain those distinctions. + */ +data class DeckStudyState( + val deckId: DeckId, + val kind: DeckKind, + /** Includes subdecks and cards temporarily borrowed by filtered decks. */ + val cardCount: Int, + val newCardsAvailable: Int, + val learningCardsAvailable: Int, + val reviewCardsAvailable: Int, + val congratulations: CongratsInfoResponse, +) { + val availability: StudyAvailability + get() = + if (newCardsAvailable + learningCardsAvailable + reviewCardsAvailable > 0) { + StudyAvailability.CardsAvailable + } else { + StudyAvailability.NoCardsAvailable + } + + val isEmptyRegularDeck: Boolean get() = kind == DeckKind.Regular && cardCount == 0 + + /** Time until learning becomes available, accounting for the learn-ahead limit. */ + val nextLearningIn: Duration? + get() = congratulations.secsUntilNextLearn.takeIf { it < SECONDS_PER_DAY }?.seconds +} + +enum class DeckKind { + Regular, + Filtered, +} + +enum class StudyAvailability { + CardsAvailable, + NoCardsAvailable, +} + +/** Read under the collection lock so identity, contents and scheduling refer to the same deck. */ +fun Collection.currentDeckStudyState(): DeckStudyState { + val deck = decks.current() + val counts = sched.counts() + return DeckStudyState( + deckId = deck.id, + kind = if (deck.isFiltered) DeckKind.Filtered else DeckKind.Regular, + cardCount = decks.cardCount(deck.id, includeSubdecks = true), + newCardsAvailable = counts.new, + learningCardsAvailable = counts.lrn, + reviewCardsAvailable = counts.rev, + congratulations = sched.congratulationsInfo(), + ) +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/study/StudyCompletionUi.kt b/AnkiDroid/src/main/java/com/ichi2/anki/study/StudyCompletionUi.kt index e5f801abef9b..1dc4c5fdd303 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/study/StudyCompletionUi.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/study/StudyCompletionUi.kt @@ -8,7 +8,6 @@ import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.CommonString import com.ichi2.anki.common.preferences.sharedPrefs -import com.ichi2.anki.common.time.SECONDS_PER_DAY import com.ichi2.anki.common.time.TIME_HOUR import com.ichi2.anki.common.time.TIME_MINUTE import com.ichi2.anki.common.utils.android.showThemedToast @@ -23,57 +22,47 @@ object StudyCompletionUi { activity.startActivity(CongratsPage.getIntent(activity)) } - private fun displayNewCongratsScreen(context: Context): Boolean = context.sharedPrefs().getBoolean("new_congrats_screen", false) - fun displayFromShortcut(activity: FragmentActivity) { if (displayNewCongratsScreen(activity)) { openCongratsPage(activity) } else { activity.launchCatchingTask { - val message = getDeckFinishedMessage(activity) - showThemedToast(activity, message, false) + val state = withCol { currentDeckStudyState() } + showThemedToast(activity, finishedMessage(activity, state), false) } } } fun onReviewsCompleted( activity: FragmentActivity, - cardsInDeck: Boolean, + state: DeckStudyState, ) { if (displayNewCongratsScreen(activity)) { openCongratsPage(activity) return } - - // Show a message when reviewing has finished - if (cardsInDeck) { - activity.launchCatchingTask { - val message = getDeckFinishedMessage(activity) - activity.showSnackbar(message) - } - } else { - activity.showSnackbar(CommonString.studyoptions_no_cards_due) + when (state.availability) { + StudyAvailability.NoCardsAvailable -> activity.showSnackbar(finishedMessage(activity, state)) + StudyAvailability.CardsAvailable -> activity.showSnackbar(CommonString.studyoptions_no_cards_due) } } - // based in https://github.com/ankitects/anki/blob/9b4dd54312de8798a3f2bee07892bb3a488d1f9b/ts/routes/congrats/lib.ts#L8C17-L8C34 - private suspend fun getDeckFinishedMessage(activity: FragmentActivity): String { - val info = withCol { sched.congratulationsInfo() } - val secsUntilNextLearn = info.secsUntilNextLearn - if (secsUntilNextLearn >= SECONDS_PER_DAY) { - return activity.getString(CommonString.studyoptions_congrats_finished) - } - // https://github.com/ankitects/anki/blob/9b4dd54312de8798a3f2bee07892bb3a488d1f9b/ts/lib/tslib/time.ts#L22 + private fun displayNewCongratsScreen(context: Context) = context.sharedPrefs().getBoolean("new_congrats_screen", false) + + private fun finishedMessage( + context: Context, + state: DeckStudyState, + ): String { + val seconds = state.nextLearningIn?.inWholeSeconds ?: return context.getString(CommonString.studyoptions_congrats_finished) val (unit, amount) = - if (secsUntilNextLearn < TIME_MINUTE) { - "seconds" to secsUntilNextLearn.toDouble() - } else if (secsUntilNextLearn < TIME_HOUR) { - "minutes" to secsUntilNextLearn / TIME_MINUTE + if (seconds < TIME_MINUTE) { + "seconds" to seconds.toDouble() + } else if (seconds < TIME_HOUR) { + "minutes" to seconds / TIME_MINUTE } else { - "hours" to secsUntilNextLearn / TIME_HOUR + "hours" to seconds / TIME_HOUR } - val nextLearnDue = TR.schedulingNextLearnDue(unit, round(amount).toInt()) - return activity.getString(CommonString.studyoptions_congrats_next_due_in, nextLearnDue) + return context.getString(CommonString.studyoptions_congrats_next_due_in, nextLearnDue) } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ReviewCompletionTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ReviewCompletionTest.kt new file mode 100644 index 000000000000..6507475c38f2 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ReviewCompletionTest.kt @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package com.ichi2.anki + +import android.content.Context +import android.content.Intent +import android.view.KeyEvent +import androidx.core.content.edit +import anki.scheduler.CardAnswer.Rating +import com.ichi2.anki.AbstractFlashcardViewer.Companion.RESULT_NO_MORE_CARDS +import com.ichi2.anki.CollectionManager.TR +import com.ichi2.anki.NavigationDrawerActivity.Companion.EXTRA_STARTED_WITH_SHORTCUT +import com.ichi2.anki.libanki.Consts.DEFAULT_DECK_ID +import com.ichi2.anki.pages.CongratsPage +import com.ichi2.testutils.ext.snackbarText +import org.json.JSONArray +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.shadows.ShadowToast +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@RunWith(ParameterizedRobolectricTestRunner::class) +class ReviewCompletionTest : RobolectricTest() { + @ParameterizedRobolectricTestRunner.Parameter(0) + @JvmField + var newCongratsScreen = false + + @ParameterizedRobolectricTestRunner.Parameter(1) + @JvmField + var fromShortcut = false + + override fun getCollectionStorageMode() = CollectionStorageMode.IN_MEMORY_WITH_MEDIA + + @Before + fun configureCompletionScreen() { + getPreferences().edit { putBoolean("new_congrats_screen", newCongratsScreen) } + ShadowToast.reset() + } + + @Test + fun `finished parent with cards only in its child still shows completion`() { + val child = addDeck("Parent::Child") + col.setDeck(listOf(addBasicNote().firstCard().id), child) + col.decks.select(col.decks.id("Parent")) + + completeReview(expectEmpty = false) { + col.sched.answerCard(assertNotNull(col.sched.card), Rating.EASY) + assertEquals(0, col.sched.totalCount()) + assertEquals(1, col.decks.cardCount(child, includeSubdecks = true)) + } + } + + @Test + fun `filtered deck emptied by the final review still shows completion`() { + addBasicNote() + val filtered = addDynamicDeck("Filtered", "") + + completeReview(expectEmpty = false) { + col.sched.answerCard(assertNotNull(col.sched.card), Rating.EASY) + assertEquals(0, col.decks.cardCount(filtered, includeSubdecks = true)) + } + } + + @Test + fun `learning later still shows completion with the next available time`() { + col.config.set("collapseTime", 0) + val config = col.decks.configDictForDeckId(DEFAULT_DECK_ID) + config.new.delays = JSONArray(listOf(1)) + col.decks.save(config) + addBasicNote() + + completeReview( + expectEmpty = false, + expectedMessage = { + it.getString( + CommonString.studyoptions_congrats_next_due_in, + TR.schedulingNextLearnDue("minutes", 1), + ) + }, + ) { + col.sched.answerCard(assertNotNull(col.sched.card), Rating.AGAIN) + assertNull(col.sched.card) + } + } + + private fun completeReview( + expectEmpty: Boolean, + expectedMessage: (Context) -> String = { + it.getString(if (expectEmpty) CommonString.empty_deck else CommonString.studyoptions_congrats_finished) + }, + finishCards: () -> Unit, + ) { + if (fromShortcut) { + runTest { + val reviewer = + startActivityNormallyOpenCollectionWithIntent( + Reviewer::class.java, + Intent().putExtra(EXTRA_STARTED_WITH_SHORTCUT, true), + ) + advanceRobolectricLooper() + assertNotNull(reviewer.currentCardId) + finishCards() + val selected = col.decks.selected() + val undoStatus = col.undoStatus() + + reviewer.updateCardAndRedraw() + advanceRobolectricLooper() + + assertTrue(reviewer.isFinishing) + assertEquals(RESULT_NO_MORE_CARDS, shadowOf(reviewer).resultCode) + if (newCongratsScreen && !expectEmpty) { + assertCongratsIntent(shadowOf(reviewer).nextStartedActivity) + } else { + assertNull(shadowOf(reviewer).nextStartedActivity) + assertEquals(expectedMessage(reviewer), ShadowToast.getTextOfLatestToast()) + } + assertEquals(selected, col.decks.selected()) + assertEquals(undoStatus, col.undoStatus()) + } + } else { + deckPicker { + advanceRobolectricLooper() + val shadow = shadowOf(this) + shadow.clearNextStartedActivities() + assertTrue(onKeyUp(KeyEvent.KEYCODE_S, KeyEvent(KeyEvent.ACTION_UP, KeyEvent.KEYCODE_S))) + advanceRobolectricLooperUntil { shadow.peekNextStartedActivity() != null } + val review = assertNotNull(shadow.nextStartedActivityForResult) + assertEquals(Reviewer::class.java.name, review.intent.component?.className) + shadow.clearNextStartedActivities() + finishCards() + val selected = col.decks.selected() + val undoStatus = col.undoStatus() + + activityResultRegistry.dispatchResult(review.requestCode, RESULT_NO_MORE_CARDS, null) + + if (newCongratsScreen && !expectEmpty) { + advanceRobolectricLooperUntil { shadow.peekNextStartedActivity() != null } + assertCongratsIntent(shadow.nextStartedActivity) + } else { + advanceRobolectricLooperUntil { snackbarText == expectedMessage(this) } + assertNull(shadow.nextStartedActivity) + } + assertEquals(selected, col.decks.selected()) + assertEquals(undoStatus, col.undoStatus()) + } + } + } + + private fun assertCongratsIntent(intent: Intent?) { + assertEquals( + CongratsPage::class.java.name, + assertNotNull(intent).getStringExtra(SingleFragmentActivity.EXTRA_FRAGMENT_NAME), + ) + } + + companion object { + @ParameterizedRobolectricTestRunner.Parameters(name = "new screen = {0}, shortcut = {1}") + @JvmStatic + fun configurations() = + listOf(false, true).flatMap { newScreen -> + listOf(false, true).map { shortcut -> arrayOf(newScreen, shortcut) } + } + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/study/DeckStudyStateTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/study/DeckStudyStateTest.kt new file mode 100644 index 000000000000..62d2b45dcef8 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/study/DeckStudyStateTest.kt @@ -0,0 +1,128 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.study + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import anki.scheduler.CardAnswer.Rating +import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.libanki.Consts.DEFAULT_DECK_ID +import org.json.JSONArray +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds + +@RunWith(AndroidJUnit4::class) +class DeckStudyStateTest : RobolectricTest() { + @Test + fun `hidden Default retains its identity and empty regular deck state`() { + addDeck("Other") + assertNull(col.sched.deckDueTree().find(DEFAULT_DECK_ID)) + val undo = col.undoStatus() + + val state = col.currentDeckStudyState() + + assertEquals(DEFAULT_DECK_ID, state.deckId) + assertEquals(DeckKind.Regular, state.kind) + assertTrue(state.isEmptyRegularDeck) + assertEquals(StudyAvailability.NoCardsAvailable, state.availability) + assertNull(state.nextLearningIn) + assertEquals(DEFAULT_DECK_ID, col.decks.selected()) + assertEquals(undo, col.undoStatus()) + } + + @Test + fun `empty filtered deck retains its kind`() { + val deckId = addDynamicDeck("Filtered", "deck:Missing") + + val state = col.currentDeckStudyState() + + assertEquals(deckId, state.deckId) + assertEquals(DeckKind.Filtered, state.kind) + assertEquals(0, state.cardCount) + assertFalse(state.isEmptyRegularDeck) + assertEquals(StudyAvailability.NoCardsAvailable, state.availability) + } + + @Test + fun `parent contents and availability include subdecks`() { + val child = addDeck("Parent::Child") + col.setDeck(listOf(addBasicNote().firstCard().id), child) + val parent = col.decks.id("Parent") + col.decks.select(parent) + + val state = col.currentDeckStudyState() + + assertEquals(parent, state.deckId) + assertEquals(1, state.cardCount) + assertEquals(1, state.newCardsAvailable) + assertEquals(StudyAvailability.CardsAvailable, state.availability) + } + + @Test + fun `suspended cards remain content without being available`() { + val card = addBasicNote().firstCard() + col.sched.suspendCards(listOf(card.id)) + + val state = col.currentDeckStudyState() + + assertEquals(1, state.cardCount) + assertFalse(state.isEmptyRegularDeck) + assertEquals(StudyAvailability.NoCardsAvailable, state.availability) + } + + @Test + fun `cards borrowed by a filtered deck remain part of the regular deck`() { + addBasicNote() + addDynamicDeck("Filtered", "") + col.decks.select(DEFAULT_DECK_ID) + + val state = col.currentDeckStudyState() + + assertEquals(1, state.cardCount) + assertFalse(state.isEmptyRegularDeck) + assertEquals(StudyAvailability.NoCardsAvailable, state.availability) + } + + @Test + fun `learning later and available cards can coexist`() { + col.config.set("collapseTime", 0) + val config = col.decks.configDictForDeckId(DEFAULT_DECK_ID) + config.new.delays = JSONArray(listOf(1)) + col.decks.save(config) + addBasicNote() + col.sched.answerCard(assertNotNull(col.sched.card), Rating.AGAIN) + assertNull(col.sched.card) + val waiting = col.currentDeckStudyState() + assertEquals(StudyAvailability.NoCardsAvailable, waiting.availability) + assertTrue(assertNotNull(waiting.nextLearningIn) in 60.seconds..75.seconds) + + addBasicNote("Another") + val available = col.currentDeckStudyState() + + assertEquals(StudyAvailability.CardsAvailable, available.availability) + assertEquals(2, available.cardCount) + assertNotNull(available.nextLearningIn) + } + + @Test + fun `daily limits and buried cards are retained together`() { + val buried = addBasicNote().firstCard() + col.sched.buryCards(listOf(buried.id)) + addBasicNote("Limited") + val config = col.decks.configDictForDeckId(DEFAULT_DECK_ID) + config.new.perDay = 0 + col.decks.save(config) + + val state = col.currentDeckStudyState() + + assertEquals(StudyAvailability.NoCardsAvailable, state.availability) + assertEquals(2, state.cardCount) + assertTrue(state.congratulations.newRemaining) + assertTrue(state.congratulations.haveUserBuried) + } +} From 8f6cc28cbb05ac38e9fab9b71f633e24d26418d6 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:30:29 +0100 Subject: [PATCH 102/163] refactor(study-options): use `DeckStudyState` ...when building `StudyOptionsState` Part of 22249. Assisted-by: GPT-6-Astra xhigh --- .../com/ichi2/anki/StudyOptionsViewModel.kt | 30 ++++++++++--------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsViewModel.kt index 528abc126751..d1079b4425fc 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsViewModel.kt @@ -10,6 +10,9 @@ import com.ichi2.anki.CollectionManager.withOpenColOrNull import com.ichi2.anki.libanki.Collection import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.observability.undoableOp +import com.ichi2.anki.study.DeckKind +import com.ichi2.anki.study.StudyAvailability +import com.ichi2.anki.study.currentDeckStudyState import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -69,18 +72,18 @@ class StudyOptionsViewModel : ViewModel() { */ private fun Collection.updateStateFromCollection() { val deck = decks.current() - val deckId = deck.id - val counts = sched.counts() + val study = currentDeckStudyState() + val deckId = study.deckId var buriedNew = 0 var buriedLearning = 0 var buriedReview = 0 val tree = sched.deckDueTree(deckId) if (tree != null) { - buriedNew = tree.newCount - counts.new - buriedLearning = tree.learnCount - counts.lrn - buriedReview = tree.reviewCount - counts.rev + buriedNew = tree.newCount - study.newCardsAvailable + buriedLearning = tree.learnCount - study.learningCardsAvailable + buriedReview = tree.reviewCount - study.reviewCardsAvailable } - val isDynamic = deck.isFiltered + val isDynamic = study.kind == DeckKind.Filtered val fullName = deck.getString("name") val description = if (isDynamic) { @@ -95,32 +98,31 @@ class StudyOptionsViewModel : ViewModel() { } val data = DeckStudyData( - newCardsToday = counts.new, - lrnCardsToday = counts.lrn, - revCardsToday = counts.rev, + newCardsToday = study.newCardsAvailable, + lrnCardsToday = study.learningCardsAvailable, + revCardsToday = study.reviewCardsAvailable, buriedNew = buriedNew, buriedLearning = buriedLearning, buriedReview = buriedReview, totalNewCards = sched.totalNewForCurrentDeck(), - numberOfCardsInDeck = decks.cardCount(deckId, includeSubdecks = true), + numberOfCardsInDeck = study.cardCount, ) flowOfIsFilteredDeck.value = isDynamic flowOfHaveBuried.value = sched.haveBuried() - flowOfSelectedDeckId.value = decks.selected() + flowOfSelectedDeckId.value = study.deckId // Only resolve the media directory when a description will render it: an in-memory // collection has no media folder and `media.dir` would throw. flowOfMediaDir.value = if (!description.isNullOrEmpty()) media.dir else null - val totalDue = data.newCardsToday + data.lrnCardsToday + data.revCardsToday flowOfState.value = when { - data.numberOfCardsInDeck == 0 && !isDynamic -> + study.isEmptyRegularDeck -> StudyOptionsState.Empty( deckName = fullName, data = data, ) - totalDue == 0 -> + study.availability == StudyAvailability.NoCardsAvailable -> StudyOptionsState.Congrats( deckName = fullName, isDynamic = isDynamic, From d7006bcb6948d1ac1f96ce7f806317fabcbe8c72 Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Fri, 16 Oct 2026 13:46:50 +0530 Subject: [PATCH 103/163] fix(tablet): keep each resizable pane at least 240dp wide Dragging the tablet divider could shrink a pane to as little as a tenth of the screen, too narrow to use, and worse with a large font. Each pane now keeps at least 240dp times the font scale, also when a saved layout opens or the screen rotates. Fixes 22533 Assisted-by: Claude Opus 5.5 --- .../com/ichi2/anki/ui/ResizablePaneManager.kt | 35 ++++- .../ichi2/anki/ui/ResizablePaneManagerTest.kt | 134 ++++++++++++++++++ 2 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/ui/ResizablePaneManagerTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/ui/ResizablePaneManager.kt b/AnkiDroid/src/main/java/com/ichi2/anki/ui/ResizablePaneManager.kt index cabe100ae948..73fc5ce1e25c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/ui/ResizablePaneManager.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/ui/ResizablePaneManager.kt @@ -9,7 +9,9 @@ import android.view.PointerIcon import android.view.View import android.widget.LinearLayout import androidx.core.content.edit +import androidx.core.view.doOnPreDraw import com.ichi2.anki.R +import com.ichi2.utils.dp import timber.log.Timber /** @@ -24,7 +26,6 @@ class ResizablePaneManager( private val sharedPrefs: SharedPreferences, private val startPaneWeightKey: String, private val endPaneWeightKey: String, - private val minWeight: Float = 0.5f, // Minimum weight for each pane private val dragColor: Int = divider.context.getColor(R.color.drag_divider_color), private val idleColor: Int = divider.context.getColor(R.color.idle_divider_color), private val onResizingChanged: (Boolean) -> Unit = {}, @@ -36,6 +37,10 @@ class ResizablePaneManager( private fun setupResizableDivider() { // Load saved weights if available loadSavedWeights() + parentLayout.doOnPreDraw { clampWeights() } + parentLayout.addOnLayoutChangeListener { _, left, _, right, _, oldLeft, _, oldRight, _ -> + if (right - left != oldRight - oldLeft) parentLayout.doOnPreDraw { clampWeights() } + } var initialTouchX = 0f var initialStartWeight = 0f @@ -97,6 +102,7 @@ class ResizablePaneManager( // Clamp the new weight for the start pane // Ensures it's not too small and not too large (leaving space for the other pane's minWeight) + val minWeight = minWeight(sumOfInitialWeights) newStartWeight = newStartWeight.coerceIn(minWeight, sumOfInitialWeights - minWeight) val newEndWeight = sumOfInitialWeights - newStartWeight @@ -151,6 +157,33 @@ class ResizablePaneManager( Timber.w(e, "Failed to load saved pane weights") } } + + /** The weight that keeps a pane [MIN_PANE_WIDTH] wide, or half of [totalWeight] if two of those don't fit */ + private fun minWeight(totalWeight: Float): Float { + val paneSpace = parentLayout.width - divider.width + if (paneSpace <= 0) return 0f + val minPaneWidth = MIN_PANE_WIDTH.toPx(parentLayout.context) * parentLayout.resources.configuration.fontScale + return (totalWeight * minPaneWidth / paneSpace).coerceAtMost(totalWeight / 2) + } + + /** Moves the divider back within the limits when a saved split or a narrower screen leaves a pane too small */ + private fun clampWeights() { + val startParams = startPane.layoutParams as LinearLayout.LayoutParams + val endParams = endPane.layoutParams as LinearLayout.LayoutParams + val totalWeight = startParams.weight + endParams.weight + val minWeight = minWeight(totalWeight) + val clampedStartWeight = startParams.weight.coerceIn(minWeight, totalWeight - minWeight) + if (clampedStartWeight == startParams.weight) return + startParams.weight = clampedStartWeight + endParams.weight = totalWeight - clampedStartWeight + startPane.layoutParams = startParams + endPane.layoutParams = endParams + } + + companion object { + /** Narrowest a pane can be, times the font scale: the panes hold text, which needs more room at a larger font size */ + private val MIN_PANE_WIDTH = 240.dp + } } private fun Float.invertIfRtl(view: View): Float = if (view.layoutDirection == View.LAYOUT_DIRECTION_RTL) -this else this diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ui/ResizablePaneManagerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ui/ResizablePaneManagerTest.kt new file mode 100644 index 000000000000..dd2e7b68c5a4 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ui/ResizablePaneManagerTest.kt @@ -0,0 +1,134 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.ui + +import android.content.Context +import android.os.Looper +import android.os.SystemClock +import android.view.MotionEvent +import android.view.View +import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import android.widget.FrameLayout +import android.widget.LinearLayout +import androidx.core.content.edit +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import androidx.fragment.app.FragmentActivity +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.EmptyApplicationCategory +import com.ichi2.testutils.AndroidTest +import com.ichi2.testutils.EmptyApplication +import com.ichi2.testutils.targetContext +import org.junit.Test +import org.junit.experimental.categories.Category +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import kotlin.test.assertEquals + +@RunWith(AndroidJUnit4::class) +@Config(application = EmptyApplication::class) +@Category(EmptyApplicationCategory::class) +class ResizablePaneManagerTest : AndroidTest { + private val prefs get() = targetContext.getSharedPreferences("ResizablePaneManagerTest", Context.MODE_PRIVATE) + private lateinit var startPane: View + private lateinit var endPane: View + private lateinit var divider: View + + @Test + fun `a pane cannot be dragged narrower than 240dp`() { + createPanes(width = 1008) + dragDivider(by = 900f) + assertEquals(240f, endPane.width.toFloat(), absoluteTolerance = 1f) + } + + @Test + fun `the minimum width grows with the font size`() { + RuntimeEnvironment.setFontScale(1.5f) + createPanes(width = 1008) + dragDivider(by = 900f) + assertEquals(360f, endPane.width.toFloat(), absoluteTolerance = 1f) + } + + @Test + fun `the divider stays in the middle when two minimums do not fit`() { + createPanes(width = 408) + dragDivider(by = 300f) + assertEquals(startPane.width.toFloat(), endPane.width.toFloat(), absoluteTolerance = 1f) + } + + @Test + fun `a saved layout that squeezes a pane opens at the minimum`() { + prefs.edit { + putFloat("start", 4.8f) + putFloat("end", 0.2f) + } + createPanes(width = 1008) + assertEquals(240f, endPane.width.toFloat(), absoluteTolerance = 1f) + } + + @Test + fun `a layout with no width yet keeps its saved split`() { + prefs.edit { + putFloat("start", 3.5f) + putFloat("end", 1.5f) + } + createPanes(width = 1008, visible = false) + (startPane.parent as View).isVisible = true + shadowOf(Looper.getMainLooper()).idle() + assertEquals(300f, endPane.width.toFloat(), absoluteTolerance = 1f) + } + + @Test + fun `a pane keeps the minimum when the window gets narrower`() { + createPanes(width = 1008) + dragDivider(by = 900f) + (startPane.parent as View).updateLayoutParams { width = 600 } + shadowOf(Looper.getMainLooper()).idle() + assertEquals(240f, endPane.width.toFloat(), absoluteTolerance = 1f) + } + + private fun createPanes( + width: Int, + visible: Boolean = true, + ) { + val activity = Robolectric.buildActivity(FragmentActivity::class.java).setup().get() + startPane = View(activity) + endPane = View(activity) + divider = ResizingDivider(activity) + val layout = + LinearLayout(activity).apply { + orientation = LinearLayout.HORIZONTAL + isVisible = visible + addView(startPane, LinearLayout.LayoutParams(0, MATCH_PARENT, 3f)) + addView(divider, LinearLayout.LayoutParams(8, MATCH_PARENT)) + addView(endPane, LinearLayout.LayoutParams(0, MATCH_PARENT, 2f)) + } + ResizablePaneManager( + parentLayout = layout, + divider = divider, + startPane = startPane, + endPane = endPane, + sharedPrefs = prefs, + startPaneWeightKey = "start", + endPaneWeightKey = "end", + ) + activity.setContentView(FrameLayout(activity).apply { addView(layout, FrameLayout.LayoutParams(width, MATCH_PARENT)) }) + shadowOf(Looper.getMainLooper()).idle() + } + + private fun dragDivider(by: Float) { + val x = (divider.left + divider.right) / 2f + val downTime = SystemClock.uptimeMillis() + divider.dispatchTouchEvent(MotionEvent.obtain(downTime, downTime, MotionEvent.ACTION_DOWN, x, 300f, 0)) + for (step in 1..10) { + divider.dispatchTouchEvent( + MotionEvent.obtain(downTime, downTime + step * 10L, MotionEvent.ACTION_MOVE, x + by * step / 10, 300f, 0), + ) + } + divider.dispatchTouchEvent(MotionEvent.obtain(downTime, downTime + 200, MotionEvent.ACTION_UP, x + by, 300f, 0)) + shadowOf(Looper.getMainLooper()).idle() + } +} From 425b4be6856478f0c6092eb49bdcbbcfc4ab43d2 Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Wed, 21 Oct 2026 07:53:50 +0530 Subject: [PATCH 104/163] test(login): stop the progress dialog covering the login error On slow runners the test jumped the clock ahead while the login was still finishing, so the progress dialog opened over the error dialog. Wait for the login to finish instead. Assisted-by: Claude Opus 5.5 --- .../com/ichi2/anki/account/LoginFragmentTest.kt | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/account/LoginFragmentTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/account/LoginFragmentTest.kt index c527d3310765..3a7cf6c80649 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/account/LoginFragmentTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/account/LoginFragmentTest.kt @@ -2,6 +2,8 @@ package com.ichi2.anki.account +import android.os.Looper +import android.view.WindowManager import android.widget.Button import androidx.test.ext.junit.runners.AndroidJUnit4 import com.google.android.material.textfield.TextInputEditText @@ -16,6 +18,9 @@ import org.junit.After import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf +import kotlin.time.Duration.Companion.seconds +import kotlin.time.TimeSource @RunWith(AndroidJUnit4::class) class LoginFragmentTest : RobolectricTest() { @@ -43,8 +48,17 @@ class LoginFragmentTest : RobolectricTest() { activity.findViewById(R.id.password).setText("hunter2") activity.findViewById