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 01/60] 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 02/60] 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 03/60] 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 04/60] 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 05/60] 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 06/60] 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 07/60] 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 08/60] 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 09/60] 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 10/60] 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 11/60] 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 12/60] 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 13/60] 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 14/60] 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 15/60] 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 16/60] 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 17/60] 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 18/60] 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 19/60] 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 20/60] 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 21/60] 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 22/60] 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 23/60] 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 24/60] 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 25/60] 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 26/60] 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 27/60] 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 28/60] 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 29/60] 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 30/60] 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 31/60] 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 32/60] 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 33/60] 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 34/60] 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 35/60] 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 36/60] 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 37/60] 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 38/60] 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 39/60] 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 40/60] 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 41/60] 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 42/60] 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 43/60] 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 44/60] 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 45/60] 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 46/60] 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 47/60] 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 48/60] 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 49/60] 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 50/60] 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 51/60] 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 52/60] 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 53/60] 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 54/60] 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 55/60] 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 56/60] 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 57/60] 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 58/60] 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 59/60] 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 60/60] 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 {