From a645d5fc702182aeda433a0f50652c1961689094 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:41:55 +0100 Subject: [PATCH 01/43] refactor: extract IdsFile Part of 19572 Assisted-by: GPT-6 --- .../anki/browser/CardBrowserViewModel.kt | 69 ----------------- .../java/com/ichi2/anki/browser/IdsFile.kt | 76 +++++++++++++++++++ 2 files changed, 76 insertions(+), 69 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/browser/IdsFile.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt index e76394367369..64c688288e22 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/CardBrowserViewModel.kt @@ -3,7 +3,6 @@ package com.ichi2.anki.browser import android.os.Bundle -import android.os.Parcel import android.os.Parcelable import androidx.annotation.CheckResult import androidx.core.os.BundleCompat @@ -95,11 +94,7 @@ import kotlinx.parcelize.Parcelize import net.ankiweb.rsdroid.BackendException import org.jetbrains.annotations.VisibleForTesting import timber.log.Timber -import java.io.DataInputStream -import java.io.DataOutputStream import java.io.File -import java.io.FileInputStream -import java.io.FileOutputStream import java.util.Collections import kotlin.math.max import kotlin.math.min @@ -1744,70 +1739,6 @@ enum class CardBrowserProgress { DELETING_NOTES, } -/** - * Temporary file containing cards or note IDs to be passed in a Bundle. - * - * It avoids [android.os.TransactionTooLargeException] when passing a big amount of data. - */ -class IdsFile( - path: String, -) : File(path), - Parcelable { - /** - * @param directory parent directory of the file. Generally it should be the cache directory - * @param ids ids to store - */ - constructor(directory: File, ids: List, prefix: String = "ids") : this(path = createTempFile(prefix, ".tmp", directory).path) { - DataOutputStream(FileOutputStream(this)).use { outputStream -> - outputStream.writeInt(ids.size) - for (id in ids) { - outputStream.writeLong(id) - } - } - } - - fun getIds(): List = - DataInputStream(FileInputStream(this)).use { inputStream -> - val size = inputStream.readInt() - List(size) { inputStream.readLong() } - } - - override fun describeContents(): Int = 0 - - override fun writeToParcel( - dest: Parcel, - flags: Int, - ) { - dest.writeString(path) - } - - companion object { - @JvmField - @Suppress("unused") - val CREATOR = - object : Parcelable.Creator { - override fun createFromParcel(source: Parcel?): IdsFile = IdsFile(source!!.readString()!!) - - override fun newArray(size: Int): Array = arrayOf() - } - } -} - -/** Attempt to delete the associated [IdsFile] and logs the result */ -fun IdsFile.removeSafely(owner: String) { - runCatching { delete() } - .onFailure { throwable -> - Timber.w( - throwable, - "Exception when removing IdsFile of $owner", - ) - }.onSuccess { status -> - Timber.i( - "$owner associated IdsFile was deleted: $status", - ) - } -} - /** * Determines if a card can be repositioned. * diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/IdsFile.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/IdsFile.kt new file mode 100644 index 000000000000..5b3f90c879a4 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/IdsFile.kt @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.browser + +import android.os.Parcel +import android.os.Parcelable +import timber.log.Timber +import java.io.DataInputStream +import java.io.DataOutputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream + +/** + * Temporary file containing cards or note IDs to be passed in a Bundle. + * + * It avoids [android.os.TransactionTooLargeException] when passing a big amount of data. + */ +class IdsFile( + path: String, +) : File(path), + Parcelable { + /** + * @param directory parent directory of the file. Generally it should be the cache directory + * @param ids ids to store + */ + constructor(directory: File, ids: List, prefix: String = "ids") : this(path = createTempFile(prefix, ".tmp", directory).path) { + DataOutputStream(FileOutputStream(this)).use { outputStream -> + outputStream.writeInt(ids.size) + for (id in ids) { + outputStream.writeLong(id) + } + } + } + + fun getIds(): List = + DataInputStream(FileInputStream(this)).use { inputStream -> + val size = inputStream.readInt() + List(size) { inputStream.readLong() } + } + + override fun describeContents(): Int = 0 + + override fun writeToParcel( + dest: Parcel, + flags: Int, + ) { + dest.writeString(path) + } + + companion object { + @JvmField + @Suppress("unused") + val CREATOR = + object : Parcelable.Creator { + override fun createFromParcel(source: Parcel?): IdsFile = IdsFile(source!!.readString()!!) + + override fun newArray(size: Int): Array = arrayOf() + } + } +} + +/** Attempt to delete the associated [IdsFile] and logs the result */ +fun IdsFile.removeSafely(owner: String) { + runCatching { delete() } + .onFailure { throwable -> + Timber.w( + throwable, + "Exception when removing IdsFile of $owner", + ) + }.onSuccess { status -> + Timber.i( + "$owner associated IdsFile was deleted: $status", + ) + } +} From 60a89accdeba73d726c124879fe4aded5f3ccc74 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:40:52 +0100 Subject: [PATCH 02/43] fix(tests): use CommonString --- AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt index e1cb513f1e50..0bcb54b4b674 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt @@ -294,7 +294,7 @@ class DeckPickerTest : RobolectricTest() { .filterIsInstance() .single() .requireDialog() as AlertDialog - assertEquals(getString(R.string.delete_deck_title), dialog.title) + assertEquals(getString(CommonString.delete_deck_title), dialog.title) assertThat(dialog.message, containsString(deckName)) assertEquals(deckId, col.decks.byName(deckName)?.id) assertEquals(1, col.cardCount()) From 3e82d05abdaecdd983d7ea21bd1ce124deee99aa Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 30 Aug 2026 20:57:16 +0100 Subject: [PATCH 03/43] build(lint): enable `checkDependencies` This enables `UnusedResources` and `StringFormatMatches` to run cross-module, which will be necessary when our strings move to `:common:android` Part of 21500 Assisted-by: Claude Fable 5 --- AnkiDroid/build.gradle | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/AnkiDroid/build.gradle b/AnkiDroid/build.gradle index ca6cdea8e422..414b096c1b50 100644 --- a/AnkiDroid/build.gradle +++ b/AnkiDroid/build.gradle @@ -667,6 +667,15 @@ apply from: "./robolectricDownloader.gradle" apply from: "./jacoco.gradle.kts" apply from: "../lint.gradle" +android { + lint { + // Analyze project-local dependencies (:common:android etc.) as part of this + // module's lint run. + // Required for whole-app UnusedResources and StringFormatMatches coverage + checkDependencies = true + } +} + dependencies { configurations.configureEach { resolutionStrategy { From 11ae9b569de1610c29ea12b7898557a99c899b55 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Thu, 17 Sep 2026 23:26:49 +0100 Subject: [PATCH 04/43] fix(ci): retry emulator installation `sdkmanager` can fail with "Error on ZipFile unknown archive" before the emulator starts. Pre-install the emulator to work around this while upstream fixes the bug Fixes 21885 Related to https://redirect.github.com/ReactiveCircus/android-emulator-runner/issues/496 Assisted-by: GPT-6 --- .github/workflows/tests_emulator.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/tests_emulator.yml b/.github/workflows/tests_emulator.yml index 2f62d5f8de6e..714274db142c 100644 --- a/.github/workflows/tests_emulator.yml +++ b/.github/workflows/tests_emulator.yml @@ -161,6 +161,20 @@ jobs: max_attempts: 3 command: ./gradlew packagePlayRelease packagePlayReleaseAndroidTest --daemon + - name: Install Android Emulator + # Retry to work around https://github.com/ReactiveCircus/android-emulator-runner/issues/496 + uses: nick-fields/retry@ad984534de44a9489a53aefd81eb77f87c70dc60 # v4.0.0 + with: + timeout_minutes: 5 + retry_wait_seconds: 60 + max_attempts: 3 + command: '"$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --verbose --install emulator --channel=0' + # sdkmanager preserves failed downloads and installation state for resuming. + on_retry_command: >- + rm -rf "${ANDROID_HOME:?}/.temp" + "${ANDROID_HOME:?}/.downloadIntermediates" + "${ANDROID_HOME:?}/emulator/.installer" + - name: AVD Boot and Snapshot Creation # Only generate a snapshot for saving if we are on main branch with a cache miss # Comment the if out to generate snapshots on branch for performance testing From 19d528708b9496c8bb51b7e56c789733a439fd6a Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:38:07 +0100 Subject: [PATCH 05/43] fix(manage-tags): make search input independent of loading This allows searching when tags are loading, or with an empty collection. Part of 10397 Assisted-by: GPT-6 --- .../ichi2/anki/tags/ManageTagsViewModel.kt | 23 +++--- .../anki/tags/ManageTagsViewModelTest.kt | 73 ++++++++++++++++++- 2 files changed, 81 insertions(+), 15 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt index 46ba49b5e8c6..297e8c06cc40 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt @@ -40,6 +40,10 @@ class ManageTagsViewModel : ViewModel() { val state: StateFlow field = MutableStateFlow(ManageTagsState.Loading) + /** Search field contents, available even while tags are loading or failed to load. */ + val searchQuery: StateFlow + field = MutableStateFlow("") + private val _events = Channel(Channel.BUFFERED) val events = _events.receiveAsFlow() @@ -63,10 +67,9 @@ class ManageTagsViewModel : ViewModel() { * of having a filtered tag selected. */ fun filter(query: String) { - if (tagList.isEmpty()) return + searchQuery.value = query updateState { loaded -> loaded.copy( - searchQuery = query, visibleNodes = computeVisibleNodes(query), ) } @@ -109,7 +112,7 @@ class ManageTagsViewModel : ViewModel() { tagList.map { if (it.fullTag == tag) it.copy(collapsed = newCollapsed) else it } - updateState { it.copy(visibleNodes = computeVisibleNodes(it.searchQuery)) } + updateState { it.copy(visibleNodes = computeVisibleNodes(searchQuery.value)) } } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -151,13 +154,12 @@ class ManageTagsViewModel : ViewModel() { _events.send(DisplayMessage(ClearedUnusedTags(result.count))) } - private suspend fun loadTags(searchQuery: String) { + private suspend fun loadTags() { Timber.i("Loading tags from collection") tagList = flattenTree(withCol { tags.tree() }) state.value = ManageTagsState.Content( - visibleNodes = computeVisibleNodes(searchQuery), - searchQuery = searchQuery, + visibleNodes = computeVisibleNodes(searchQuery.value), ) } @@ -166,12 +168,11 @@ class ManageTagsViewModel : ViewModel() { * On failure, transitions to [Error] */ private fun launchTagOpAndRefresh(block: suspend () -> Unit = { }): Job { - val previousLoaded = state.value as? ManageTagsState.Content state.value = ManageTagsState.Loading return viewModelScope.launch { try { block() - loadTags(searchQuery = previousLoaded?.searchQuery ?: "") + loadTags() } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -195,10 +196,7 @@ class ManageTagsViewModel : ViewModel() { state.update { current -> when (current) { is ManageTagsState.Content -> transform(current) - else -> { - Timber.w("updateState called while in %s; ignoring", current::class.simpleName) - current - } + else -> current } } } @@ -319,7 +317,6 @@ sealed class ManageTagsState { data class Content( val visibleNodes: List, - val searchQuery: String = "", ) : ManageTagsState() data class Error( diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt index b3f1b947ddb5..032d80b954c7 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt @@ -7,7 +7,11 @@ import androidx.test.filters.MediumTest import com.ichi2.anki.RobolectricTest import com.ichi2.anki.dialogs.utils.AnKingTags import com.ichi2.anki.observability.ensureOpsExecuted +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.setMain import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.containsInAnyOrder import org.hamcrest.Matchers.equalTo @@ -76,7 +80,7 @@ class ManageTagsViewModelTest : RobolectricTest() { withViewModel { filter("sci") refreshTags() - assertThat(loadedState.searchQuery, equalTo("sci")) + assertThat(searchQuery.value, equalTo("sci")) assertThat(loadedState.visibleTagNames, equalTo(listOf("science"))) } } @@ -96,7 +100,72 @@ class ManageTagsViewModelTest : RobolectricTest() { withViewModel { filter("sci") assertThat(loadedState.visibleTagNames, equalTo(listOf("science"))) - assertThat(loadedState.searchQuery, equalTo("sci")) + assertThat(searchQuery.value, equalTo("sci")) + } + } + + @Test + fun `filter updates query in an empty collection`() = + runTest { + withViewModel { + filter("sci") + assertThat(searchQuery.value, equalTo("sci")) + assertThat(loadedState.visibleNodes, hasSize(0)) + + addTags("science", "history") + refreshTags() + assertThat(loadedState.visibleTagNames, equalTo(listOf("science"))) + } + } + + @Test + fun `filter during initial loading applies latest query when tags arrive`() = + runTest { + addTags("science", "history") + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + withViewModel { + assertThat(state.value, equalTo(ManageTagsState.Loading)) + filter("sci") + filter("hist") + assertThat(searchQuery.value, equalTo("hist")) + + runCurrent() + + assertThat(searchQuery.value, equalTo("hist")) + assertThat(loadedState.visibleTagNames, equalTo(listOf("history"))) + } + } + + @Test + fun `filter during refresh is not overwritten when refresh completes`() = + runTest { + addTags("science", "history") + Dispatchers.setMain(StandardTestDispatcher(testScheduler)) + withViewModel { + runCurrent() + filter("sci") + val refresh = refreshTags() + filter("hist") + assertThat(searchQuery.value, equalTo("hist")) + + refresh.join() + + assertThat(searchQuery.value, equalTo("hist")) + assertThat(loadedState.visibleTagNames, equalTo(listOf("history"))) + } + } + + @Test + fun `filter can be cleared after deleting the last tag`() = + runTest { + addTags("science") + withViewModel { + filter("sci") + removeTag("science") + filter("") + + assertThat(searchQuery.value, equalTo("")) + assertThat(loadedState.visibleNodes, hasSize(0)) } } From a7ea895b591edd6e8e05af40ac65837092eda090 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:53:25 +0100 Subject: [PATCH 06/43] refactor(manage-tags): combine tag operation handling In preparation for better handling of progress, errors and overlap protection. Part of 10397 Assisted-by: GPT-6 --- .../ichi2/anki/tags/ManageTagsViewModel.kt | 64 +++++++++---------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt index 297e8c06cc40..f83d8712eb3d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt @@ -98,27 +98,20 @@ class ManageTagsViewModel : ViewModel() { * @see Tags.setCollapsed */ fun toggleCollapsed(tag: TagName) = - viewModelScope.launch { - try { - val node = - findVisibleNode(tag) ?: run { - _events.send(DisplayMessage(UserMessage.UnexpectedError)) - return@launch - } - val newCollapsed = !node.collapsed - withCol { tags.setCollapsed(tag, newCollapsed) } - // Update the in-memory list with the new collapsed state - tagList = - tagList.map { - if (it.fullTag == tag) it.copy(collapsed = newCollapsed) else it - } - updateState { it.copy(visibleNodes = computeVisibleNodes(searchQuery.value)) } - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Timber.w(e, "Failed to toggle collapsed state") - state.value = Error(e) - } + launchTagOperation { + val node = + findVisibleNode(tag) ?: run { + _events.send(DisplayMessage(UserMessage.UnexpectedError)) + return@launchTagOperation + } + val newCollapsed = !node.collapsed + withCol { tags.setCollapsed(tag, newCollapsed) } + // Update the in-memory list with the new collapsed state + tagList = + tagList.map { + if (it.fullTag == tag) it.copy(collapsed = newCollapsed) else it + } + updateState { it.copy(visibleNodes = computeVisibleNodes(searchQuery.value)) } } /** @@ -165,20 +158,27 @@ class ManageTagsViewModel : ViewModel() { /** * Sets [ManageTagsState.Loading], runs [block], then [reloads][loadTags]. - * On failure, transitions to [Error] + * On failure, transitions to [Error]. */ private fun launchTagOpAndRefresh(block: suspend () -> Unit = { }): Job { state.value = ManageTagsState.Loading - return viewModelScope.launch { - try { - block() - loadTags() - } catch (e: CancellationException) { - throw e - } catch (e: Exception) { - Timber.w(e, "launchTagOperation failed") - state.value = Error(e) - } + return launchTagOperation { + block() + loadTags() + } + } + + private fun launchTagOperation(block: suspend () -> Unit): Job = viewModelScope.launch { runTagOperation(block) } + + /** Handles failures consistently for both tag mutations and expand/collapse operations. */ + private suspend fun runTagOperation(block: suspend () -> Unit) { + try { + block() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Timber.w(e, "Tag operation failed") + state.value = Error(e) } } From de648db899fe4d83e99dae2c7b406ff3df33276c Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:29:04 +0100 Subject: [PATCH 07/43] fix(manage-tags): prevent overlapping tag operations Stops an operation from clearing progress while another is running. This now ignores requests made while an operation is in progress (the UI should block operations when this occurs). * Remember the unfinished job * Check completion via 'isCompleted' * set the instance variable for the next job before running it * State is only modified for accepted requests Part of 10397 Assisted-by: GPT-6 --- .../ichi2/anki/tags/ManageTagsViewModel.kt | 25 +++++++++--- .../anki/tags/ManageTagsViewModelTest.kt | 39 +++++++++++++++++++ 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt index f83d8712eb3d..b6eef2e9c99d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt @@ -13,6 +13,7 @@ import com.ichi2.anki.tags.UserMessage.ClearedUnusedTags import com.ichi2.anki.tags.UserMessage.TagRemoved import com.ichi2.anki.tags.UserMessage.TagRenamed import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow @@ -50,6 +51,8 @@ class ManageTagsViewModel : ViewModel() { /** Cached flat list of all tags, rebuilt when the backend tree changes */ private var tagList: List = emptyList() + private var tagOperation: Job? = null + init { refreshTags() } @@ -160,15 +163,27 @@ class ManageTagsViewModel : ViewModel() { * Sets [ManageTagsState.Loading], runs [block], then [reloads][loadTags]. * On failure, transitions to [Error]. */ - private fun launchTagOpAndRefresh(block: suspend () -> Unit = { }): Job { - state.value = ManageTagsState.Loading - return launchTagOperation { + private fun launchTagOpAndRefresh(block: suspend () -> Unit = { }): Job = + launchTagOperation { + state.value = ManageTagsState.Loading block() loadTags() } - } - private fun launchTagOperation(block: suspend () -> Unit): Job = viewModelScope.launch { runTagOperation(block) } + /** Starts an operation, or returns the unfinished operation's job without starting another. */ + private fun launchTagOperation(block: suspend () -> Unit): Job { + val currentOperation = tagOperation + if (currentOperation != null && !currentOperation.isCompleted) { + return currentOperation + } + + // set 'tagOperation' before executing + val operation = viewModelScope.launch(start = CoroutineStart.LAZY) { runTagOperation(block) } + tagOperation = operation + // calls from observers will now find this job + operation.start() + return operation + } /** Handles failures consistently for both tag mutations and expand/collapse operations. */ private suspend fun runTagOperation(block: suspend () -> Unit) { diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt index 032d80b954c7..c14ea09b8c3e 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt @@ -4,12 +4,14 @@ package com.ichi2.anki.tags import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest +import com.ichi2.anki.CollectionManager import com.ichi2.anki.RobolectricTest import com.ichi2.anki.dialogs.utils.AnKingTags import com.ichi2.anki.observability.ensureOpsExecuted import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.setMain import org.hamcrest.MatcherAssert.assertThat @@ -18,6 +20,7 @@ import org.hamcrest.Matchers.equalTo import org.hamcrest.Matchers.hasItem import org.hamcrest.Matchers.hasSize import org.hamcrest.Matchers.not +import org.hamcrest.Matchers.sameInstance import org.junit.Test import org.junit.runner.RunWith import kotlin.system.measureTimeMillis @@ -85,6 +88,32 @@ class ManageTagsViewModelTest : RobolectricTest() { } } + @Test + fun `operations requested while busy do not mutate tags`() = + runTest { + addTags("science::biology", "history") + addUnusedTag("unused") + withViewModel { + withQueuedCollectionAccess { + val refresh = refreshTags() + assertThat(refresh.isCompleted, equalTo(false)) + + assertThat(removeTag("science"), sameInstance(refresh)) + assertThat(renameTag("history", "past"), sameInstance(refresh)) + assertThat(clearUnusedTags(), sameInstance(refresh)) + assertThat(toggleCollapsed("science"), sameInstance(refresh)) + assertThat(refreshTags(), sameInstance(refresh)) + + refresh.join() + } + assertThat(loadedState.visibleTagNames, containsInAnyOrder("science", "history", "unused")) + assertThat(loadedState.visibleNodes.single { it.fullTagName == "science" }.collapsed, equalTo(true)) + + removeTag("science").join() + assertThat(loadedState.visibleTagNames, containsInAnyOrder("history", "unused")) + } + } + @Test fun `initial state is Loaded after construction`() = runTest { @@ -418,6 +447,16 @@ class ManageTagsViewModelTest : RobolectricTest() { } } + /** Suspend backend access so assertions can observe an operation in progress. */ + private suspend fun TestScope.withQueuedCollectionAccess(block: suspend () -> Unit) { + val previousQueue = CollectionManager.setTestDispatcher(StandardTestDispatcher(testScheduler), useReentrantLock = false) + try { + block() + } finally { + CollectionManager.setTestDispatcher(previousQueue) + } + } + private suspend fun withViewModel(block: suspend ManageTagsViewModel.() -> Unit) = ManageTagsViewModel().block() /** Helper abstracting [com.ichi2.anki.libanki.Tags.all] */ From 0cbd254794f4e20d32188ae895d94c5c750b9b55 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:30:02 +0100 Subject: [PATCH 08/43] fix(manage-tags): keep content visible while operations run e.g. a tag rename Part of 10397 Assisted-by: GPT-6 --- .../ichi2/anki/tags/ManageTagsViewModel.kt | 19 +++-- .../anki/tags/ManageTagsViewModelTest.kt | 69 +++++++++++++++++++ 2 files changed, 84 insertions(+), 4 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt index b6eef2e9c99d..29cd5ea0fb63 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt @@ -160,12 +160,10 @@ class ManageTagsViewModel : ViewModel() { } /** - * Sets [ManageTagsState.Loading], runs [block], then [reloads][loadTags]. - * On failure, transitions to [Error]. + * Runs [block], then [reloads][loadTags], keeping any existing content visible. */ private fun launchTagOpAndRefresh(block: suspend () -> Unit = { }): Job = launchTagOperation { - state.value = ManageTagsState.Loading block() loadTags() } @@ -185,8 +183,17 @@ class ManageTagsViewModel : ViewModel() { return operation } - /** Handles failures consistently for both tag mutations and expand/collapse operations. */ + /** + * Keeps existing content visible while an operation runs, clearing progress on completion. + * Uses [ManageTagsState.Loading] when no content is available. Failures transition to [Error]. + */ private suspend fun runTagOperation(block: suspend () -> Unit) { + state.update { current -> + when (current) { + is ManageTagsState.Content -> current.copy(isWorking = true) + is ManageTagsState.Loading, is Error -> ManageTagsState.Loading + } + } try { block() } catch (e: CancellationException) { @@ -194,6 +201,8 @@ class ManageTagsViewModel : ViewModel() { } catch (e: Exception) { Timber.w(e, "Tag operation failed") state.value = Error(e) + } finally { + updateState { it.copy(isWorking = false) } } } @@ -332,6 +341,8 @@ sealed class ManageTagsState { data class Content( val visibleNodes: List, + /** `true` while a tag operation runs, allowing the UI to show progress without hiding the list. */ + val isWorking: Boolean = false, ) : ManageTagsState() data class Error( diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt index c14ea09b8c3e..382a57a11d9c 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt @@ -9,6 +9,8 @@ import com.ichi2.anki.RobolectricTest import com.ichi2.anki.dialogs.utils.AnKingTags import com.ichi2.anki.observability.ensureOpsExecuted import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope @@ -88,6 +90,34 @@ class ManageTagsViewModelTest : RobolectricTest() { } } + @Test + fun `operations keep existing content visible until completion`() = + runTest { + addTags("science::biology", "history") + addUnusedTag("unused") + withViewModel { + val operations: List Job> = + listOf( + { refreshTags() }, + { toggleCollapsed("science") }, + { renameTag("science", "physics") }, + { clearUnusedTags() }, + { removeTag("physics") }, + ) + for (operation in operations) { + val previous = loadedState + withQueuedCollectionAccess { + val job = operation() + assertThat(loadedState, equalTo(previous.copy(isWorking = true))) + + job.join() + + assertThat(loadedState.isWorking, equalTo(false)) + } + } + } + } + @Test fun `operations requested while busy do not mutate tags`() = runTest { @@ -114,6 +144,45 @@ class ManageTagsViewModelTest : RobolectricTest() { } } + @Test + fun `search updates visible content while refresh is running`() = + runTest { + addTags("science", "history") + withViewModel { + withQueuedCollectionAccess { + val refresh = refreshTags() + filter("hist") + + assertThat(loadedState.isWorking, equalTo(true)) + assertThat(loadedState.visibleTagNames, equalTo(listOf("history"))) + + refresh.join() + assertThat(loadedState.visibleTagNames, equalTo(listOf("history"))) + } + } + } + + @Test + fun `cancelled operation clears progress and permits another operation`() = + runTest { + addTags("science", "history") + withViewModel { + val previous = loadedState + withQueuedCollectionAccess { + val deletion = removeTag("science") + assertThat(loadedState.isWorking, equalTo(true)) + + deletion.cancelAndJoin() + + assertThat(loadedState, equalTo(previous)) + val refresh = refreshTags() + assertThat(loadedState.isWorking, equalTo(true)) + refresh.join() + assertThat(loadedState, equalTo(previous)) + } + } + } + @Test fun `initial state is Loaded after construction`() = runTest { From f080a7bb9e618b918abb152a80846ee64d2eefcc Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:31:09 +0100 Subject: [PATCH 09/43] fix(manage-tags): keep content visible after operation failures Part of 10397 Assisted-by: GPT-6 --- .../ichi2/anki/tags/ManageTagsViewModel.kt | 15 ++- .../anki/tags/ManageTagsViewModelTest.kt | 93 ++++++++++++++----- 2 files changed, 82 insertions(+), 26 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt index 29cd5ea0fb63..b4994d32b568 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt @@ -184,13 +184,13 @@ class ManageTagsViewModel : ViewModel() { } /** - * Keeps existing content visible while an operation runs, clearing progress on completion. - * Uses [ManageTagsState.Loading] when no content is available. Failures transition to [Error]. + * Updates progress and errors while keeping existing content visible. + * Uses [ManageTagsState.Loading] and [Error] only when no content is available. */ private suspend fun runTagOperation(block: suspend () -> Unit) { state.update { current -> when (current) { - is ManageTagsState.Content -> current.copy(isWorking = true) + is ManageTagsState.Content -> current.copy(isWorking = true, error = null) is ManageTagsState.Loading, is Error -> ManageTagsState.Loading } } @@ -200,7 +200,12 @@ class ManageTagsViewModel : ViewModel() { throw e } catch (e: Exception) { Timber.w(e, "Tag operation failed") - state.value = Error(e) + state.update { current -> + when (current) { + is ManageTagsState.Content -> current.copy(isWorking = false, error = e) + is ManageTagsState.Loading, is Error -> Error(e) + } + } } finally { updateState { it.copy(isWorking = false) } } @@ -343,6 +348,8 @@ sealed class ManageTagsState { val visibleNodes: List, /** `true` while a tag operation runs, allowing the UI to show progress without hiding the list. */ val isWorking: Boolean = false, + /** Most recent operation failure, cleared when the next operation starts. */ + val error: Throwable? = null, ) : ManageTagsState() data class Error( diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt index 382a57a11d9c..b9547100d217 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt @@ -9,19 +9,21 @@ import com.ichi2.anki.RobolectricTest import com.ichi2.anki.dialogs.utils.AnKingTags import com.ichi2.anki.observability.ensureOpsExecuted import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.setMain +import net.ankiweb.rsdroid.BackendException.BackendDbException.BackendDbLockedException import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.containsInAnyOrder import org.hamcrest.Matchers.equalTo import org.hamcrest.Matchers.hasItem import org.hamcrest.Matchers.hasSize +import org.hamcrest.Matchers.instanceOf import org.hamcrest.Matchers.not +import org.hamcrest.Matchers.nullValue import org.hamcrest.Matchers.sameInstance import org.junit.Test import org.junit.runner.RunWith @@ -91,29 +93,21 @@ class ManageTagsViewModelTest : RobolectricTest() { } @Test - fun `operations keep existing content visible until completion`() = + fun `removeTag keeps existing content visible until completion`() = runTest { - addTags("science::biology", "history") - addUnusedTag("unused") + addTags("science", "history") withViewModel { - val operations: List Job> = - listOf( - { refreshTags() }, - { toggleCollapsed("science") }, - { renameTag("science", "physics") }, - { clearUnusedTags() }, - { removeTag("physics") }, - ) - for (operation in operations) { - val previous = loadedState - withQueuedCollectionAccess { - val job = operation() - assertThat(loadedState, equalTo(previous.copy(isWorking = true))) - - job.join() - - assertThat(loadedState.isWorking, equalTo(false)) - } + val previousTags = loadedState.visibleNodes + withQueuedCollectionAccess { + val deletion = removeTag("science") + assertThat(loadedState.isWorking, equalTo(true)) + assertThat(loadedState.visibleNodes, equalTo(previousTags)) + + deletion.join() + + assertThat(loadedState.isWorking, equalTo(false)) + assertThat(loadedState.error, nullValue()) + assertThat(loadedState.visibleTagNames, equalTo(listOf("history"))) } } } @@ -162,6 +156,52 @@ class ManageTagsViewModelTest : RobolectricTest() { } } + @Test + fun `operation failure preserves content and retry clears error`() = + runTest { + addTags("science", "history") + withViewModel { + val previousTags = loadedState.visibleNodes + withLockedCollection { + removeTag("science").join() + } + + assertThat(loadedState.visibleNodes, equalTo(previousTags)) + assertThat(loadedState.isWorking, equalTo(false)) + assertThat(loadedState.error, instanceOf(BackendDbLockedException::class.java)) + + withQueuedCollectionAccess { + val retry = removeTag("science") + assertThat(loadedState.isWorking, equalTo(true)) + assertThat(loadedState.error, nullValue()) + + retry.join() + assertThat(loadedState.isWorking, equalTo(false)) + assertThat(loadedState.visibleTagNames, equalTo(listOf("history"))) + } + } + } + + @Test + fun `initial load failure allows search input and retry`() = + runTest { + addTags("science", "history") + val viewModel = withLockedCollection { ManageTagsViewModel() } + val failure = viewModel.state.value + assertThat(failure, instanceOf(ManageTagsState.Error::class.java)) + assertThat((failure as ManageTagsState.Error).error, instanceOf(BackendDbLockedException::class.java)) + + viewModel.filter("hist") + assertThat(viewModel.searchQuery.value, equalTo("hist")) + + withQueuedCollectionAccess { + val retry = viewModel.refreshTags() + assertThat(viewModel.state.value, equalTo(ManageTagsState.Loading)) + retry.join() + } + assertThat(viewModel.loadedState.visibleTagNames, equalTo(listOf("history"))) + } + @Test fun `cancelled operation clears progress and permits another operation`() = runTest { @@ -516,6 +556,15 @@ class ManageTagsViewModelTest : RobolectricTest() { } } + private suspend fun withLockedCollection(block: suspend () -> T): T { + CollectionManager.emulatedOpenFailure = CollectionManager.CollectionOpenFailure.LOCKED + try { + return block() + } finally { + CollectionManager.emulatedOpenFailure = null + } + } + /** Suspend backend access so assertions can observe an operation in progress. */ private suspend fun TestScope.withQueuedCollectionAccess(block: suspend () -> Unit) { val previousQueue = CollectionManager.setTestDispatcher(StandardTestDispatcher(testScheduler), useReentrantLock = false) From eed0d718cd0a67fbf6228362ae1af2af070ea78a Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:45:04 +0100 Subject: [PATCH 10/43] feat(manage-tags): retain messages until acknowledged A screen rotation could hide a snackbar before a user had time to see it Part of 10397 Assisted-by: GPT-6 --- .../ichi2/anki/tags/ManageTagsViewModel.kt | 29 +++---- .../java/com/ichi2/anki/utils/MessageQueue.kt | 49 ++++++++++++ .../anki/tags/ManageTagsViewModelTest.kt | 57 ++++++++++---- .../com/ichi2/anki/utils/MessageQueueTest.kt | 78 +++++++++++++++++++ 4 files changed, 183 insertions(+), 30 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/utils/MessageQueue.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/utils/MessageQueueTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt index b4994d32b568..31d528a4b9a4 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/tags/ManageTagsViewModel.kt @@ -12,13 +12,13 @@ import com.ichi2.anki.tags.ManageTagsState.Error import com.ichi2.anki.tags.UserMessage.ClearedUnusedTags import com.ichi2.anki.tags.UserMessage.TagRemoved import com.ichi2.anki.tags.UserMessage.TagRenamed +import com.ichi2.anki.utils.MessageQueue +import com.ichi2.anki.utils.MessageQueue.MessageId import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Job -import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import timber.log.Timber @@ -35,7 +35,7 @@ import anki.tags.TagTreeNode as BackendTagTreeNode * @see Tags for backend functions. * @see TagListItemState for display. * @see ManageTagsState for UI state. - * @see events for one-shot events. + * @see pendingMessages for messages awaiting display. */ class ManageTagsViewModel : ViewModel() { val state: StateFlow @@ -45,8 +45,10 @@ class ManageTagsViewModel : ViewModel() { val searchQuery: StateFlow field = MutableStateFlow("") - private val _events = Channel(Channel.BUFFERED) - val events = _events.receiveAsFlow() + private val messageQueue = MessageQueue() + + /** Messages in display order. Collecting does not consume them; acknowledge with [messageShown]. */ + val pendingMessages = messageQueue.messages /** Cached flat list of all tags, rebuilt when the backend tree changes */ private var tagList: List = emptyList() @@ -78,6 +80,9 @@ class ManageTagsViewModel : ViewModel() { } } + /** Acknowledges that the UI has finished displaying a message. */ + fun messageShown(id: MessageId) = messageQueue.acknowledge(id) + /** Returns the visible node for [tag], or null with a warning if not found or not loaded */ private fun findVisibleNode(tag: TagName): TagListItemState? { val loaded = state.value as? ManageTagsState.Content @@ -104,7 +109,7 @@ class ManageTagsViewModel : ViewModel() { launchTagOperation { val node = findVisibleNode(tag) ?: run { - _events.send(DisplayMessage(UserMessage.UnexpectedError)) + messageQueue.enqueue(UserMessage.UnexpectedError) return@launchTagOperation } val newCollapsed = !node.collapsed @@ -124,7 +129,7 @@ class ManageTagsViewModel : ViewModel() { fun removeTag(tag: TagName) = launchTagOpAndRefresh { val result = undoableOp { tags.remove(tag) } - _events.send(DisplayMessage(TagRemoved(result.count))) + messageQueue.enqueue(TagRemoved(result.count)) } /** @@ -136,18 +141,18 @@ class ManageTagsViewModel : ViewModel() { newName: TagName, ) = launchTagOpAndRefresh { val result = undoableOp { tags.rename(oldName, newName) } - _events.send(DisplayMessage(TagRenamed(result.count))) + messageQueue.enqueue(TagRenamed(result.count)) } /** - * Removes tags not present on any note. Emits a [ClearedUnusedTags] event with the count. + * Removes tags not present on any note. Queues a [ClearedUnusedTags] message with the count. * @see Tags.clearUnusedTags */ fun clearUnusedTags() = launchTagOpAndRefresh { val result = undoableOp { tags.clearUnusedTags() } Timber.i("Deleted %d unused tags", result.count) - _events.send(DisplayMessage(ClearedUnusedTags(result.count))) + messageQueue.enqueue(ClearedUnusedTags(result.count)) } private suspend fun loadTags() { @@ -357,10 +362,6 @@ sealed class ManageTagsState { ) : ManageTagsState() } -data class DisplayMessage( - val message: UserMessage, -) - sealed interface UserMessage { /** @param notesAffected number of notes the tag was removed from */ data class TagRemoved( diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/MessageQueue.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/MessageQueue.kt new file mode 100644 index 000000000000..b93b0db1d074 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/MessageQueue.kt @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.utils + +import androidx.annotation.MainThread +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update + +/** + * Keeps ViewModel messages pending until the UI has finished displaying them. + * + * e.g. Rotating the screen can remove a snackbar immediately after it's displayed. + * + * A UI should display [messages] in enqueue order, then call [acknowledge] after each message is handled. + * + * @see [Android UI event guidance](https://developer.android.com/topic/architecture/ui-layer/events#consuming-events) + */ +class MessageQueue { + val messages: StateFlow>> + field = MutableStateFlow>>(emptyList()) + + private var nextMessageId = MessageId(0) + + @MainThread + fun enqueue(message: T) { + val pending = Message(id = nextMessageId++, message = message) + messages.update { it + pending } + } + + /** Removes only the acknowledged message. Repeated or stale acknowledgements are harmless. */ + @MainThread + fun acknowledge(id: MessageId) { + messages.update { messages -> messages.filterNot { it.id == id } } + } + + /** [id] distinguishes messages with identical contents within this queue. */ + data class Message( + val id: MessageId, + val message: T, + ) + + @JvmInline + value class MessageId( + val value: Int, + ) { + operator fun inc() = MessageId(value + 1) + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt index b9547100d217..bbcc1c86f0d9 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt @@ -10,7 +10,6 @@ import com.ichi2.anki.dialogs.utils.AnKingTags import com.ichi2.anki.observability.ensureOpsExecuted import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent @@ -375,13 +374,12 @@ class ManageTagsViewModelTest : RobolectricTest() { } @Test - fun `toggleCollapsed on unknown tag sends UnexpectedError`() = + fun `toggleCollapsed on unknown tag queues UnexpectedError`() = runTest { addTags("science") withViewModel { toggleCollapsed("nonexistent") - val event = events.first() - assertIs(event.message) + assertThat(pendingMessages.value.single().message, equalTo(UserMessage.UnexpectedError)) } } @@ -453,43 +451,70 @@ class ManageTagsViewModelTest : RobolectricTest() { } @Test - fun `clearUnusedTags sends event with count`() = + fun `clearUnusedTags queues message with count`() = runTest { addTags("used") addUnusedTag("unused") withViewModel { clearUnusedTags() - val event = events.first() - val message = assertIs(event.message) - assertThat(message.count, equalTo(1)) + assertThat(pendingMessages.value.single().message, equalTo(UserMessage.ClearedUnusedTags(1))) } } @Test - fun `removeTag sends event with affected note count`() = + fun `removeTag queues message with affected note count`() = runTest { // add 'science to 2 notes - ensure that the return value is the notes affected addTags("science") addTags("science") withViewModel { removeTag("science") - val event = events.first() - val message = assertIs(event.message) - assertThat(message.notesAffected, equalTo(2)) + assertThat(pendingMessages.value.single().message, equalTo(UserMessage.TagRemoved(2))) } } @Test - fun `renameTag sends event with affected note count`() = + fun `renameTag queues message with affected note count`() = runTest { // add 'science to 2 notes - ensure that the return value is the notes affected addTags("science") addTags("science") withViewModel { renameTag("science", "physics") - val event = events.first() - val message = assertIs(event.message) - assertThat(message.notesAffected, equalTo(2)) + assertThat(pendingMessages.value.single().message, equalTo(UserMessage.TagRenamed(2))) + } + } + + @Test + fun `messageShown removes the displayed message`() = + runTest { + addTags("science") + withViewModel { + removeTag("science").join() + + messageShown(pendingMessages.value.single().id) + + assertThat(pendingMessages.value, hasSize(0)) + } + } + + @Test + fun `pending messages survive refresh failures and retries`() = + runTest { + addTags("science", "history") + withViewModel { + removeTag("science").join() + val pending = pendingMessages.value + withLockedCollection { + refreshTags().join() + } + assertThat(loadedState.error, instanceOf(BackendDbLockedException::class.java)) + assertThat(pendingMessages.value, equalTo(pending)) + + refreshTags().join() + filter("hist") + assertThat(loadedState.error, nullValue()) + assertThat(pendingMessages.value, equalTo(pending)) } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/utils/MessageQueueTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/utils/MessageQueueTest.kt new file mode 100644 index 000000000000..0b99e1982bc7 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/utils/MessageQueueTest.kt @@ -0,0 +1,78 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.utils + +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.contains +import org.hamcrest.Matchers.equalTo +import org.hamcrest.Matchers.hasSize +import org.hamcrest.Matchers.not +import org.junit.Test + +class MessageQueueTest { + private val queue = MessageQueue() + + @Test + fun `messages remain pending when collectors restart until acknowledged`() = + runTest { + queue.enqueue("message") + + // Each first() starts and stops a collector, as when the UI is recreated. + val firstCollection = queue.messages.first() + assertThat(firstCollection.single().message, equalTo("message")) + assertThat(queue.messages.first(), equalTo(firstCollection)) + + queue.acknowledge(firstCollection.single().id) + + assertThat(queue.messages.first(), hasSize(0)) + } + + @Test + fun `acknowledgements distinguish identical messages`() { + queue.enqueue("message") + queue.enqueue("message") + val (first, second) = queue.messages.value + assertThat(second.message, equalTo(first.message)) + assertThat(second.id, not(equalTo(first.id))) + + queue.acknowledge(first.id) + queue.acknowledge(first.id) + + assertThat(queue.messages.value, contains(second)) + } + + @Test + fun `acknowledgements for old IDs do not remove newer messages`() { + queue.enqueue("message") + val first = queue.messages.value.single() + queue.acknowledge(first.id) + queue.enqueue("message") + val second = queue.messages.value.single() + assertThat(second.id, not(equalTo(first.id))) + + queue.acknowledge(first.id) + + assertThat(queue.messages.value, contains(second)) + } + + @Test + fun `messages queue in enqueue order without a collector`() { + queue.enqueue("first") + queue.enqueue("second") + queue.enqueue("third") + + assertThat(queue.messages.value.map { it.message }, contains("first", "second", "third")) + } + + @Test + fun `many pending messages do not block enqueueing`() { + repeat(100) { queue.enqueue("message") } + queue.enqueue("last") + + val messages = queue.messages.value + assertThat(messages, hasSize(101)) + assertThat(messages.last().message, equalTo("last")) + } +} From 8c9be9bca5b1075ab0246d5e2952faa5d12eff51 Mon Sep 17 00:00:00 2001 From: Mike Hardy Date: Tue, 29 Sep 2026 14:52:21 -0500 Subject: [PATCH 11/43] fix(build): narrow JaCoCo execution data file trees Gradle task validation failed when lint and jacocoUnitTestReport ran in one invocation because report tasks scanned all of build/ for coverage. --- AnkiDroid/jacoco.gradle.kts | 47 ++++++++++++++++++------------------- 1 file changed, 23 insertions(+), 24 deletions(-) diff --git a/AnkiDroid/jacoco.gradle.kts b/AnkiDroid/jacoco.gradle.kts index 9f5fec6b4610..24bcd95e4978 100644 --- a/AnkiDroid/jacoco.gradle.kts +++ b/AnkiDroid/jacoco.gradle.kts @@ -89,6 +89,25 @@ fun builtInKotlincClassDir(variant: String): String { val flavorClassDir = builtInKotlincClassDir("play${rootProject.androidTestVariantName}") val moduleClassDir = builtInKotlincClassDir(rootProject.androidTestVariantName.lowercase()) +// Narrow coverage roots: a fileTree over the whole build/ dir makes Gradle treat every +// sibling task output under build/ as an undeclared input (e.g. lintFullDebug + this report). +val unitTestCoverageDirs = listOf("outputs/unit_test_code_coverage", "jacoco") +val androidTestCoverageDirs = listOf("outputs/code_coverage") + +fun Project.coverageFileTree( + relativeDirs: List, + includes: List, +) = files( + relativeDirs.map { relativeDir -> + fileTree( + mapOf( + "dir" to layout.buildDirectory.dir(relativeDir), + "includes" to includes, + ), + ) + }, +) + // Our merge report task tasks.register("jacocoTestReport") { val htmlOutDir = @@ -120,12 +139,8 @@ tasks.register("jacocoTestReport") { sourceDirectories.setFrom(project.files(mainSrc)) classDirectories.setFrom(project.files(kotlinClasses)) executionData.setFrom( - project.fileTree( - mapOf( - "dir" to project.layout.buildDirectory, - "includes" to listOf("**/*.exec", "**/*.ec"), - ), - ), + project.coverageFileTree(unitTestCoverageDirs, listOf("**/*.exec")), + project.coverageFileTree(androidTestCoverageDirs, listOf("**/*.ec")), ) dependsOn("testPlayDebugUnitTest") dependsOn("connectedPlay${rootProject.androidTestVariantName}AndroidTest") @@ -208,16 +223,7 @@ fun includeUnitTestCoverage( ), ), ) - report.executionData.from( - project.files( - project.fileTree( - mapOf( - "dir" to project.layout.buildDirectory, - "includes" to listOf("**/*.exec"), - ), - ), - ), - ) + report.executionData.from(project.coverageFileTree(unitTestCoverageDirs, listOf("**/*.exec"))) } // A connected android tests only report task @@ -250,14 +256,7 @@ tasks.register("jacocoAndroidTestReport") { sourceDirectories.setFrom(project.files(mainSrc)) classDirectories.setFrom(project.files(kotlinClasses)) - executionData.setFrom( - project.fileTree( - mapOf( - "dir" to project.layout.buildDirectory, - "includes" to listOf("**/*.ec"), - ), - ), - ) + executionData.setFrom(project.coverageFileTree(androidTestCoverageDirs, listOf("**/*.ec"))) dependsOn("connectedPlay${rootProject.androidTestVariantName}AndroidTest") } From ebeb8b18c1c275dbe4b6b53bb4c660aa3c9c2eea Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 29 Sep 2026 20:11:13 +0100 Subject: [PATCH 12/43] refactor(tests): use [positive/negative]Button extensions Assisted-by: GPT-6 --- .../com/ichi2/anki/CardTemplateEditorTest.kt | 33 ++++++++++--------- .../java/com/ichi2/anki/RobolectricTest.kt | 9 ++--- .../anki/dialogs/utils/AlertDialogUtils.kt | 4 +-- .../ControlsSettingsFragmentTest.kt | 7 ++-- .../anki/previewer/CardViewerFragmentTest.kt | 7 ++-- 5 files changed, 31 insertions(+), 29 deletions(-) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CardTemplateEditorTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CardTemplateEditorTest.kt index c47a942a5ce6..ab14fc41ff5d 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CardTemplateEditorTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CardTemplateEditorTest.kt @@ -4,7 +4,6 @@ package com.ichi2.anki import android.app.Activity -import android.content.DialogInterface import android.content.Intent import android.os.Bundle import android.os.Looper @@ -29,6 +28,8 @@ import com.ichi2.testutils.assertFalse import com.ichi2.testutils.dispatchInsets import com.ichi2.testutils.withSplitPaneUi import com.ichi2.utils.dp +import com.ichi2.utils.negativeButton +import com.ichi2.utils.positiveButton import org.hamcrest.CoreMatchers.equalTo import org.hamcrest.MatcherAssert import org.hamcrest.MatcherAssert.assertThat @@ -133,14 +134,14 @@ class CardTemplateEditorTest : RobolectricTest() { assertTrue("Unable to click?", shadowTestEditor.clickMenuItem(android.R.id.home)) advanceRobolectricLooper() assertEquals("Wrong dialog shown?", getAlertDialogText(true), "Discard changes?") - clickAlertDialogButton(DialogInterface.BUTTON_NEGATIVE, false) + clickAlertDialogButton(checkDismissed = false) { negativeButton } advanceRobolectricLooper() assertTrue("note type change not preserved despite canceling back button?", testEditor.noteTypeHasChanged()) // Make sure we things are cleared out after a cancel assertTrue("Unable to click?", shadowTestEditor.clickMenuItem(android.R.id.home)) assertEquals("Wrong dialog shown?", getAlertDialogText(true), "Discard changes?") - clickAlertDialogButton(DialogInterface.BUTTON_POSITIVE, false) + clickAlertDialogButton(checkDismissed = false) { positiveButton } advanceRobolectricLooper() assertFalse("note type change not cleared despite discarding changes?", testEditor.noteTypeHasChanged()) @@ -215,7 +216,7 @@ class CardTemplateEditorTest : RobolectricTest() { assertTrue("Unable to click?", shadowTestEditor.clickMenuItem(R.id.action_delete)) advanceRobolectricLooper() assertEquals("Wrong dialog shown?", "Delete the “Card 1” card type, and its 0 cards?", getAlertDialogText(true)) - clickAlertDialogButton(DialogInterface.BUTTON_POSITIVE, true) + clickAlertDialogButton { positiveButton } advanceRobolectricLooper() assertTrue("Note type should have changed", testEditor.noteTypeHasChanged()) assertEquals("Note type should have 1 template now", 1, testEditor.tempNoteType?.templateCount) @@ -353,7 +354,7 @@ class CardTemplateEditorTest : RobolectricTest() { assertTrue("Unable to click?", shadowTestEditor.clickMenuItem(R.id.action_delete)) advanceRobolectricLooper() assertEquals("Wrong dialog shown?", "Delete the “Card 1” card type, and its 0 cards?", getAlertDialogText(true)) - clickAlertDialogButton(DialogInterface.BUTTON_NEGATIVE, true) + clickAlertDialogButton { negativeButton } advanceRobolectricLooper() assertFalse("Note type should not have changed", testEditor.noteTypeHasChanged()) @@ -376,7 +377,7 @@ class CardTemplateEditorTest : RobolectricTest() { getResourceString(CommonString.orphan_note_message), getAlertDialogText(true), ) - clickAlertDialogButton(DialogInterface.BUTTON_POSITIVE, true) + clickAlertDialogButton { positiveButton } advanceRobolectricLooper() assertNull( "Can delete used template?", @@ -462,7 +463,7 @@ class CardTemplateEditorTest : RobolectricTest() { getQuantityString(CommonPlurals.card_template_editor_confirm_delete, 1, 1, "Card 1"), getAlertDialogText(true), ) - clickAlertDialogButton(DialogInterface.BUTTON_NEGATIVE, true) + clickAlertDialogButton { negativeButton } advanceRobolectricLooper() assertNotNull("Cannot delete template?", collectionBasicNoteTypeOriginal.getCardIds(0)) assertNotNull("Cannot delete template?", collectionBasicNoteTypeOriginal.getCardIds(1)) @@ -542,7 +543,7 @@ class CardTemplateEditorTest : RobolectricTest() { getQuantityString(CommonPlurals.card_template_editor_confirm_delete, 1, 1, "Card 1"), getAlertDialogText(true), ) - clickAlertDialogButton(DialogInterface.BUTTON_POSITIVE, true) + clickAlertDialogButton { positiveButton } advanceRobolectricLooper() advanceRobolectricLooper() testEditor.mainBinding.cardTemplateEditorPager.currentItem = 0 @@ -553,7 +554,7 @@ class CardTemplateEditorTest : RobolectricTest() { getQuantityString(CommonPlurals.card_template_editor_confirm_delete, 1, 1, "Card 2"), getAlertDialogText(true), ) - clickAlertDialogButton(DialogInterface.BUTTON_POSITIVE, true) + clickAlertDialogButton { positiveButton } advanceRobolectricLooper() // - assert can delete any 1 or 2 Card templates but not all @@ -644,7 +645,7 @@ class CardTemplateEditorTest : RobolectricTest() { getQuantityString(CommonPlurals.card_template_editor_confirm_delete, 1, 1, "Card 2"), getAlertDialogText(true), ) - clickAlertDialogButton(DialogInterface.BUTTON_POSITIVE, true) + clickAlertDialogButton { positiveButton } advanceRobolectricLooper() assertTrue("Note type should have changed", testEditor.noteTypeHasChanged()) assertNotNull("Cannot delete template?", collectionBasicNoteTypeOriginal.getCardIds(0)) @@ -678,7 +679,7 @@ class CardTemplateEditorTest : RobolectricTest() { getQuantityString(CommonPlurals.card_template_editor_confirm_delete, 0, 0, CollectionManager.TR.cardTemplatesCard(2)), getAlertDialogText(true), ) - clickAlertDialogButton(DialogInterface.BUTTON_POSITIVE, true) + clickAlertDialogButton { positiveButton } advanceRobolectricLooper() assertTrue("Note type should have changed", testEditor.noteTypeHasChanged()) assertNotNull("Cannot delete template?", collectionBasicNoteTypeOriginal.getCardIds(0)) @@ -760,7 +761,7 @@ class CardTemplateEditorTest : RobolectricTest() { assertEquals("Back should confirm discarding edits", "Discard changes?", getAlertDialogText(true)) assertFalse("Edits must not be discarded without confirmation", isFinishing) // dismiss: the next case would otherwise assert against this dialog if it showed none - clickAlertDialogButton(DialogInterface.BUTTON_NEGATIVE, false) + clickAlertDialogButton(checkDismissed = false) { negativeButton } } // 3-button navigation reports a visible IME with no height @@ -801,7 +802,7 @@ class CardTemplateEditorTest : RobolectricTest() { withCardTemplateEditor(col.notetypes.basicAndReversed) { assertTrue("Unable to delete the card type", shadowOf(this).clickMenuItem(R.id.action_delete)) advanceRobolectricLooper() - clickAlertDialogButton(DialogInterface.BUTTON_POSITIVE, true) + clickAlertDialogButton { positiveButton } advanceRobolectricLooper() assertEquals("One card type should remain", 1, tempNoteType!!.templateCount) assertTrue("The deletion is unsaved", noteTypeHasChanged()) @@ -822,7 +823,7 @@ class CardTemplateEditorTest : RobolectricTest() { withCardTemplateEditor(col.notetypes.basicAndReversed) { assertTrue("Unable to delete the card type", shadowOf(this).clickMenuItem(R.id.action_delete)) advanceRobolectricLooper() - clickAlertDialogButton(DialogInterface.BUTTON_POSITIVE, true) + clickAlertDialogButton { positiveButton } advanceRobolectricLooper() assertEquals("One card type should remain", 1, tempNoteType!!.templateCount) @@ -885,7 +886,7 @@ class CardTemplateEditorTest : RobolectricTest() { assertTrue("Unable to click?", shadowEditor.clickMenuItem(android.R.id.home)) advanceRobolectricLooper() assertEquals("Wrong dialog shown?", "Discard changes?", getAlertDialogText(true)) - clickAlertDialogButton(DialogInterface.BUTTON_POSITIVE, false) + clickAlertDialogButton(checkDismissed = false) { positiveButton } } // Case 1: Show dialog after deck override @@ -1161,7 +1162,7 @@ class CardTemplateEditorTest : RobolectricTest() { getQuantityString(CommonPlurals.card_template_editor_confirm_add, numAffectedCards, numAffectedCards), getAlertDialogText(true), ) - clickAlertDialogButton(DialogInterface.BUTTON_POSITIVE, true) + clickAlertDialogButton { positiveButton } } @Test diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt index b51c5304194d..8392b7bf2850 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt @@ -12,6 +12,7 @@ import android.content.Intent import android.content.SharedPreferences import android.content.res.Resources import android.os.Looper +import android.widget.Button import android.widget.TextView import androidx.annotation.CallSuper import androidx.appcompat.app.AlertDialog @@ -219,15 +220,15 @@ open class RobolectricTest : } /** - * Click on a dialog button for an AlertDialog dialog box. Replaces the above helper. + * Click [button] on the latest AlertDialog and process its click handler. */ protected fun clickAlertDialogButton( - button: Int, - @Suppress("SameParameterValue") checkDismissed: Boolean, + checkDismissed: Boolean = true, + button: AlertDialog.() -> Button, ) { val dialog = getLatestAlertDialog() - dialog.getButton(button).performClick() + dialog.button().performClick() // Need to run UI thread tasks to actually run the onClickHandler ShadowLooper.runUiThreadTasks() diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/utils/AlertDialogUtils.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/utils/AlertDialogUtils.kt index 10b18e1dd293..92c8accaacbb 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/utils/AlertDialogUtils.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/utils/AlertDialogUtils.kt @@ -2,7 +2,6 @@ package com.ichi2.anki.dialogs.utils -import android.content.DialogInterface import android.widget.TextView import androidx.appcompat.app.AlertDialog import androidx.core.view.isVisible @@ -10,8 +9,8 @@ import androidx.test.platform.app.InstrumentationRegistry import com.ichi2.anki.R import com.ichi2.anki.common.utils.android.HandlerUtils.executeFunctionUsingHandler import com.ichi2.utils.getInputField +import com.ichi2.utils.positiveButton import org.hamcrest.MatcherAssert.assertThat -import kotlin.test.assertNotNull var AlertDialog.input get() = getInputField().text.toString() @@ -37,7 +36,6 @@ val AlertDialog.ankiListView fun AlertDialog.performPositiveClick() { // This exists as callOnClick did not call the listener - val positiveButton = assertNotNull(getButton(DialogInterface.BUTTON_POSITIVE), message = "positive button") assertThat("button is visible", positiveButton.isVisible) assertThat("button is enabled", positiveButton.isEnabled) executeFunctionUsingHandler { positiveButton.callOnClick() } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/preferences/ControlsSettingsFragmentTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/preferences/ControlsSettingsFragmentTest.kt index a04d84c92bfa..4f6d4284b135 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/preferences/ControlsSettingsFragmentTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/preferences/ControlsSettingsFragmentTest.kt @@ -2,7 +2,6 @@ package com.ichi2.anki.preferences -import android.content.DialogInterface import androidx.test.core.app.ActivityScenario import androidx.test.core.app.ApplicationProvider import androidx.test.espresso.matcher.ViewMatchers.assertThat @@ -15,6 +14,8 @@ import com.ichi2.anki.reviewer.Binding import com.ichi2.anki.reviewer.CardSide import com.ichi2.preferences.ReviewerControlPreference import com.ichi2.testutils.HamcrestUtils +import com.ichi2.utils.negativeButton +import com.ichi2.utils.positiveButton import org.hamcrest.Matchers.equalTo import org.hamcrest.Matchers.hasItem import org.hamcrest.Matchers.not @@ -51,7 +52,7 @@ class ControlsSettingsFragmentTest : RobolectricTest() { withControlsSettings { val binding = Binding.GestureInput(Gesture.SWIPE_UP) answerPref.setBinding(binding) - clickAlertDialogButton(DialogInterface.BUTTON_NEGATIVE, checkDismissed = true) + clickAlertDialogButton { negativeButton } val showAnswerBindings = showAnswerPref.getMappableBindings().map { Pair(it.binding, it.side) } val answerBindings = answerPref.getMappableBindings().map { Pair(it.binding, it.side) } @@ -65,7 +66,7 @@ class ControlsSettingsFragmentTest : RobolectricTest() { withControlsSettings { val binding = Binding.GestureInput(Gesture.SWIPE_UP) answerPref.setBinding(binding) - clickAlertDialogButton(DialogInterface.BUTTON_POSITIVE, checkDismissed = true) + clickAlertDialogButton { positiveButton } val showAnswerBindings = showAnswerPref.getMappableBindings().map { Pair(it.binding, it.side) } val answerBindings = answerPref.getMappableBindings().map { Pair(it.binding, it.side) } 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 d7f6f70084c7..a3b2420a48d9 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/previewer/CardViewerFragmentTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/previewer/CardViewerFragmentTest.kt @@ -2,7 +2,6 @@ package com.ichi2.anki.previewer -import android.content.DialogInterface import android.os.Build import android.view.ViewGroup import android.webkit.PermissionRequest @@ -17,6 +16,8 @@ import com.ichi2.anki.RobolectricTest import com.ichi2.anki.browser.IdsFile import com.ichi2.anki.settings.Prefs import com.ichi2.testutils.createTransientDirectory +import com.ichi2.utils.negativeButton +import com.ichi2.utils.positiveButton import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.equalTo import org.junit.After @@ -61,7 +62,7 @@ class CardViewerFragmentTest : RobolectricTest() { chromeClient.onPermissionRequest(second) verify(second).deny() - clickAlertDialogButton(DialogInterface.BUTTON_POSITIVE, true) + clickAlertDialogButton { positiveButton } verify(first).grant(arrayOf(PermissionRequest.RESOURCE_AUDIO_CAPTURE)) verify(second, never()).grant(any()) } @@ -76,7 +77,7 @@ class CardViewerFragmentTest : RobolectricTest() { withCardViewerChromeClient { chromeClient -> chromeClient.onPermissionRequest(first) - clickAlertDialogButton(DialogInterface.BUTTON_NEGATIVE, true) + clickAlertDialogButton { negativeButton } verify(first).deny() val dialogCount = ShadowDialog.getShownDialogs().size From e4b56226c9f46d2be924a667e88c72ec957f8247 Mon Sep 17 00:00:00 2001 From: Mike Hardy Date: Wed, 30 Sep 2026 03:35:16 +0000 Subject: [PATCH 13/43] Bumped version to 2.26.0alpha1 --- AnkiDroid/build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/AnkiDroid/build.gradle b/AnkiDroid/build.gradle index 414b096c1b50..5980a4ddecfc 100644 --- a/AnkiDroid/build.gradle +++ b/AnkiDroid/build.gradle @@ -116,9 +116,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=22600100 + versionCode=22600101 // 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.0alpha0" + versionName="2.26.0alpha1" minSdk = libs.versions.minSdk.get().toInteger() // After #13695: change .tests_emulator.yml From c9e21898d755b129f9d075116496574d64f3736e Mon Sep 17 00:00:00 2001 From: Mike Hardy Date: Tue, 29 Sep 2026 22:02:50 -0500 Subject: [PATCH 14/43] test: fail on Robolectric leftover activities after each test RuntimeEnvironment.setQualifiers(+night) recreates ActivityThread leftovers (often DeckPicker), which flakes ThemesTest mid-suite. Add an after-test guard and dispose the current offenders. tearDown force-removes owned controllers only; clearAll would hide ActivityScenario leftovers from NoLiveRobolectricActivitiesRule. Preserve original test failures alongside leak reports via MultipleFailureException, and use a low Rule.order so the check runs outside other rules after their activity cleanup finishes. Dispose AppCompat delegates during leak cleanup, and report owned activity cleanup failures after remaining teardown finishes. Assisted-by: GPT-6 Co-authored-by: Cursor --- .../ActivityStartupUndecidedStorageTest.kt | 1 + .../anki/CoroutineHelpersRobolectricTest.kt | 16 +- .../java/com/ichi2/anki/DeckPickerTest.kt | 59 ++--- .../java/com/ichi2/anki/IntentHandlerTest.kt | 4 +- .../com/ichi2/anki/ReviewerNoParamTest.kt | 1 + .../java/com/ichi2/anki/RobolectricTest.kt | 21 +- .../ichi2/anki/StudyOptionsFragmentTest.kt | 55 ++--- .../CardSideSelectionDialogScreenshotTest.kt | 8 +- .../anki/dialogs/CreateDeckDialogTest.kt | 2 +- .../com/ichi2/anki/pages/CongratsPageTest.kt | 19 +- .../ichi2/anki/startup/StartupGuardsTest.kt | 6 +- .../NoLiveRobolectricActivitiesRule.kt | 48 ++++ .../NoLiveRobolectricActivitiesRuleTest.kt | 221 ++++++++++++++++++ .../RobolectricLiveActivitiesTest.kt | 83 +++++++ .../test/java/com/ichi2/themes/ThemesTest.kt | 14 +- .../java/com/ichi2/utils/PermissionsTest.kt | 10 +- common/android/build.gradle.kts | 2 + .../java/com/ichi2/testutils/Robolectric.kt | 119 ++++++++++ 18 files changed, 603 insertions(+), 86 deletions(-) create mode 100644 AnkiDroid/src/test/java/com/ichi2/testutils/NoLiveRobolectricActivitiesRule.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/testutils/NoLiveRobolectricActivitiesRuleTest.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/testutils/RobolectricLiveActivitiesTest.kt diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ActivityStartupUndecidedStorageTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ActivityStartupUndecidedStorageTest.kt index aa7948126d30..3a07d56f76fd 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ActivityStartupUndecidedStorageTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ActivityStartupUndecidedStorageTest.kt @@ -53,6 +53,7 @@ class ActivityStartupUndecidedStorageTest : RobolectricTest() { @Test fun `startup is crash-free when storage is undecided`() { val controller = launcher!!.build(targetContext) + saveControllerForCleanup(controller) // mirrors Android: an activity which finishes during onCreate (e.g. redirectToMainEntryPoint) // does not receive the remaining lifecycle callbacks; controller.setup() would force them controller.create() diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CoroutineHelpersRobolectricTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CoroutineHelpersRobolectricTest.kt index 5dc27f0f7126..49c7d798aacd 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CoroutineHelpersRobolectricTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CoroutineHelpersRobolectricTest.kt @@ -22,14 +22,16 @@ class CoroutineHelpersRobolectricTest : RobolectricTest() { */ @Test fun `launchCatchingTask redirects to main entry point when storage is not configured`() { - val activity = Robolectric.buildActivity(FragmentActivity::class.java).create().get() + Robolectric.buildActivity(FragmentActivity::class.java).use { controller -> + val activity = controller.create().get() - activity.launchCatchingTask { throw StorageNotConfiguredException() } - advanceRobolectricLooper() + activity.launchCatchingTask { throw StorageNotConfiguredException() } + advanceRobolectricLooper() - assertTrue(activity.isFinishing, "activity should finish") - val redirect = shadowOf(activity).nextStartedActivity - assertNotNull(redirect, "the main entry point should be opened") - assertEquals(IntentHandler::class.qualifiedName, redirect.component?.className) + assertTrue(activity.isFinishing, "activity should finish") + val redirect = shadowOf(activity).nextStartedActivity + assertNotNull(redirect, "the main entry point should be opened") + assertEquals(IntentHandler::class.qualifiedName, redirect.component?.className) + } } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt index 0bcb54b4b674..dae51842a2e3 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTest.kt @@ -308,7 +308,9 @@ class DeckPickerTest : RobolectricTest() { @Test fun databaseLockedTest() { // don't call .onCreate - val deckPicker = Robolectric.buildActivity(DeckPickerEx::class.java, Intent()).get() + val controller = Robolectric.buildActivity(DeckPickerEx::class.java, Intent()) + saveControllerForCleanup(controller) + val deckPicker = controller.get() deckPicker.handleStartupFailure(InitialActivity.StartupFailure.DatabaseLocked) assertThat( deckPicker.databaseErrorDialog, @@ -320,7 +322,9 @@ class DeckPickerTest : RobolectricTest() { @Test fun `storage undecided shows load-failure options rather than crashing`() { // don't call .onCreate - val deckPicker = Robolectric.buildActivity(DeckPickerEx::class.java, Intent()).get() + val controller = Robolectric.buildActivity(DeckPickerEx::class.java, Intent()) + saveControllerForCleanup(controller) + val deckPicker = controller.get() deckPicker.handleStartupFailure(InitialActivity.StartupFailure.StorageUndecided) assertThat( deckPicker.databaseErrorDialog, @@ -751,31 +755,32 @@ class DeckPickerTest : RobolectricTest() { @Test fun `restored study options fragment is pruned when recreated into single pane`() { assumeTrue("We are running on a tablet", qualifiers!!.contains("xlarge")) - val scenario = ActivityScenario.launch(DeckPicker::class.java) - advanceRobolectricLooper() - scenario.onActivity { deckPicker -> - assertThat( - "side panel fragment should be displayed on tablet", - deckPicker.supportFragmentManager.findFragmentById(R.id.studyoptions_fragment), - notNullValue(), - ) - } - // Fold the device: the activity recreates into the single-pane layout, while - // FragmentManager restores the saved side panel fragment into it. - RuntimeEnvironment.setQualifiers("sw320dp") - scenario.recreate() - advanceRobolectricLooper() - scenario.onActivity { deckPicker -> - assertThat( - "restored side panel fragment must be pruned in single-pane layout", - deckPicker.supportFragmentManager.findFragmentById(R.id.studyoptions_fragment), - nullValue(), - ) - assertThat( - "study options menu items must not leak into the single-pane toolbar", - deckPicker.menu().findItem(R.id.action_custom_study), - nullValue(), - ) + ActivityScenario.launch(DeckPicker::class.java).use { scenario -> + advanceRobolectricLooper() + scenario.onActivity { deckPicker -> + assertThat( + "side panel fragment should be displayed on tablet", + deckPicker.supportFragmentManager.findFragmentById(R.id.studyoptions_fragment), + notNullValue(), + ) + } + // Fold the device: the activity recreates into the single-pane layout, while + // FragmentManager restores the saved side panel fragment into it. + RuntimeEnvironment.setQualifiers("sw320dp") + scenario.recreate() + advanceRobolectricLooper() + scenario.onActivity { deckPicker -> + assertThat( + "restored side panel fragment must be pruned in single-pane layout", + deckPicker.supportFragmentManager.findFragmentById(R.id.studyoptions_fragment), + nullValue(), + ) + assertThat( + "study options menu items must not leak into the single-pane toolbar", + deckPicker.menu().findItem(R.id.action_custom_study), + nullValue(), + ) + } } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/IntentHandlerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/IntentHandlerTest.kt index 07a563ad2b71..66b7b2954193 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/IntentHandlerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/IntentHandlerTest.kt @@ -24,7 +24,9 @@ class IntentHandlerTest { val intent = Intent("com.ichi2.anki.COPY_DEBUG_INFO") assertDoesNotThrow { - Robolectric.buildActivity(IntentHandler::class.java, intent).create() + Robolectric.buildActivity(IntentHandler::class.java, intent).use { controller -> + controller.create() + } } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerNoParamTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerNoParamTest.kt index adbe6caaad5e..71f6b945e441 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerNoParamTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ReviewerNoParamTest.kt @@ -181,6 +181,7 @@ class ReviewerNoParamTest : RobolectricTest() { fun noDrawerConflictsBeforeOnCreate() { enableGestureSetting() val controller = Robolectric.buildActivity(Reviewer::class.java, Intent()) + saveControllerForCleanup(controller) try { assertThat("no conflicts before onCreate", controller.get().hasDrawerSwipeConflicts(), equalTo(false)) } finally { diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt index 8392b7bf2850..939c4f591a9f 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt @@ -43,6 +43,7 @@ import com.ichi2.anki.observability.ChangeManager import com.ichi2.anki.observability.undoableOp import com.ichi2.compat.customtabs.CustomTabActivityHelper import com.ichi2.testutils.AndroidTest +import com.ichi2.testutils.NoLiveRobolectricActivitiesRule import com.ichi2.testutils.ProductionCollectionManager import com.ichi2.testutils.common.FailOnUnhandledExceptionRule import com.ichi2.testutils.common.IgnoreFlakyTestsInCIRule @@ -78,6 +79,7 @@ import kotlin.test.assertNotNull import kotlin.time.Duration import kotlin.time.Duration.Companion.seconds import kotlin.time.TimeSource +import com.ichi2.testutils.Robolectric as RobolectricActivities open class RobolectricTest : AnkiTest, @@ -107,6 +109,15 @@ open class RobolectricTest : @get:Rule val tempFolder = TemporaryFolder() + /** + * After all `@After` methods, fail if any Robolectric activities are still live. + * Retained AppCompat delegates can recreate activities on later night-mode changes. + * + * Low [Rule.order] so this wraps other rules and always runs after their cleanup. + */ + @get:Rule(order = Int.MIN_VALUE) + val noLiveActivities = NoLiveRobolectricActivitiesRule() + override val collectionManager: TestCollectionManager by lazy { when (getCollectionStorageMode()) { ON_DISK -> ProductionCollectionManager as TestCollectionManager @@ -175,12 +186,12 @@ open class RobolectricTest : throwOnShowError = false // If you don't clean up your ActivityControllers you will get OOM errors for (controller in controllersForCleanup) { - Timber.d("Calling destroy on controller %s", controller.get().toString()) try { - controller.destroy() - } catch (e: Exception) { - // Any exception here is likely because the test code already destroyed it, which is fine - // No exception here should halt test execution since tests are over anyway. + Timber.d("Closing controller %s", controller.get()) + RobolectricActivities.closeActivity(controller) + } catch (failure: Throwable) { + // Let subclass @After methods and other rules finish cleanup before reporting. + noLiveActivities.recordCleanupFailure(failure) } } controllersForCleanup.clear() diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/StudyOptionsFragmentTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/StudyOptionsFragmentTest.kt index 65394a66156b..19fc3c46f05d 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/StudyOptionsFragmentTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/StudyOptionsFragmentTest.kt @@ -55,8 +55,10 @@ class StudyOptionsFragmentTest : RobolectricTest() { withNullCollection { launchFragmentInContainer( initialState = Lifecycle.State.RESUMED, - ).onFragment { fragment -> - assertEquals(Lifecycle.State.RESUMED, fragment.lifecycle.currentState) + ).use { scenario -> + scenario.onFragment { fragment -> + assertEquals(Lifecycle.State.RESUMED, fragment.lifecycle.currentState) + } } } } @@ -66,9 +68,9 @@ class StudyOptionsFragmentTest : RobolectricTest() { withNullCollection { launchFragmentInContainer( initialState = Lifecycle.State.STARTED, - ).run { - moveToState(Lifecycle.State.RESUMED) - onFragment { fragment -> + ).use { scenario -> + scenario.moveToState(Lifecycle.State.RESUMED) + scenario.onFragment { fragment -> assertEquals(Lifecycle.State.RESUMED, fragment.lifecycle.currentState) } } @@ -80,9 +82,9 @@ class StudyOptionsFragmentTest : RobolectricTest() { withNullCollection { launchFragmentInContainer( initialState = Lifecycle.State.RESUMED, - ).run { - recreate() - onFragment { fragment -> + ).use { scenario -> + scenario.recreate() + scenario.onFragment { fragment -> assertEquals(Lifecycle.State.RESUMED, fragment.lifecycle.currentState) } } @@ -94,10 +96,12 @@ class StudyOptionsFragmentTest : RobolectricTest() { withNullCollection { launchFragmentInContainer( initialState = Lifecycle.State.RESUMED, - ).onFragment { fragment -> - val menu = MenuBuilder(fragment.requireContext()) - fragment.onCreateMenu(menu, fragment.requireActivity().menuInflater) - fragment.onPrepareMenu(menu) + ).use { scenario -> + scenario.onFragment { fragment -> + val menu = MenuBuilder(fragment.requireContext()) + fragment.onCreateMenu(menu, fragment.requireActivity().menuInflater) + fragment.onPrepareMenu(menu) + } } } } @@ -106,21 +110,20 @@ class StudyOptionsFragmentTest : RobolectricTest() { fun `onPrepareMenu does not crash when collection closes after population`() { col - val scenario = - launchFragmentInContainer( - initialState = Lifecycle.State.RESUMED, - ) - scenario.onFragment { fragment -> - - runBlocking { fragment.viewModel.refreshData() } - assertIs(fragment.viewModel.state) - } - withNullCollection { + launchFragmentInContainer( + initialState = Lifecycle.State.RESUMED, + ).use { scenario -> scenario.onFragment { fragment -> - @Suppress("RestrictedApi") - val menu = MenuBuilder(fragment.requireContext()) - fragment.onCreateMenu(menu, fragment.requireActivity().menuInflater) - fragment.onPrepareMenu(menu) + runBlocking { fragment.viewModel.refreshData() } + assertIs(fragment.viewModel.state) + } + withNullCollection { + scenario.onFragment { fragment -> + @Suppress("RestrictedApi") + val menu = MenuBuilder(fragment.requireContext()) + fragment.onCreateMenu(menu, fragment.requireActivity().menuInflater) + fragment.onPrepareMenu(menu) + } } } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/CardSideSelectionDialogScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/CardSideSelectionDialogScreenshotTest.kt index c15052501498..527a4e4c3c2d 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/CardSideSelectionDialogScreenshotTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/CardSideSelectionDialogScreenshotTest.kt @@ -9,8 +9,10 @@ import org.robolectric.Robolectric.buildActivity class CardSideSelectionDialogScreenshotTest : ScreenshotTest() { @Test fun testCardSideSelectionDialogAppearance() { - val activity = buildActivity(FragmentActivity::class.java).setup().get() - CardSideSelectionDialog.displayInstance(activity) {} - captureScreen("dialog") + buildActivity(FragmentActivity::class.java).use { controller -> + val activity = controller.setup().get() + CardSideSelectionDialog.displayInstance(activity) {} + captureScreen("dialog") + } } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/CreateDeckDialogTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/CreateDeckDialogTest.kt index 68b06361b4b7..7467340b6d50 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/CreateDeckDialogTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/CreateDeckDialogTest.kt @@ -55,8 +55,8 @@ class CreateDeckDialogTest : RobolectricTest() { } override fun tearDown() { - super.tearDown() activityScenario.closeQuietly() + super.tearDown() } @Test diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/pages/CongratsPageTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/pages/CongratsPageTest.kt index dd6d7adf7f52..71827a5e4aaa 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/pages/CongratsPageTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/pages/CongratsPageTest.kt @@ -24,18 +24,19 @@ class CongratsPageTest : RobolectricTest() { val deckId = addDynamicDeck("Filtered", "") withCol { sched.emptyFilteredDeck(deckId) } - val scenario = launchFragmentInContainer() - scenario.moveToState(Lifecycle.State.CREATED) + launchFragmentInContainer().use { scenario -> + scenario.moveToState(Lifecycle.State.CREATED) - // rebuild happens while the screen isn't visible - nobody is collecting congratsRefreshState - undoableOp { sched.rebuildFilteredDeck(deckId) } + // rebuild happens while the screen isn't visible - nobody is collecting congratsRefreshState + undoableOp { sched.rebuildFilteredDeck(deckId) } - scenario.moveToState(Lifecycle.State.RESUMED) - advanceUntilIdle() + scenario.moveToState(Lifecycle.State.RESUMED) + advanceUntilIdle() - scenario.onFragment { fragment -> - val next = shadowOf(fragment.requireActivity()).nextStartedActivity - assertEquals(StudyOptionsActivity::class.java.name, next?.component?.className) + scenario.onFragment { fragment -> + val next = shadowOf(fragment.requireActivity()).nextStartedActivity + assertEquals(StudyOptionsActivity::class.java.name, next?.component?.className) + } } } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/startup/StartupGuardsTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/startup/StartupGuardsTest.kt index 5ce0694e3232..8548235c1551 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/startup/StartupGuardsTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/startup/StartupGuardsTest.kt @@ -29,7 +29,11 @@ class StartupGuardsTest : RobolectricTest() { CollectionHelper.storageDecisionTestOverride = null } - private fun buildActivity(): Activity = Robolectric.buildActivity(Activity::class.java).create().get() + private fun buildActivity(): Activity { + val controller = Robolectric.buildActivity(Activity::class.java).create() + saveControllerForCleanup(controller) + return controller.get() + } @Test fun `passes when storage is decided and accessible`() { diff --git a/AnkiDroid/src/test/java/com/ichi2/testutils/NoLiveRobolectricActivitiesRule.kt b/AnkiDroid/src/test/java/com/ichi2/testutils/NoLiveRobolectricActivitiesRule.kt new file mode 100644 index 000000000000..3a8ed45f64d5 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/testutils/NoLiveRobolectricActivitiesRule.kt @@ -0,0 +1,48 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.testutils + +import org.junit.rules.TestRule +import org.junit.runner.Description +import org.junit.runners.model.MultipleFailureException +import org.junit.runners.model.Statement + +/** + * After each test (including all `@After` methods), fails if Robolectric still has live activities. + * + * Runs as a [TestRule] so subclass `@After` cleanup that closes [androidx.test.core.app.ActivityScenario] + * after `super.tearDown()` still runs before the check. + */ +class NoLiveRobolectricActivitiesRule : TestRule { + private val cleanupFailures = mutableListOf() + + /** Report an owned controller's failure after subclass and rule cleanup have finished. */ + fun recordCleanupFailure(failure: Throwable) { + cleanupFailures.add(failure) + } + + override fun apply( + base: Statement, + description: Description, + ): Statement = + object : Statement() { + override fun evaluate() { + val failures = mutableListOf() + try { + base.evaluate() + } catch (failure: Throwable) { + failures.add(failure) + } + failures.addAll(cleanupFailures) + cleanupFailures.clear() + try { + Robolectric.assertNoLiveActivities( + "test \"${description.methodName}\"", + ) + } catch (failure: Throwable) { + failures.add(failure) + } + MultipleFailureException.assertEmpty(failures) + } + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/testutils/NoLiveRobolectricActivitiesRuleTest.kt b/AnkiDroid/src/test/java/com/ichi2/testutils/NoLiveRobolectricActivitiesRuleTest.kt new file mode 100644 index 000000000000..b866bcb56057 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/testutils/NoLiveRobolectricActivitiesRuleTest.kt @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.testutils + +import android.annotation.SuppressLint +import android.app.Activity +import android.os.Bundle +import android.os.Looper +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.CollectionManager +import com.ichi2.anki.RobolectricTest +import org.junit.Ignore +import org.junit.Rule +import org.junit.Test +import org.junit.rules.ExternalResource +import org.junit.runner.Description +import org.junit.runner.JUnitCore +import org.junit.runner.RunWith +import org.junit.runners.BlockJUnit4ClassRunner +import org.junit.runners.model.MultipleFailureException +import org.junit.runners.model.Statement +import org.robolectric.Robolectric +import org.robolectric.Shadows.shadowOf +import org.robolectric.android.controller.ActivityController +import kotlin.test.assertContains +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertSame +import kotlin.test.assertTrue +import com.ichi2.testutils.Robolectric as RobolectricActivities + +@RunWith(AndroidJUnit4::class) +class NoLiveRobolectricActivitiesRuleTest { + @Test + fun `preserves the original failure when no activity leaks`() { + val original = AssertionError("original test failure") + + val failure = assertFailsWith { evaluate { throw original } } + + assertSame(original, failure) + } + + @Test + fun `reports both the original failure and the activity leak`() { + val original = AssertionError("original test failure") + + val failure = + assertFailsWith { + evaluate { + Robolectric.buildActivity(Activity::class.java).setup() + throw original + } + } + + assertEquals(2, failure.failures.size) + assertSame(original, failure.failures[0]) + val leak = assertIs(failure.failures[1]) + assertContains(leak.message!!, "leftover activities") + assertTrue(RobolectricActivities.liveActivities().isEmpty()) + } + + @Test + fun `detects an unowned leak after cleaning up owned controllers`() { + // Exercise the real @Before, @After and rules inside the current Robolectric sandbox. + val result = JUnitCore().run(BlockJUnit4ClassRunner(MixedOwnershipTest::class.java)) + + assertEquals(1, result.runCount) + assertEquals(1, result.failureCount, result.failures.toString()) + val failure = assertIs(result.failures.single().exception) + assertContains(failure.message!!, "leftover activities") + assertTrue(RobolectricActivities.liveActivities().isEmpty()) + } + + @Test + fun `checks for leaks after other rules finish cleanup`() { + val result = JUnitCore().run(BlockJUnit4ClassRunner(RuleManagedActivityTest::class.java)) + + assertEquals(1, result.runCount) + assertEquals(0, result.failureCount, result.failures.toString()) + assertTrue(RobolectricActivities.liveActivities().isEmpty()) + } + + @Test + fun `reports failed owned destruction after cleanup and prevents recreation`() { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) + val result = JUnitCore().run(BlockJUnit4ClassRunner(FailedOwnedCleanupTest::class.java)) + val controller = checkNotNull(FailedOwnedCleanupTest.controller) + val activity = controller.get() + try { + assertEquals(1, result.runCount) + assertEquals(1, result.failureCount, result.failures.toString()) + assertSame(activity.destroyFailure, result.failures.single().exception) + assertTrue(RobolectricActivities.liveActivities().isEmpty()) + assertFalse(CollectionManager.isOpenUnsafe()) + assertTrue(FailedOwnedCleanupTest.subclassCleanupRan) + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) + shadowOf(Looper.getMainLooper()).idle() + assertEquals(0, activity.recreationCount) + } finally { + FailedOwnedCleanupTest.controller = null + cleanupThrowingActivity(controller) + } + } + + @Test + fun `reports the leak and its destruction failure without leaving an active delegate`() { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) + val controller = Robolectric.buildActivity(ThrowingDestroyActivity::class.java).setup() + val activity = controller.get() + try { + val failure = assertFailsWith { evaluate {} } + + assertEquals(2, failure.failures.size) + assertContains(assertIs(failure.failures[0]).message!!, "leftover activities") + assertSame(activity.destroyFailure, failure.failures[1]) + assertTrue(RobolectricActivities.liveActivities().isEmpty()) + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) + shadowOf(Looper.getMainLooper()).idle() + assertEquals(0, activity.recreationCount) + } finally { + cleanupThrowingActivity(controller) + } + } + + private fun cleanupThrowingActivity(controller: ActivityController) { + // Keep the regression tests isolated even when the cleanup being tested is broken. + try { + controller.get().delegate.onDestroy() + } finally { + RobolectricActivities.forceRemoveActivity(controller.get()) + } + } + + private fun evaluate(block: () -> Unit) { + val statement = + object : Statement() { + override fun evaluate() = block() + } + NoLiveRobolectricActivitiesRule() + .apply(statement, Description.createTestDescription(javaClass, "test")) + .evaluate() + } + + @Ignore("Run explicitly by the enclosing test inside its Robolectric sandbox") + class MixedOwnershipTest : RobolectricTest() { + @Test + fun `leaks an activity alongside an owned controller`() { + saveControllerForCleanup(Robolectric.buildActivity(Activity::class.java).setup()) + Robolectric.buildActivity(Activity::class.java).setup() + } + } + + @Ignore("Run explicitly by the enclosing test inside its Robolectric sandbox") + class FailedOwnedCleanupTest : RobolectricTest() { + @Test + fun `owns an activity whose destruction fails`() { + subclassCleanupRan = false + col + val activityController = Robolectric.buildActivity(ThrowingDestroyActivity::class.java).setup() + controller = activityController + saveControllerForCleanup(activityController) + // Cleanup must continue to the next controller and the collection after the failure. + saveControllerForCleanup(Robolectric.buildActivity(Activity::class.java).setup()) + } + + override fun tearDown() { + super.tearDown() + subclassCleanupRan = true + } + + companion object { + var controller: ActivityController? = null + var subclassCleanupRan = false + } + } + + class ThrowingDestroyActivity : AppCompatActivity() { + val destroyFailure = IllegalStateException("activity destruction failed") + var recreationCount = 0 + + override fun onCreate(savedInstanceState: Bundle?) { + setTheme(androidx.appcompat.R.style.Theme_AppCompat) + super.onCreate(savedInstanceState) + } + + // Intentionally omit super.onDestroy() so cleanup paths that fail mid-destroy can be tested. + @SuppressLint("MissingSuperCall") + override fun onDestroy(): Unit = throw destroyFailure + + override fun recreate() { + recreationCount++ + super.recreate() + } + } + + @Ignore("Run explicitly by the enclosing test inside its Robolectric sandbox") + class RuleManagedActivityTest : RobolectricTest() { + private lateinit var controller: ActivityController + + @get:Rule + val activity = + object : ExternalResource() { + override fun before() { + controller = Robolectric.buildActivity(Activity::class.java).setup() + } + + override fun after() { + controller.close() + } + } + + @Test + fun `activity is closed by its rule`() { + assertSame(controller.get(), RobolectricActivities.liveActivities().single()) + } + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/testutils/RobolectricLiveActivitiesTest.kt b/AnkiDroid/src/test/java/com/ichi2/testutils/RobolectricLiveActivitiesTest.kt new file mode 100644 index 000000000000..a3ac20fd68e4 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/testutils/RobolectricLiveActivitiesTest.kt @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.testutils + +import android.app.Activity +import android.os.Bundle +import android.os.Looper +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.RobolectricTest +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.empty +import org.hamcrest.Matchers.hasSize +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.Shadows.shadowOf +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import com.ichi2.testutils.Robolectric as RobolectricActivities + +@RunWith(AndroidJUnit4::class) +class RobolectricLiveActivitiesTest : RobolectricTest() { + @Test + fun `liveActivities empty after ActivityController close`() { + Robolectric.buildActivity(Activity::class.java).use { controller -> + controller.setup() + assertThat(RobolectricActivities.liveActivities(), hasSize(1)) + } + assertThat(RobolectricActivities.liveActivities(), empty()) + } + + @Test + fun `assertNoLiveActivities fails and clears leftovers`() { + Robolectric.buildActivity(Activity::class.java).setup() + assertThat(RobolectricActivities.liveActivities(), hasSize(1)) + + val error = + assertFailsWith { + RobolectricActivities.assertNoLiveActivities("unit test") + } + assertThat(error.message!!.contains("leftover activities"), org.hamcrest.Matchers.equalTo(true)) + assertThat(RobolectricActivities.liveActivities(), empty()) + } + + @Test + fun `cleanup disposes a delegate even when onCreate never completed`() { + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO) + val controller = Robolectric.buildActivity(ThrowingCreateActivity::class.java) + saveControllerForCleanup(controller) + assertFailsWith { controller.create() } + + RobolectricActivities.closeActivity(controller) + + assertThat(RobolectricActivities.liveActivities(), empty()) + AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES) + shadowOf(Looper.getMainLooper()).idle() + assertEquals(0, controller.get().recreationCount) + } + + @Test + fun `owned controller can already be closed before teardown`() { + val controller = Robolectric.buildActivity(Activity::class.java).setup() + saveControllerForCleanup(controller) + controller.close() + } + + class ThrowingCreateActivity : AppCompatActivity() { + var recreationCount = 0 + + override fun onCreate(savedInstanceState: Bundle?) { + setTheme(androidx.appcompat.R.style.Theme_AppCompat) + super.onCreate(savedInstanceState) + error("activity creation failed after registering its delegate") + } + + override fun recreate() { + recreationCount++ + super.recreate() + } + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/themes/ThemesTest.kt b/AnkiDroid/src/test/java/com/ichi2/themes/ThemesTest.kt index bcfe78c842f9..a9926851d1fb 100644 --- a/AnkiDroid/src/test/java/com/ichi2/themes/ThemesTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/themes/ThemesTest.kt @@ -21,6 +21,7 @@ import org.junit.runner.RunWith import org.robolectric.Robolectric import org.robolectric.RuntimeEnvironment import kotlin.test.assertFailsWith +import com.ichi2.testutils.Robolectric as RobolectricActivities @RunWith(AndroidJUnit4::class) class ThemesTest : RobolectricTest() { @@ -31,15 +32,22 @@ class ThemesTest : RobolectricTest() { */ @Test fun `decor view access before setTheme fails fast`() { + // create() throws before the controller leaves INITIAL, so ActivityController.close() would + // no-op and leave the activity registered — register for tearDown's fallback cleanup. + val controller = Robolectric.buildActivity(EarlyDecorViewInitActivity::class.java) + saveControllerForCleanup(controller) val exception = assertFailsWith { - Robolectric.buildActivity(EarlyDecorViewInitActivity::class.java).create() + controller.create() } assertThat(exception.message, containsString("setTheme")) } @Test fun `window background follows the night theme - issue 21520`() { + // Check for leftovers before applying the night theme, which can recreate retained + // AppCompat activities. + RobolectricActivities.assertNoLiveActivities("ThemesTest before setQualifiers(+night)") RuntimeEnvironment.setQualifiers("+night") PrefsRepository(targetContext).apply { appTheme = AppTheme.NIGHT @@ -70,7 +78,9 @@ class ThemesTest : RobolectricTest() { */ @Test fun `recreated activity with an existing decor view does not fail - issue 21548`() { - Robolectric.buildActivity(EarlyDecorViewInitActivity::class.java).create(Bundle()) + Robolectric.buildActivity(EarlyDecorViewInitActivity::class.java).use { controller -> + controller.create(Bundle()) + } } /** simulates e.g. an [androidx.activity.enableEdgeToEdge] call before `super.onCreate` */ diff --git a/AnkiDroid/src/test/java/com/ichi2/utils/PermissionsTest.kt b/AnkiDroid/src/test/java/com/ichi2/utils/PermissionsTest.kt index 0bdd47c0653c..d26006302be5 100644 --- a/AnkiDroid/src/test/java/com/ichi2/utils/PermissionsTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/utils/PermissionsTest.kt @@ -84,10 +84,12 @@ class PermissionsTest { @Test fun `openAppSettingsScreen is a no-op if no app can show the app settings screen`() { - val realActivity = Robolectric.buildActivity(Activity::class.java).setup().get() - // throw when resolving `ACTION_APPLICATION_DETAILS_SETTINGS` - shadowOf(realActivity.application).checkActivities(true) - assertDoesNotThrow { realActivity.openAppSettingsScreen() } + Robolectric.buildActivity(Activity::class.java).use { controller -> + val realActivity = controller.setup().get() + // throw when resolving `ACTION_APPLICATION_DETAILS_SETTINGS` + shadowOf(realActivity.application).checkActivities(true) + assertDoesNotThrow { realActivity.openAppSettingsScreen() } + } } @Test diff --git a/common/android/build.gradle.kts b/common/android/build.gradle.kts index 3f7bf529f2fb..c7a82bea300b 100644 --- a/common/android/build.gradle.kts +++ b/common/android/build.gradle.kts @@ -31,7 +31,9 @@ dependencies { testImplementation(libs.robolectric) testImplementation(libs.androidx.test.junit) + testFixturesImplementation(libs.androidx.appcompat) testFixturesImplementation(libs.androidx.test.core) testFixturesImplementation(libs.jakewharton.timber) + testFixturesImplementation(libs.junit.vintage.engine) testFixturesImplementation(libs.robolectric) } diff --git a/common/android/src/testFixtures/java/com/ichi2/testutils/Robolectric.kt b/common/android/src/testFixtures/java/com/ichi2/testutils/Robolectric.kt index 19d4136c794e..a8951e82bbd9 100644 --- a/common/android/src/testFixtures/java/com/ichi2/testutils/Robolectric.kt +++ b/common/android/src/testFixtures/java/com/ichi2/testutils/Robolectric.kt @@ -5,8 +5,15 @@ package com.ichi2.testutils import android.app.Activity import android.content.Context import android.content.pm.ActivityInfo +import androidx.appcompat.app.AppCompatActivity import androidx.test.core.app.ApplicationProvider +import org.junit.runners.model.MultipleFailureException +import org.robolectric.RuntimeEnvironment import org.robolectric.Shadows.shadowOf +import org.robolectric.android.controller.ActivityController +import org.robolectric.shadow.api.Shadow +import org.robolectric.shadows.ShadowActivity +import org.robolectric.util.ReflectionHelpers /** * Methods for interacting with Robolectric @@ -26,4 +33,116 @@ object Robolectric { } shadowOf(context.packageManager).addOrUpdateActivity(activityInfo) } + + /** + * Activities still registered with Robolectric's ActivityThread (`mActivities`). + * + * A retained AppCompat delegate can recreate its activity on a later night-mode change. + * Removing a registration alone does not dispose that delegate. + */ + fun liveActivities(): List { + // ActivityThread is not on the library compile classpath; use RuntimeEnvironment + reflection. + val activityThread = RuntimeEnvironment.getActivityThread() ?: return emptyList() + + @Suppress("UNCHECKED_CAST") + val records = + ReflectionHelpers.getField>(activityThread, "mActivities") ?: return emptyList() + return records.values.mapNotNull { record -> + record ?: return@mapNotNull null + ReflectionHelpers.getField(record, "activity") + } + } + + /** + * Closes a controller, reporting lifecycle failures after removing its registrations. + * + * [ActivityController.close] follows the lifecycle and tolerates already-closed controllers. + * It does nothing if `onCreate` never completed, so that case also needs explicit cleanup. + */ + fun closeActivity(controller: ActivityController<*>) { + val activity = controller.get() + val closeFailure = runCatching { controller.close() }.exceptionOrNull() + if (closeFailure == null && liveActivities().none { it === activity }) return + + val cleanupFailure = runCatching { disposeActivityRegistrations(activity) }.exceptionOrNull() + MultipleFailureException.assertEmpty(listOfNotNull(closeFailure, cleanupFailure)) + } + + private fun disposeActivityRegistrations(activity: Activity) { + try { + // onDestroy may fail before AppCompatActivity reaches its delegate. AppCompat keeps + // its own registry, independent of ActivityThread.mActivities. + (activity as? AppCompatActivity)?.delegate?.onDestroy() + } finally { + forceRemoveActivity(activity) + } + } + + /** Removes [activity] from ActivityThread.mActivities if still present. */ + fun forceRemoveActivity(activity: Activity) { + val activities = activityThreadActivities() ?: return + val tokens = + activities.entries + .mapNotNull { (token, record) -> + val recorded = + try { + ReflectionHelpers.getField(record, "activity") + } catch (_: Exception) { + null + } + if (recorded === activity) token else null + } + tokens.forEach { activities.remove(it) } + } + + /** Clears every ActivityThread.mActivities entry (last-resort isolation). */ + fun clearAllLiveActivities() { + activityThreadActivities()?.clear() + } + + private fun activityThreadActivities(): MutableMap? { + val activityThread = RuntimeEnvironment.getActivityThread() ?: return null + val activitiesField = + try { + ReflectionHelpers.getField(activityThread, "mActivities") + } catch (_: Exception) { + null + } ?: return null + @Suppress("UNCHECKED_CAST") + return activitiesField as? MutableMap + } + + /** + * Fails if any Robolectric-managed activities are still alive. + * Destroys remnants before throwing so later tests stay isolated. + */ + fun assertNoLiveActivities(whenWhat: String) { + val live = liveActivities() + if (live.isEmpty()) return + val names = live.map { it.javaClass.name } + val leak = + AssertionError( + "Robolectric leftover activities after $whenWhat: $names. " + + "Close ActivityControllers in the test that created them " + + "(saveControllerForCleanup / ActivityController.use / close). " + + "Retained AppCompat delegates can recreate activities on later night-mode changes.", + ) + val failures = mutableListOf(leak) + for (activity in live) { + try { + val shadow = Shadow.extract(activity) + val controller = ReflectionHelpers.getField?>(shadow, "controller") + if (controller != null) { + closeActivity(controller) + } else { + disposeActivityRegistrations(activity) + } + } catch (failure: Throwable) { + failures.add(failure) + } + } + // Last resort: if reflection identity matching failed, drop the whole map. + clearAllLiveActivities() + MultipleFailureException.assertEmpty(failures) + } } From b4a63d6996220a92154cf46898d9ae4e87bc8fc0 Mon Sep 17 00:00:00 2001 From: Mike Hardy Date: Tue, 29 Sep 2026 23:37:30 -0500 Subject: [PATCH 15/43] build(lint): enable WrongThread in JVM libraries checkDependencies rejects setting WrongThread to informational while :common leaves that Android check disabled. Informational enables it without failing :common:lint. Co-authored-by: Cursor --- buildSrc/src/main/kotlin/ankidroid.jvm.library.gradle.kts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/buildSrc/src/main/kotlin/ankidroid.jvm.library.gradle.kts b/buildSrc/src/main/kotlin/ankidroid.jvm.library.gradle.kts index 8f519ac9dbcf..1ae1fba591e5 100644 --- a/buildSrc/src/main/kotlin/ankidroid.jvm.library.gradle.kts +++ b/buildSrc/src/main/kotlin/ankidroid.jvm.library.gradle.kts @@ -39,5 +39,11 @@ if (path != ":lint-rules") { // > just be enabled in a dependent project; they must also be enabled // > in all the libraries the project depends on.) enable += "LogConditional" + + // WrongThread is off in this non-Android lint client. lint-release.xml sets it + // informational, and checkDependencies rejects raising a disabled library issue. + // Informational both enables it and keeps :common:lint from failing the build. + // ThreadConstraint is already enabled here. + informational += "WrongThread" } } From 3845a47ae9f067331c84d38e5248dc1779728797 Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Sat, 18 Jul 2026 03:40:03 +0530 Subject: [PATCH 16/43] fix(tts): prefer an installed voice for locale-only tags For a locale-only tag such as {{tts fr_FR:Front}}, voiceForTag fell back on the first voice matching the language without checking availability. Android can list voices flagged KEY_FEATURE_NOT_INSTALLED, so an uninstalled voice could be chosen and fail on playback with ERROR_NOT_INSTALLED_YET even when an installed voice for that language was available. The language fallback now prefers an installed voice, only settling for an unavailable one when nothing else matches. The fix and its tests come from David Allison's review suggestion, generated with GPT-6. Co-authored-by: David Allison <62114487+david-allison@users.noreply.github.com> Assisted-by: GPT-6 [fix and tests] --- .../java/com/ichi2/anki/AndroidTtsPlayer.kt | 4 +- .../com/ichi2/anki/AndroidTtsPlayerTest.kt | 88 ++++++++++++++++++- 2 files changed, 90 insertions(+), 2 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AndroidTtsPlayer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AndroidTtsPlayer.kt index 03a94c8c82fe..3c9ed3d2bfda 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AndroidTtsPlayer.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AndroidTtsPlayer.kt @@ -93,7 +93,9 @@ class AndroidTtsPlayer( TtsVoices.ttsEngine?.let { defaultEngine -> getOrCreateTts(defaultEngine) } } - override fun getAvailableVoices(): List = this.voices + // Prefer installed voices for language fallback without changing the shared matcher. + // Keep uninstalled voices selectable: an explicit request can trigger their download. + override fun getAvailableVoices(): List = voices.sortedBy { it.unavailable() } /** * Returns a ready [TextToSpeech] bound to [engine], creating and caching one if necessary. diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/AndroidTtsPlayerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/AndroidTtsPlayerTest.kt index 768d2e4ba48e..c9c87633c796 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/AndroidTtsPlayerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/AndroidTtsPlayerTest.kt @@ -7,8 +7,10 @@ import android.speech.tts.TextToSpeech import android.speech.tts.UtteranceProgressListener import android.speech.tts.Voice import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.dialogs.viewmodel.play import com.ichi2.anki.libanki.TTSTag import com.ichi2.anki.libanki.TtsPlayer.TtsCompletionStatus +import com.ichi2.anki.libanki.TtsVoiceMatch import io.mockk.every import io.mockk.mockk import io.mockk.slot @@ -22,6 +24,7 @@ import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.equalTo import org.hamcrest.Matchers.hasItem import org.hamcrest.Matchers.not +import org.hamcrest.Matchers.nullValue import org.junit.Test import org.junit.runner.RunWith import java.util.Locale @@ -30,6 +33,80 @@ import kotlin.time.Duration.Companion.milliseconds /** Test for [AndroidTtsPlayer] */ @RunWith(AndroidJUnit4::class) class AndroidTtsPlayerTest { + @Test + fun `locale fallback prefers the first installed voice`() { + val unavailable = frenchVoice("unavailable", installed = false) + val first = frenchVoice("first") + val second = frenchVoice("second") + val player = AndroidTtsPlayer(listOf(unavailable, first, second)) + + assertThat(player.voiceForTag(frenchTag()), equalTo(TtsVoiceMatch(first, rank = -100))) + } + + @Test + fun `locale fallback keeps the first unavailable voice when no matching voice is installed`() { + val first = frenchVoice("first", installed = false) + val second = frenchVoice("second", installed = false) + val english = AndroidTtsVoice(fakeVoice("english", Locale.US), ENGINE_A) + val player = AndroidTtsPlayer(listOf(first, english, second)) + + assertThat(player.voiceForTag(frenchTag()), equalTo(TtsVoiceMatch(first, rank = -100))) + } + + @Test + fun `requested voice order takes precedence over availability`() { + val installed = frenchVoice("installed") + val unavailable = frenchVoice("unavailable", installed = false) + val player = AndroidTtsPlayer(listOf(installed, unavailable)) + val tag = frenchTag("missing", unavailable.name, installed.name) + + assertThat(player.voiceForTag(tag), equalTo(TtsVoiceMatch(unavailable, rank = -1))) + } + + @Test + fun `unmatched requested voices fall back to an installed voice`() { + val unavailable = frenchVoice("unavailable", installed = false) + val installed = frenchVoice("installed") + val player = AndroidTtsPlayer(listOf(unavailable, installed)) + + assertThat(player.voiceForTag(frenchTag("missing")), equalTo(TtsVoiceMatch(installed, rank = -100))) + } + + @Test + fun `no match when no voice shares the language`() { + val english = AndroidTtsVoice(fakeVoice("english", Locale.US), ENGINE_A) + val player = AndroidTtsPlayer(listOf(english)) + + assertThat(player.voiceForTag(frenchTag()), nullValue()) + } + + @Test + fun `no match when there are no voices`() { + val player = AndroidTtsPlayer(emptyList()) + + assertThat(player.voiceForTag(frenchTag()), nullValue()) + } + + @Test + fun `voice preview selects an unavailable voice so the engine can download it`() = + runBlocking { + val installed = frenchVoice("installed") + val unavailable = frenchVoice("unavailable", installed = false) + val tts = fakeTts() + val player = AndroidTtsPlayer(listOf(installed, unavailable)) { tts } + player.init(this) + + try { + val result = withTimeout(PLAYBACK_TIMEOUT_MS.milliseconds) { player.play("bonjour", unavailable) } + + assertThat(result.success, equalTo(true)) + verify(exactly = 1) { tts.voice = unavailable.voice } + verify(exactly = 1) { tts.speak(any(), any(), any(), any()) } + } finally { + player.close() + } + } + @Test fun `playback is routed to the engine which owns the voice`() { val test = playerTest() @@ -97,6 +174,14 @@ class AndroidTtsPlayerTest { assertThat(factoryCalls.count { it == ENGINE_FAILING }, equalTo(1)) } + private fun frenchVoice( + name: String, + installed: Boolean = true, + ) = AndroidTtsVoice(fakeVoice(name, Locale.FRANCE, installed), ENGINE_A) + + private fun frenchTag(vararg voices: String) = + TTSTag(fieldText = "bonjour", lang = "fr_FR", voices = voices.toList(), speed = null, otherArgs = emptyList()) + /** Builds a player backed by two engines, each owning a single voice */ private fun playerTest(): PlayerTestFixture { val ttsA = fakeTts() @@ -162,11 +247,12 @@ class AndroidTtsPlayerTest { private fun fakeVoice( voiceName: String, voiceLocale: Locale, + installed: Boolean = true, ): Voice = mockk(relaxed = true) { every { name } returns voiceName every { locale } returns voiceLocale - every { features } returns emptySet() + every { features } returns if (installed) emptySet() else setOf(TextToSpeech.Engine.KEY_FEATURE_NOT_INSTALLED) } companion object { From 91d120ff0bb6326c49922f35b14970db2e7b32ee Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 27 Sep 2026 00:36:29 +0100 Subject: [PATCH 17/43] fix(reviewer): stop render recovery when the activity is closing An Activity can report `STARTED` after a `finish()` call: if a WebView renderer callback executed, if this opens a dialog, it crashes. In these exceptional cases, skip displaying the dialog. Fixes 6244 Assisted-by: GPT-6 --- .../cardviewer/OnRenderProcessGoneDelegate.kt | 7 +++ .../OnRenderProcessGoneDelegateTest.kt | 52 +++++++++++++++++++ 2 files changed, 59 insertions(+) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/OnRenderProcessGoneDelegate.kt b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/OnRenderProcessGoneDelegate.kt index 05fd97a54ad8..98a1eca274b6 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/OnRenderProcessGoneDelegate.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/OnRenderProcessGoneDelegate.kt @@ -56,6 +56,13 @@ open class OnRenderProcessGoneDelegate( Timber.e("WebView Renderer process terminated. Crashed: %b", detail.didCrash()) target.destroyWebViewFrame() + // #6244: finish() can invalidate the window before the lifecycle drops below STARTED. + if (target.isFinishing || target.isDestroyed) { + // don't show a dialog in this case + Timber.w("Activity is finishing or destroyed; skipping WebView recovery/crash dialog") + return true + } + // Only show one message per branch when { !canRecoverFromWebViewRendererCrash() -> { diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/OnRenderProcessGoneDelegateTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/OnRenderProcessGoneDelegateTest.kt index f4a5570f0365..20e1a39bd4fe 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/OnRenderProcessGoneDelegateTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/OnRenderProcessGoneDelegateTest.kt @@ -58,6 +58,56 @@ class OnRenderProcessGoneDelegateTest { verify(mock, times(1).description("After the dialog, the screen should be closed")).finish() } + @Test + fun crashLoopWhileFinishingDoesNotShowDialog_issue6244() { + val mock = viewer + val delegate = getInstance(mock) + callOnRenderProcessGone(delegate) + + // finish() can be called before the lifecycle drops below STARTED. + doReturn(true).whenever(mock).isFinishing + callOnRenderProcessGone(delegate) + + assertThat(delegate.displayedDialog, equalTo(false)) + verify(mock, times(2)).destroyWebViewFrame() + verify(mock, times(1)).recreateWebViewFrame() + verify(mock, times(1)).displayCardQuestion() + verify(mock.writeLock, times(2)).unlock() + } + + @Test + fun crashWhileFinishingOnlyDestroysWebView() { + val mock = viewer + val delegate = getInstance(mock) + doReturn(true).whenever(mock).isFinishing + + callOnRenderProcessGone(delegate) + + assertThat(delegate.displayedToast, equalTo(false)) + assertThat(delegate.displayedDialog, equalTo(false)) + verify(mock).destroyWebViewFrame() + verify(mock, never()).recreateWebViewFrame() + verify(mock, never()).displayCardQuestion() + verify(mock.writeLock).unlock() + } + + @Test + fun crashAfterDestructionDoesNotRecreateWebView() { + // An activity can be destroyed without finishing, e.g. during a configuration change. + val mock = getViewer(Lifecycle.State.DESTROYED) + val delegate = getInstance(mock) + doReturn(true).whenever(mock).isDestroyed + + callOnRenderProcessGone(delegate) + + verify(mock).destroyWebViewFrame() + verify(mock, never()).recreateWebViewFrame() + verify(mock, never()).displayCardQuestion() + assertThat(delegate.displayedToast, equalTo(false)) + assertThat(delegate.displayedDialog, equalTo(false)) + verify(mock.writeLock).unlock() + } + @Test fun secondCallDoesNothingIfMinimised() { val mock = minimisedViewer @@ -142,6 +192,8 @@ class OnRenderProcessGoneDelegateTest { doReturn(mockWebView).whenever(mock).webView doReturn(mock(Card::class.java)).whenever(mock).currentCard doReturn(lifecycleOf(state)).whenever(mock).lifecycle + doReturn(false).whenever(mock).isFinishing + doReturn(false).whenever(mock).isDestroyed doNothing().whenever(mock).destroyWebViewFrame() doNothing().whenever(mock).recreateWebViewFrame() doNothing().whenever(mock).displayCardQuestion() From e73de7c41a4ed3a441dc95627bffa9d93abccc38 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 26 Sep 2026 17:49:41 +0100 Subject: [PATCH 18/43] test: screens with keyboard open * Manage Fields * Deck Picker + Study Options Part of 21794 Assisted-by: GPT-6 --- .../java/com/ichi2/anki/DeckPickerTabletScreenshotTest.kt | 8 ++++++++ .../com/ichi2/anki/NoteTypeFieldEditorScreenshotTest.kt | 8 ++++++++ .../java/com/ichi2/anki/StudyOptionsScreenshotTest.kt | 8 ++++++++ 3 files changed, 24 insertions(+) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTabletScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTabletScreenshotTest.kt index 2247e83466ea..ad4704e00362 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTabletScreenshotTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/DeckPickerTabletScreenshotTest.kt @@ -3,6 +3,7 @@ package com.ichi2.anki import com.ichi2.testutils.BackupManagerTestUtilities +import com.ichi2.testutils.simulateKeyboard import org.junit.After import org.junit.Before import org.junit.Test @@ -24,6 +25,13 @@ class DeckPickerTabletScreenshotTest : ScreenshotTest() { BackupManagerTestUtilities.reset() } + @Test + fun deckOverviewKeyboardOpen() = + withDeckPicker(deckCount = 1, withCards = true) { deckPicker -> + deckPicker.simulateKeyboard() + captureScreen("keyboard_open") + } + @Test fun deckPickerWith30Decks() = withDeckPicker(deckCount = 30, withCards = true) { deckPicker -> diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/NoteTypeFieldEditorScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/NoteTypeFieldEditorScreenshotTest.kt index a35cbf8da304..742ebcbdb52c 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/NoteTypeFieldEditorScreenshotTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/NoteTypeFieldEditorScreenshotTest.kt @@ -5,6 +5,7 @@ package com.ichi2.anki import android.content.Intent import androidx.test.core.app.ActivityScenario import com.ichi2.anki.dialogs.NoteTypeFieldEditorContextMenu.NoteTypeFieldEditorContextMenuAction +import com.ichi2.testutils.simulateKeyboard import org.junit.Test /** @@ -19,6 +20,13 @@ class NoteTypeFieldEditorScreenshotTest : ScreenshotTest() { captureScreen("base") } + @Test + fun keyboardOpen() = + withNoteTypeFieldEditor { activity -> + activity.simulateKeyboard() + captureScreen("keyboard_open") + } + @Test fun deleteFieldDialog() = withNoteTypeFieldEditor { activity -> diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/StudyOptionsScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/StudyOptionsScreenshotTest.kt index ae6fc6739e8e..814b4a2ea270 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/StudyOptionsScreenshotTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/StudyOptionsScreenshotTest.kt @@ -13,6 +13,7 @@ import androidx.core.view.WindowInsetsCompat.Type.displayCutout import androidx.core.view.WindowInsetsCompat.Type.navigationBars import androidx.core.view.WindowInsetsCompat.Type.statusBars import com.ichi2.testutils.insetsOf +import com.ichi2.testutils.simulateKeyboard import com.ichi2.utils.dp import org.junit.Test import org.robolectric.RuntimeEnvironment @@ -30,6 +31,13 @@ class StudyOptionsScreenshotTest : ScreenshotTest() { captureScreen("with_counts") } + @Test + fun studyOptionsKeyboardOpen() = + withStudyOptions { activity -> + activity.simulateKeyboard() + captureScreen("keyboard_open") + } + /** * Landscape with 3-button navigation: the navigation bar is a side inset and the camera * cutout is on the opposite side. The toolbar and content clear both sides. From 4767ddd23e60c484ad5b4fe3b20cba1d3b3f7580 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 26 Sep 2026 18:35:33 +0100 Subject: [PATCH 19/43] fix(fields): keep content above keyboard FAB is still in the background Part of 21794 Assisted-by: GPT-6 --- AnkiDroid/src/main/java/com/ichi2/anki/NoteTypeFieldEditor.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NoteTypeFieldEditor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NoteTypeFieldEditor.kt index b24855f06423..4ae9410e5dd8 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/NoteTypeFieldEditor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/NoteTypeFieldEditor.kt @@ -22,6 +22,7 @@ import androidx.core.os.BundleCompat import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat.Type.displayCutout +import androidx.core.view.WindowInsetsCompat.Type.ime import androidx.core.view.WindowInsetsCompat.Type.systemBars import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding @@ -103,7 +104,7 @@ class NoteTypeFieldEditor : AnkiActivity(R.layout.activity_note_type_field_edito enableToolbar() enableEdgeToEdge(statusBarStyle = SystemBarStyle.dark(Color.TRANSPARENT)) ViewCompat.setOnApplyWindowInsetsListener(binding.rootLayout) { _, insets -> - val constraints = insets.getInsets(systemBars() or displayCutout()) + val constraints = insets.getInsets(systemBars() or displayCutout() or ime()) binding.toolbarContainer.updatePadding(left = constraints.left, right = constraints.right, top = constraints.top) binding.btnAdd.updateLayoutParams { bottomMargin = constraints.bottom + 32.dp.toPx(this@NoteTypeFieldEditor) From 32c59b4fbb3c90771258bef90b9889536aabbbeb Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 26 Sep 2026 18:36:44 +0100 Subject: [PATCH 20/43] fix(study-options): keep content above keyboard Content was covered when entering custom study options Part of 21794 Assisted-by: GPT-6 --- .../src/main/java/com/ichi2/anki/StudyOptionsFragment.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt index c4f18b8e9b03..dbfbab4c7e60 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/StudyOptionsFragment.kt @@ -24,6 +24,7 @@ import androidx.core.text.parseAsHtml import androidx.core.view.MenuProvider import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat.Type.displayCutout +import androidx.core.view.WindowInsetsCompat.Type.ime import androidx.core.view.WindowInsetsCompat.Type.systemBars import androidx.core.view.doOnAttach import androidx.core.view.isVisible @@ -125,13 +126,13 @@ class StudyOptionsFragment : refreshInterface() } - /** Keeps the content clear of the system bars and any display cutout. */ + /** Keeps the content clear of the system bars, display cutout and keyboard. */ private fun setupContentInsets(view: View) { view.doOnApplyWindowInsets { v, insets, _ -> // Both hosts display this fragment below a toolbar, so don't update the top padding. // Note: the left-padding is 0 when fragmented - handled by DeckPicker - val bars = insets.getInsets(systemBars() or displayCutout()) - v.updatePadding(left = bars.left, right = bars.right, bottom = bars.bottom) + val contentInsets = insets.getInsets(systemBars() or displayCutout() or ime()) + v.updatePadding(left = contentInsets.left, right = contentInsets.right, bottom = contentInsets.bottom) } // the view may be added after the insets were dispatched (returning from the review // reminders screen), so request them again From a462515563f4fad7d885f64ee1c10be8ae653b6d Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:31:07 +0100 Subject: [PATCH 21/43] fix(reviewer): fix MathJax text scaling WebView text-only zoom (`textZoom`) does not scale em-based layouts (used by MathJax). It would have required fragile, font-specific workarounds to fix on the MathJax level: * `text-size-adjust` had no effect. * `font-size-adjust` required compensation for each font. Instead, set `textZoom` to 100 (from 130 [system-dependent]) and scale using CSS zoom. This scales the text size and layout together. BREAKING CHANGE: Some layouts are changed by this, see my list on 20776 Fixes 20776 Assisted-by: GPT-6 --- .../main/java/com/ichi2/anki/AbstractFlashcardViewer.kt | 2 ++ .../ichi2/anki/cardviewer/AndroidCardRenderContext.kt | 2 +- .../java/com/ichi2/anki/cardviewer/CardAppearance.kt | 9 ++++++++- .../java/com/ichi2/anki/previewer/CardViewerFragment.kt | 2 ++ .../java/com/ichi2/anki/previewer/PreviewerHelpers.kt | 4 ++++ 5 files changed, 17 insertions(+), 2 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt index 67c448947a0d..2673355c8004 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AbstractFlashcardViewer.kt @@ -1061,6 +1061,8 @@ abstract class AbstractFlashcardViewer : MyWebView(this).apply { scrollBarStyle = View.SCROLLBARS_OUTSIDE_OVERLAY with(settings) { + // Scale the whole card with CSS instead; text-only zoom breaks MathJax layout. + textZoom = 100 displayZoomControls = false builtInZoomControls = true setSupportZoom(true) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/AndroidCardRenderContext.kt b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/AndroidCardRenderContext.kt index a0f007eac77e..ad3a9b2bf769 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/AndroidCardRenderContext.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/AndroidCardRenderContext.kt @@ -118,7 +118,7 @@ class AndroidCardRenderContext( typeAnswer: TypeAnswer, ): AndroidCardRenderContext { val preferences = context.sharedPrefs() - val cardAppearance = CardAppearance.create(ReviewerCustomFonts(), preferences) + val cardAppearance = CardAppearance.create(ReviewerCustomFonts(), preferences, context.resources.configuration.fontScale) val cardHtmlTemplate = CardTemplate.load(context) val showAudioPlayButtons = !col.config.getBool(ConfigKey.Bool.HIDE_AUDIO_PLAY_BUTTONS) return AndroidCardRenderContext( diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/CardAppearance.kt b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/CardAppearance.kt index 32598206120e..f72fb69eebd7 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/CardAppearance.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/cardviewer/CardAppearance.kt @@ -15,9 +15,15 @@ class CardAppearance( private val cardZoom: Int, private val imageZoom: Int, private val centerVertically: Boolean, + private val fontScale: Float, ) { /** Below could be in a better abstraction. */ fun appendCssStyle(style: StringBuilder) { + // Preserve the system font size while scaling text and em-based layout together. + if (fontScale != 1f) { + style.append("html { zoom: $fontScale }\n") + } + // Zoom cards if (cardZoom != 100) { style.append("body { zoom: ${cardZoom / 100.0} }\n") @@ -68,11 +74,12 @@ class CardAppearance( fun create( customFonts: ReviewerCustomFonts, preferences: SharedPreferences, + fontScale: Float, ): CardAppearance { val cardZoom = preferences.getInt("cardZoom", 100) val imageZoom = preferences.getInt("imageZoom", 100) val centerVertically = preferences.getBoolean("centerVertically", false) - return CardAppearance(customFonts, cardZoom, imageZoom, centerVertically) + return CardAppearance(customFonts, cardZoom, imageZoom, centerVertically, fontScale) } fun fixBoldStyle(content: String): String { 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 f9192b41de47..60d59b8beaf9 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/CardViewerFragment.kt @@ -191,6 +191,8 @@ abstract class CardViewerFragment( scrollBars = View.SCROLLBARS_OUTSIDE_OVERLAY setAcceptThirdPartyCookies(true) with(settings) { + // Scale the whole card with CSS instead; text-only zoom breaks MathJax layout. + textZoom = 100 javaScriptEnabled = true loadWithOverviewMode = true builtInZoomControls = true diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerHelpers.kt b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerHelpers.kt index 34bd7a843c03..750485314776 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerHelpers.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/previewer/PreviewerHelpers.kt @@ -40,6 +40,9 @@ fun stdHtml( val canvasColor = ThemeUtils.getThemeAttrColor(context, android.R.attr.colorBackground).toRGBHex() val fgColor = ThemeUtils.getThemeAttrColor(context, android.R.attr.textColor).toRGBHex() val colors = ":root$rootNightMode { --canvas: $canvasColor; --fg: $fgColor; }" + // Preserve the system font size while scaling text and em-based layout together. + val fontScale = context.resources.configuration.fontScale + val fontScaleCss = if (fontScale != 1f) "html { zoom: $fontScale }" else "" val jsAssets: List = listOf( @@ -65,6 +68,7 @@ fun stdHtml( From cde9d6906731fc731da0b6e6317eb276a9e48111 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:31:21 +0100 Subject: [PATCH 22/43] test(reviewer): text scaling Part of 20776 Assisted-by: GPT-6 --- .../anki/cardviewer/CardTextScaleTest.kt | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/CardTextScaleTest.kt diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/CardTextScaleTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/CardTextScaleTest.kt new file mode 100644 index 000000000000..1b9aa58b43d4 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/cardviewer/CardTextScaleTest.kt @@ -0,0 +1,98 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.cardviewer + +import android.content.res.Configuration +import android.webkit.WebView +import androidx.core.content.edit +import androidx.core.view.children +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.R +import com.ichi2.anki.ReviewerTest +import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.browser.IdsFile +import com.ichi2.anki.common.preferences.sharedPrefs +import com.ichi2.anki.previewer.CardViewerActivity +import com.ichi2.anki.previewer.PreviewerFragment +import com.ichi2.anki.previewer.stdHtml +import com.ichi2.anki.workarounds.SafeWebViewLayout +import com.ichi2.testutils.createTransientDirectory +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import kotlin.test.assertEquals + +/** Issue 20776: MathJax needs text and em-based layout to scale together. */ +@RunWith(AndroidJUnit4::class) +class CardTextScaleTest : RobolectricTest() { + override fun getCollectionStorageMode() = CollectionStorageMode.IN_MEMORY_WITH_MEDIA + + @Test + fun `legacy cards preserve system font scale independently of card and image zoom`() { + val card = addBasicNote("\\(\\sqrt{a^2}\\)").firstCard() + val preferences = targetContext.sharedPrefs() + preferences.edit { + putInt("cardZoom", 80) + putInt("imageZoom", 120) + } + for (fontScale in listOf(0.85f, 1f, 1.3f)) { + val context = contextWithFontScale(fontScale) + val renderer = AndroidCardRenderContext.createInstance(context, col, TypeAnswer.createInstance(preferences)) + val html = renderer.renderCard(col, card, SingleCardSide.FRONT).html + + assertEquals(fontScale.toDouble(), cssZoom(html, "html"), 0.000001) + assertEquals(0.8, cssZoom(html, "body")) + assertEquals(1.2, cssZoom(html, "img")) + } + } + + @Test + fun `shared reviewer and previewer HTML preserves system font scale`() { + for (fontScale in listOf(0.85f, 1f, 1.3f)) { + val html = stdHtml(contextWithFontScale(fontScale)) + + assertEquals(fontScale.toDouble(), cssZoom(html, "html"), 0.000001) + } + } + + @Test + fun `legacy reviewer disables text-only zoom`() { + addBasicNote() + val reviewer = ReviewerTest.startReviewer(this) + + assertEquals(100, reviewer.webView!!.settings.textZoom) + } + + @Test + fun `previewer disables text-only zoom including after WebView recreation`() { + val note = addBasicNote() + val intent = PreviewerFragment.getIntent(targetContext, IdsFile(createTransientDirectory(), note.cardIds(col)), 0) + Robolectric.buildActivity(CardViewerActivity::class.java, intent).use { controller -> + controller.setup() + val activity = controller.get() + val layout = activity.findViewById(R.id.web_view_layout) + val webView = layout.children.filterIsInstance().single() + assertEquals(100, webView.settings.textZoom) + + webView.settings.textZoom = 130 + (activity.fragment as PreviewerFragment).onWebViewRecreated(webView) + assertEquals(100, webView.settings.textZoom) + } + } + + private fun contextWithFontScale(fontScale: Float) = + targetContext.createConfigurationContext( + Configuration(targetContext.resources.configuration).apply { this.fontScale = fontScale }, + ) + + /** A missing zoom rule leaves the element at its default scale. */ + private fun cssZoom( + html: String, + selector: String, + ): Double = + Regex("""\b$selector\s*\{\s*zoom:\s*([\d.]+)""") + .find(html) + ?.groupValues + ?.get(1) + ?.toDouble() ?: 1.0 +} From 9651dd33c67447228037690f0f530e23a1afe31b Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:09:52 +0100 Subject: [PATCH 23/43] fix(snackbar): retain pending snackbars until shown Keeps a strong reference to a pending snackbar so it can't be GCed The reference is removed when the snackbar is shown or dismissed (without clearing a newer replacement). Fixes 22076 Assisted-by: GPT-6 --- .../java/com/ichi2/anki/snackbar/Snackbars.kt | 39 ++++++ AnkiDroid/src/main/res/values/ids.xml | 2 + .../com/ichi2/anki/snackbar/SnackbarsTest.kt | 117 ++++++++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/snackbar/SnackbarsTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/snackbar/Snackbars.kt b/AnkiDroid/src/main/java/com/ichi2/anki/snackbar/Snackbars.kt index 554c90f27e21..bb9e1f3f323c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/snackbar/Snackbars.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/snackbar/Snackbars.kt @@ -227,10 +227,49 @@ fun View.showSnackbar( snackbar.anchorView = null } + snackbar.retainUntilShown(rootView) snackbar.show() return snackbar } +/** + * Keeps the latest pending snackbar alive on [host] until shown or dismissed. + * + * Workaround to fix losing a pending snackbar after a GC (#22076). + * + * See [SnackbarManager.show and SnackbarRecord](https://github.com/material-components/material-components-android/blob/1.14.0/lib/java/com/google/android/material/snackbar/SnackbarManager.java). + */ +private fun Snackbar.retainUntilShown(host: View) { + host.retainPendingSnackbar(this) + addCallback( + object : Snackbar.Callback() { + override fun onShown(snackbar: Snackbar) = release(snackbar) + + override fun onDismissed( + snackbar: Snackbar, + event: Int, + ) = release(snackbar) + + private fun release(snackbar: Snackbar) { + host.releasePendingSnackbar(snackbar) + snackbar.removeCallback(this) + } + }, + ) +} + +/** Stores a strong reference to [snackbar] on this view, replacing any previously retained snackbar. */ +private fun View.retainPendingSnackbar(snackbar: Snackbar) { + setTag(R.id.pending_snackbar, snackbar) +} + +/** Releases the strong reference to [snackbar] only if it is still the retained snackbar. */ +private fun View.releasePendingSnackbar(snackbar: Snackbar) { + // A callback from an older snackbar must not release its replacement. + if (getTag(R.id.pending_snackbar) !== snackbar) return + setTag(R.id.pending_snackbar, null) +} + /** * Show a snackbar. * diff --git a/AnkiDroid/src/main/res/values/ids.xml b/AnkiDroid/src/main/res/values/ids.xml index f70272303cae..e2d4feb53b21 100644 --- a/AnkiDroid/src/main/res/values/ids.xml +++ b/AnkiDroid/src/main/res/values/ids.xml @@ -43,4 +43,6 @@ + + \ No newline at end of file diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/snackbar/SnackbarsTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/snackbar/SnackbarsTest.kt new file mode 100644 index 000000000000..59f37cd470ed --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/snackbar/SnackbarsTest.kt @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.snackbar + +import android.accessibilityservice.AccessibilityServiceInfo +import android.annotation.SuppressLint +import android.content.Context +import android.os.Looper +import android.view.ContextThemeWrapper +import android.view.View +import android.view.accessibility.AccessibilityManager +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.android.material.snackbar.Snackbar +import com.ichi2.anki.EmptyApplicationCategory +import com.ichi2.anki.R +import com.ichi2.testutils.EmptyApplication +import org.junit.Before +import org.junit.Test +import org.junit.experimental.categories.Category +import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertSame +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +@Config(application = EmptyApplication::class) +@Category(EmptyApplicationCategory::class) +class SnackbarsTest { + private val context = ContextThemeWrapper(ApplicationProvider.getApplicationContext(), R.style.Theme_Light) + private val host = CoordinatorLayout(context) + private val looper = shadowOf(Looper.getMainLooper()) + + @Before + fun disableSnackbarAnimations() { + shadowOf(context.getSystemService(AccessibilityManager::class.java)) + .setEnabledAccessibilityServiceList(listOf(AccessibilityServiceInfo())) + } + + @Test + @SuppressLint("DirectSnackbarMakeUsage") // Verify Material's queue without AnkiDroid's wrapper. + fun `Material only queues the latest pending snackbar`() { + // Material 1.14.0 has one current slot and one pending slot, so retaining only + // the latest pending snackbar is sufficient. Exercise Material directly here. + val first = Snackbar.make(host, "First", Snackbar.LENGTH_INDEFINITE) + val second = Snackbar.make(host, "Second", Snackbar.LENGTH_INDEFINITE) + val third = Snackbar.make(host, "Third", Snackbar.LENGTH_INDEFINITE) + first.show() + looper.idle() + + second.show() + third.show() + + assertTrue(first.isShown) + assertFalse(second.isShownOrQueued) + assertTrue(third.isShownOrQueued) + + looper.idle() + assertSame(host, third.view.parent) + assertNull(second.view.parent) + } + + @Test + fun `pending snackbar is retained by the root until shown`() { + val child = View(context).also(host::addView) + val snackbar = child.showSnackbar("Message") + assertSame(snackbar, host.getTag(R.id.pending_snackbar)) + + looper.idle() + snackbar.view.layout(0, 0, 300, 60) + + assertNull(host.getTag(R.id.pending_snackbar)) + } + + @Test + fun `showing an older snackbar does not release its replacement`() { + val first = host.showSnackbar("First") + looper.idle() + val second = host.showSnackbar("Second") + assertSame(second, host.getTag(R.id.pending_snackbar)) + + // Showing the first snackbar must not release the second, which is still queued. + first.view.layout(0, 0, 300, 60) + assertSame(second, host.getTag(R.id.pending_snackbar)) + + // Finish dismissing the first and attach the second, without laying it out yet. + looper.idle() + assertSame(second, host.getTag(R.id.pending_snackbar)) + } + + @Test + fun `dismissing an older snackbar does not release its replacement`() { + host.showSnackbar("First") + looper.idle() + val second = host.showSnackbar("Second") + + looper.idle() + + assertSame(second, host.getTag(R.id.pending_snackbar)) + } + + @Test + fun `dismissal before layout releases the pending snackbar`() { + val snackbar = host.showSnackbar("Message") + looper.idle() + assertSame(snackbar, host.getTag(R.id.pending_snackbar)) + + snackbar.dismiss() + looper.idle() + + assertNull(host.getTag(R.id.pending_snackbar)) + } +} From feef8fbe6fa9d330961654bc907b8f9bdb52e8b4 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:28:51 +0100 Subject: [PATCH 24/43] fix(sync): fix crash - foreground service timeout `AppLifecycleObserver.onStop` blocked on the collection queue, This blocked WorkManager's timeout callback, crashing the app if `dataSync` timed out. ``` android.app.RemoteServiceException$ForegroundServiceDidNotStopInTimeException: A foreground service of type dataSync did not stop within its timeout: ComponentInfo{com.ichi2.anki/androidx.work.impl.foreground.SystemForegroundService} at android.app.ActivityThread.generateForegroundServiceDidNotStopInTimeException(ActivityThread.java:2875) at android.app.ActivityThread.throwRemoteServiceException(ActivityThread.java:2837) at android.app.ActivityThread.-$$Nest$mthrowRemoteServiceException(Unknown Source:0) ``` Fixes 19749 Assisted-by: GPT-6 --- .../com/ichi2/anki/AppLifecycleObserver.kt | 15 +++- .../ichi2/anki/AppLifecycleObserverTest.kt | 79 +++++++++++++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/AppLifecycleObserverTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AppLifecycleObserver.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AppLifecycleObserver.kt index c91ade3ac086..b985b5572da2 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AppLifecycleObserver.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AppLifecycleObserver.kt @@ -6,7 +6,10 @@ import android.content.Context import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.lifecycleScope import com.ichi2.widget.WidgetStatus +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch import timber.log.Timber class AppLifecycleObserver( @@ -15,9 +18,17 @@ class AppLifecycleObserver( override fun onStop(owner: LifecycleOwner) { super.onStop(owner) - if (owner.lifecycle.currentState != Lifecycle.State.DESTROYED && CollectionManager.isOpenUnsafe()) { + if (owner.lifecycle.currentState == Lifecycle.State.DESTROYED) return + + // A sync may hold the collection queue. Keep the main thread free to handle + // WorkManager's foreground service timeout while waiting for the collection. + owner.lifecycleScope.launch { try { - WidgetStatus.updateInBackground(context) + if (CollectionManager.withOpenColOrNull { true } == true) { + WidgetStatus.updateInBackground(context) + } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { Timber.w(e) } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/AppLifecycleObserverTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/AppLifecycleObserverTest.kt new file mode 100644 index 000000000000..4d1fb147586a --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/AppLifecycleObserverTest.kt @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.widget.WidgetStatus +import io.mockk.coEvery +import io.mockk.every +import io.mockk.just +import io.mockk.mockkObject +import io.mockk.runs +import io.mockk.unmockkObject +import io.mockk.verify +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.test.assertFalse + +@RunWith(AndroidJUnit4::class) +class AppLifecycleObserverTest : RobolectricTest() { + private val owner = TestLifecycleOwner() + + @Before + fun mockWidgetUpdates() { + mockkObject(WidgetStatus) + every { WidgetStatus.updateInBackground(any()) } just runs + } + + @After + fun cleanUp() { + owner.registry.currentState = Lifecycle.State.DESTROYED + unmockkObject(CollectionManager, WidgetStatus) + } + + /** Issue 19749: blocking on a sync here prevents WorkManager's service timeout callback. */ + @Test + fun `backgrounding during sync does not wait for the collection queue`() = + runTest { + CollectionManager.ensureOpen() + val syncFinished = CompletableDeferred() + mockkObject(CollectionManager) + coEvery { CollectionManager.withOpenColOrNull(any()) } coAnswers { + syncFinished.await() + true + } + + AppLifecycleObserver(targetContext).onStop(owner) + runCurrent() + verify(exactly = 0) { WidgetStatus.updateInBackground(any()) } + + syncFinished.complete(Unit) + advanceUntilIdle() + verify(exactly = 1) { WidgetStatus.updateInBackground(targetContext) } + } + + @Test + fun `backgrounding with a closed collection does not open it or update widgets`() = + runTest { + CollectionManager.ensureClosed() + AppLifecycleObserver(targetContext).onStop(owner) + advanceUntilIdle() + + verify(exactly = 0) { WidgetStatus.updateInBackground(any()) } + assertFalse(CollectionManager.isOpenUnsafe()) + } + + private class TestLifecycleOwner : LifecycleOwner { + val registry = LifecycleRegistry(this).apply { currentState = Lifecycle.State.STARTED } + + override val lifecycle: Lifecycle get() = registry + } +} From 21d06437bbb5e3517c690f951b609d5fd763813f Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:17:51 +0530 Subject: [PATCH 25/43] fix(tags): stop the tag filter freezing in landscape With the keyboard up in landscape, Filter by tag froze AnkiDroid for seconds at a time and could end in "AnkiDroid isn't responding". The dialog had so little height left that the tag list was laid out with a negative height, which RecyclerView read as a huge one, so it built a row for every tag, again on each keystroke. The list now sits in its own container, so it shrinks to nothing instead. Part of 22082 Assisted-by: Opus 5.5 --- AnkiDroid/src/main/res/layout/dialog_tags.xml | 30 +++++++++++-------- .../ichi2/anki/dialogs/tags/TagsDialogTest.kt | 26 ++++++++++++++++ 2 files changed, 43 insertions(+), 13 deletions(-) diff --git a/AnkiDroid/src/main/res/layout/dialog_tags.xml b/AnkiDroid/src/main/res/layout/dialog_tags.xml index 7625d3248b43..4763b4e9292e 100644 --- a/AnkiDroid/src/main/res/layout/dialog_tags.xml +++ b/AnkiDroid/src/main/res/layout/dialog_tags.xml @@ -7,14 +7,6 @@ - - - + android:layout_below="@id/toolbar"> + + + + + + val dialog = f.requireDialog() + val recycler: RecyclerView = dialog.findViewById(R.id.tags_list)!! + val content = dialog.findViewById(R.id.toolbar)!!.parent as View + + content.measure( + View.MeasureSpec.makeMeasureSpec(500, View.MeasureSpec.EXACTLY), + View.MeasureSpec.makeMeasureSpec(50, View.MeasureSpec.AT_MOST), + ) + content.layout(0, 0, content.measuredWidth, content.measuredHeight) + + assertThat(recycler.height, greaterThanOrEqualTo(0)) + assertThat(recycler.childCount, lessThan(allTags.size)) + } + } + // these are called 'withTestArguments' due to "extension is shadowed by a member" warnings // this is needed so we can pass in 'targetContext' for context.cacheDir private fun TagsDialog.withTestArguments( From bb8dd61571ca1bbdd41fd51cbfb87bdb60ec6a14 Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:18:42 +0530 Subject: [PATCH 26/43] fix(tags): keep tags visible above the keyboard On a phone in landscape, opening the keyboard squeezed the tags dialog into the strip above it: the toolbar was cut off and no tags were visible. On short screens the dialog now keeps its size and the keyboard covers its lower part, so the search field and the first matches stay in view, and the buttons come back once the keyboard closes. Rotating with the dialog open switches between the two. Fixes 22082 Assisted-by: Opus 5.5 --- .../com/ichi2/anki/dialogs/tags/TagsDialog.kt | 20 +++++++-- .../ichi2/anki/dialogs/tags/TagsDialogTest.kt | 45 +++++++++++++++++++ 2 files changed, 62 insertions(+), 3 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt index 24bab21cc278..0e4d436bc7ff 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt @@ -5,6 +5,7 @@ package com.ichi2.anki.dialogs.tags import android.app.Dialog import android.content.Context import android.content.DialogInterface +import android.content.res.Configuration import android.os.Bundle import android.os.Parcelable import android.text.InputFilter @@ -12,6 +13,7 @@ import android.text.InputType import android.text.Spanned import android.view.MenuItem import android.view.View +import android.view.Window import android.view.WindowManager import android.widget.EditText import android.widget.RadioGroup @@ -250,9 +252,7 @@ class TagsDialog : AnalyticsDialogFragment { positiveButton?.isEnabled = true } - dialog.window?.let { - resizeWhenSoftInputShown(it) - } + dialog.window?.updateSoftInputMode(resources.configuration) return dialog } @@ -291,6 +291,19 @@ class TagsDialog : AnalyticsDialogFragment { dialog?.window?.clearFlags(WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_ALT_FOCUSABLE_IM) } + override fun onConfigurationChanged(newConfig: Configuration) { + super.onConfigurationChanged(newConfig) + dialog?.window?.updateSoftInputMode(newConfig) + } + + private fun Window.updateSoftInputMode(configuration: Configuration) { + if (configuration.screenHeightDp < COMPACT_HEIGHT_DP) { + setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN) + } else { + resizeWhenSoftInputShown(this) + } + } + private fun radioButtonIdToCardState(id: Int) = when (id) { 0 -> CardStateFilter.ALL_CARDS @@ -471,6 +484,7 @@ class TagsDialog : AnalyticsDialogFragment { const val ARG_TAGS_FILE = "tagsFile" private const val ARG_DIALOG_TYPE = "dialogType" private const val ARG_CHECKED_TAGS = "checkedTags" + private const val COMPACT_HEIGHT_DP = 480 /** * The filter that constrains the inputted tag. diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/tags/TagsDialogTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/tags/TagsDialogTest.kt index 5eb527363bea..6cd848458ed9 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/tags/TagsDialogTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/tags/TagsDialogTest.kt @@ -15,8 +15,10 @@ */ package com.ichi2.anki.dialogs.tags +import android.content.res.Configuration import android.os.Bundle import android.view.View +import android.view.WindowManager import android.widget.EditText import androidx.appcompat.app.AlertDialog import androidx.fragment.app.testing.FragmentScenario @@ -31,6 +33,7 @@ import com.ichi2.testutils.RecyclerViewUtils import com.ichi2.ui.CheckBoxTriStates import com.ichi2.utils.ListUtil import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo import org.hamcrest.Matchers.greaterThanOrEqualTo import org.hamcrest.Matchers.lessThan import org.hamcrest.core.IsNull @@ -38,6 +41,7 @@ import org.junit.Assert import org.junit.Test import org.junit.runner.RunWith import org.mockito.Mockito +import org.robolectric.annotation.Config import timber.log.Timber @RunWith(AndroidJUnit4::class) @@ -601,6 +605,47 @@ class TagsDialogTest : RobolectricTest() { } } + @Test + @Config(qualifiers = "w731dp-h411dp") + fun `keyboard pans the dialog on a short screen 22082`() { + runTagsDialogScenario(editTagsArguments()) { f: TagsDialog -> + assertThat(f.softInputAdjustment, equalTo(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN)) + } + } + + @Test + @Config(qualifiers = "w411dp-h914dp") + @Suppress("DEPRECATION") + fun `keyboard resizes the dialog on a tall screen`() { + runTagsDialogScenario(editTagsArguments()) { f: TagsDialog -> + assertThat(f.softInputAdjustment, equalTo(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)) + } + } + + @Test + @Config(qualifiers = "w411dp-h914dp") + fun `rotating to a short screen pans the dialog 22082`() { + runTagsDialogScenario(editTagsArguments()) { f: TagsDialog -> + val landscape = + Configuration(f.resources.configuration).apply { + screenWidthDp = 914 + screenHeightDp = 411 + orientation = Configuration.ORIENTATION_LANDSCAPE + } + f.onConfigurationChanged(landscape) + + assertThat(f.softInputAdjustment, equalTo(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN)) + } + } + + private val TagsDialog.softInputAdjustment: Int + get() = requireDialog().window!!.attributes.softInputMode and WindowManager.LayoutParams.SOFT_INPUT_MASK_ADJUST + + private fun editTagsArguments() = + TagsDialog(ParametersUtils.whatever()) + .withTestArguments(TagsDialog.DialogType.EDIT_TAGS, arrayListOf(), listOf("a")) + .requireArguments() + // these are called 'withTestArguments' due to "extension is shadowed by a member" warnings // this is needed so we can pass in 'targetContext' for context.cacheDir private fun TagsDialog.withTestArguments( From 7773e08d2a9822b6c6a7867c44dbab3d63dbd713 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:32:32 +0100 Subject: [PATCH 27/43] refactor: separate ViewModel state from arguments Add a ViewModelSavedStateHandle alias and a factory that supplies an empty set of default arguments to AndroidX. Keep the remaining creation extras intact so saved-state ownership and restoration still use AndroidX. Document that the alias alone does not filter arguments and previously saved values remain intact. Launch inputs belong in constructor parameters. Existing ViewModels are not migrated in this preparatory change. Test activity and fragment owners with EmptyApplication, including an unfiltered model that must still receive the original owner arguments. Both cases fail when the factory copies those arguments. Part of 21953 Assisted-by: GPT-6 --- .../anki/utils/SavedStateViewModelFactory.kt | 52 +++++++++++++++ .../anki/utils/ViewModelSavedStateHandle.kt | 12 ++++ .../utils/SavedStateViewModelFactoryTest.kt | 64 +++++++++++++++++++ 3 files changed, 128 insertions(+) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/utils/SavedStateViewModelFactory.kt create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/utils/ViewModelSavedStateHandle.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/utils/SavedStateViewModelFactoryTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/SavedStateViewModelFactory.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/SavedStateViewModelFactory.kt new file mode 100644 index 000000000000..75e586a10a84 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/SavedStateViewModelFactory.kt @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.utils + +import android.os.Bundle +import androidx.lifecycle.DEFAULT_ARGS_KEY +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.createSavedStateHandle +import androidx.lifecycle.viewmodel.MutableCreationExtras +import androidx.lifecycle.viewmodel.initializer +import androidx.lifecycle.viewmodel.viewModelFactory + +/** + * Avoids duplicating large launch arguments in saved instance state, which can cause + * [android.os.TransactionTooLargeException] when the activity stops. + * + * AndroidX normally copies all intent extras or fragment arguments into each ViewModel's saved + * state. This factory creates a new [ViewModelSavedStateHandle] (or returns one which restores + * the previously saved state). + * + * Pass launch inputs directly to the ViewModel constructor & use ViewModel state as normal. + * + * ```kt + * class EditorViewModel( + * state: ViewModelSavedStateHandle, + * val noteId: NoteId, + * ) : ViewModel() { + * val selectedField = state.getMutableStateFlow("selectedField", 0) + * } + * + * // In a Fragment: + * private val viewModel by viewModels { + * savedStateViewModelFactory { state -> + * EditorViewModel(state, requireArguments().getLong("noteId")) + * } + * } + * ``` + * + * Here, `noteId` comes from the fragment's arguments without being copied into the handle. + * Edits to `selectedField.value` are saved and restored after process death. + */ +inline fun savedStateViewModelFactory( + crossinline create: (ViewModelSavedStateHandle) -> VM, +): ViewModelProvider.Factory = + viewModelFactory { + initializer { + val extras = MutableCreationExtras(this) + extras[DEFAULT_ARGS_KEY] = Bundle.EMPTY + create(extras.createSavedStateHandle()) + } + } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/utils/ViewModelSavedStateHandle.kt b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ViewModelSavedStateHandle.kt new file mode 100644 index 000000000000..a698c21d87fc --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/utils/ViewModelSavedStateHandle.kt @@ -0,0 +1,12 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.utils + +import androidx.lifecycle.SavedStateHandle + +/** + * A [SavedStateHandle] created without automatically copying intent extras or fragment arguments. + * + * @see savedStateViewModelFactory + */ +typealias ViewModelSavedStateHandle = SavedStateHandle diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/utils/SavedStateViewModelFactoryTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/utils/SavedStateViewModelFactoryTest.kt new file mode 100644 index 000000000000..ca26e664379a --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/utils/SavedStateViewModelFactoryTest.kt @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.utils + +import android.content.Intent +import android.os.Bundle +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStoreOwner +import com.ichi2.anki.EmptyApplicationCategory +import com.ichi2.testutils.EmptyApplication +import org.junit.Test +import org.junit.experimental.categories.Category +import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner +import org.robolectric.Robolectric +import org.robolectric.annotation.Config +import kotlin.test.assertEquals + +@RunWith(ParameterizedRobolectricTestRunner::class) +@Config(application = EmptyApplication::class) +@Category(EmptyApplicationCategory::class) +class SavedStateViewModelFactoryTest( + private val fragmentScoped: Boolean, +) { + private val factory = savedStateViewModelFactory(create = ::TestViewModel) + + @Test + fun `factory excludes launch arguments without changing owner defaults`() { + val intent = Intent().putExtra("id", 42L).putExtra("unrelated", "payload") + Robolectric.buildActivity(FragmentActivity::class.java, intent).setup().use { controller -> + val owner = owner(controller.get()) + val model = ViewModelProvider(owner, factory)[TestViewModel::class.java] + + assertEquals(emptySet(), model.state.keys()) + + // Other factories must still receive the owner's original launch arguments. + val unfiltered = ViewModelProvider(owner)["unfiltered", TestViewModel::class.java] + assertEquals(42L, unfiltered.state.get("id")) + assertEquals("payload", unfiltered.state.get("unrelated")) + } + } + + private fun owner(activity: FragmentActivity): ViewModelStoreOwner { + if (!fragmentScoped) return activity + val manager = activity.supportFragmentManager + return manager.findFragmentByTag("owner") ?: Fragment().also { + it.arguments = Bundle(activity.intent.extras) + manager.beginTransaction().add(it, "owner").commitNow() + } + } + + class TestViewModel( + val state: ViewModelSavedStateHandle, + ) : ViewModel() + + companion object { + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters(name = "fragment scoped = {0}") + fun owners() = listOf(arrayOf(false), arrayOf(true)) + } +} From 63ed2f903aa0cf9f72214d89a1404f4a0a260e48 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:36:46 +0100 Subject: [PATCH 28/43] test: preserve ViewModel state across recreation Part of 21953 Assisted-by: GPT-6 --- .../utils/SavedStateViewModelFactoryTest.kt | 22 +++++++++++++++ .../com/ichi2/testutils/SavedStateUtils.kt | 28 +++++++++++++++++++ 2 files changed, 50 insertions(+) create mode 100644 AnkiDroid/src/test/java/com/ichi2/testutils/SavedStateUtils.kt diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/utils/SavedStateViewModelFactoryTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/utils/SavedStateViewModelFactoryTest.kt index ca26e664379a..e912045b0614 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/utils/SavedStateViewModelFactoryTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/utils/SavedStateViewModelFactoryTest.kt @@ -11,6 +11,8 @@ import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.ViewModelStoreOwner import com.ichi2.anki.EmptyApplicationCategory import com.ichi2.testutils.EmptyApplication +import com.ichi2.testutils.parcelledCopy +import com.ichi2.testutils.saveState import org.junit.Test import org.junit.experimental.categories.Category import org.junit.runner.RunWith @@ -43,6 +45,26 @@ class SavedStateViewModelFactoryTest( } } + @Test + fun `process death restores handle and flow values instead of launch arguments`() { + val intent = Intent().putExtra("id", 42L).putExtra("unrelated", "payload") + val savedState = + Robolectric.buildActivity(FragmentActivity::class.java, intent).setup().use { controller -> + val original = ViewModelProvider(owner(controller.get()), factory)[TestViewModel::class.java] + original.state["id"] = 43L + original.state.getMutableStateFlow("new_ui_state", "initial value").value = "edited value" + controller.saveState().parcelledCopy(javaClass.classLoader) + } + + Robolectric.buildActivity(FragmentActivity::class.java, intent).setup(savedState).use { controller -> + val restored = ViewModelProvider(owner(controller.get()), factory)[TestViewModel::class.java] + + assertEquals(setOf("id", "new_ui_state"), restored.state.keys()) + assertEquals(43L, restored.state.get("id")) + assertEquals("edited value", restored.state.getStateFlow("new_ui_state", "initial value").value) + } + } + private fun owner(activity: FragmentActivity): ViewModelStoreOwner { if (!fragmentScoped) return activity val manager = activity.supportFragmentManager diff --git a/AnkiDroid/src/test/java/com/ichi2/testutils/SavedStateUtils.kt b/AnkiDroid/src/test/java/com/ichi2/testutils/SavedStateUtils.kt new file mode 100644 index 000000000000..9766d752a27c --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/testutils/SavedStateUtils.kt @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.testutils + +import android.os.Bundle +import android.os.Parcel +import org.robolectric.android.controller.ActivityController + +/** Stops the activity and captures its saved instance state. */ +fun ActivityController<*>.saveState(): Bundle = Bundle().also { pause().stop().saveInstanceState(it) } + +fun Bundle.parcelSize(): Int = withParcel { it.dataSize() } + +fun Bundle.parcelledCopy(classLoader: ClassLoader?): Bundle = + withParcel { + it.setDataPosition(0) + requireNotNull(it.readBundle(classLoader)) + } + +private fun Bundle.withParcel(action: (Parcel) -> T): T { + val parcel = Parcel.obtain() + try { + parcel.writeBundle(this) + return action(parcel) + } finally { + parcel.recycle() + } +} From a5342533e366e3d79fbc92f3df1350e8f8f0bfcb Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:35:30 +0100 Subject: [PATCH 29/43] fix(export): stop duplicating intent extras in state ExportReadyViewModel copied any intent extras from AnkiActivity into its instance state, potentially triggering a `TransactionTooLargeException`. Use our new `savedStateViewModelFactory` to avoid this. Part of 21953 Assisted-by: GPT-6 --- .../main/java/com/ichi2/anki/AnkiActivity.kt | 2 +- .../dialogs/viewmodel/ExportReadyViewModel.kt | 7 ++- .../ichi2/anki/AnkiActivitySavedStateTest.kt | 52 +++++++++++++++++++ 3 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/AnkiActivitySavedStateTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt index 8a2691a03cac..b9577f959eb4 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiActivity.kt @@ -115,7 +115,7 @@ open class AnkiActivity( ) : AppCompatActivity(contentLayoutId ?: 0), ShortcutGroupProvider, AnkiActivityProvider { - val exportReadyViewModel by viewModels() + val exportReadyViewModel by viewModels { ExportReadyViewModel.factory } /** * Receiver that informs us when a broadcast listen in [broadcastsActions] is received. diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/viewmodel/ExportReadyViewModel.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/viewmodel/ExportReadyViewModel.kt index 01234d4b6799..ca3fe06b903d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/viewmodel/ExportReadyViewModel.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/viewmodel/ExportReadyViewModel.kt @@ -3,10 +3,11 @@ package com.ichi2.anki.dialogs.viewmodel import android.os.Parcelable -import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import com.ichi2.anki.AnkiActivity import com.ichi2.anki.dialogs.ExportReadyDialog +import com.ichi2.anki.utils.ViewModelSavedStateHandle +import com.ichi2.anki.utils.savedStateViewModelFactory import kotlinx.coroutines.flow.StateFlow import kotlinx.parcelize.Parcelize import timber.log.Timber @@ -18,7 +19,7 @@ import timber.log.Timber * @see AnkiActivity */ class ExportReadyViewModel( - private val savedStateHandle: SavedStateHandle, + private val savedStateHandle: ViewModelSavedStateHandle, ) : ViewModel() { val exportReadyDestination: StateFlow field = savedStateHandle.getMutableStateFlow(ARG_EXPORT_READY_PARAMS, null) @@ -41,6 +42,8 @@ class ExportReadyViewModel( ) : Parcelable companion object { + val factory = savedStateViewModelFactory(create = ::ExportReadyViewModel) + private const val ARG_EXPORT_READY_PARAMS = "arg_export_ready_params" } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/AnkiActivitySavedStateTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/AnkiActivitySavedStateTest.kt new file mode 100644 index 000000000000..e6642bdd73b6 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/AnkiActivitySavedStateTest.kt @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki + +import android.content.Intent +import androidx.core.os.bundleOf +import androidx.fragment.app.Fragment +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.testutils.EmptyAnkiActivity +import com.ichi2.testutils.parcelSize +import com.ichi2.testutils.saveState +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals + +/** Guards against the activity's export ViewModel duplicating launch arguments in saved state. */ +@RunWith(AndroidJUnit4::class) +class AnkiActivitySavedStateTest : RobolectricTest() { + @Test + fun `unrelated intent extras do not increase activity saved state`() { + fun savedSize(payload: ByteArray): Int { + val intent = Intent(targetContext, EmptyAnkiActivity::class.java).putExtra("payload", payload) + val size = savedStateSize(EmptyAnkiActivity::class.java, intent) + assertContentEquals(payload, intent.getByteArrayExtra("payload")) + return size + } + + assertEquals(savedSize(ByteArray(0)), savedSize(ByteArray(300_000))) + } + + @Test + fun `fragment arguments are saved exactly once`() { + fun savedSize(payload: ByteArray): Int = + savedStateSize( + SingleFragmentActivity::class.java, + SingleFragmentActivity.getIntent(targetContext, Fragment::class, bundleOf("payload" to payload)), + ) + + val payload = ByteArray(300_000) + assertEquals( + payload.size, + savedSize(payload) - savedSize(ByteArray(0)), + "Saved state should grow by exactly one copy of the fragment arguments", + ) + } + + private fun savedStateSize( + activityClass: Class, + intent: Intent, + ): Int = startActivityControllerNormallyOpenCollectionWithIntent(activityClass, intent).saveState().parcelSize() +} From f965d0229ac50fe65cfd83a7fbb13e5c2c2c7a85 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Tue, 29 Sep 2026 23:16:27 +0100 Subject: [PATCH 30/43] fix(sync): fix race condition on sync completion Calling `.cancel()` on the progress monitor did not wait for completion, so a notification + 'Cancel', associated with a completed WorkManager could appear. Fix: make the monitor a child of the sync operation via `coroutineScope`. The scope will then wait for the monitor to finish before cleaning up notifications. Fixes 19830 Assisted-by: GPT-6 --- .../java/com/ichi2/anki/worker/SyncWorker.kt | 47 +++--- .../com/ichi2/anki/worker/SyncWorkerTest.kt | 138 ++++++++++++++++++ 2 files changed, 162 insertions(+), 23 deletions(-) create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/worker/SyncWorkerTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncWorker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncWorker.kt index ddf554c25035..97eaa8a521ea 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncWorker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncWorker.kt @@ -34,9 +34,8 @@ import com.ichi2.anki.notifications.NotificationId import com.ichi2.anki.setLastSyncTimeToNow import com.ichi2.anki.settings.Prefs import com.ichi2.anki.utils.ext.trySetForeground -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch import timber.log.Timber @@ -116,29 +115,31 @@ class SyncWorker( syncMedia: Boolean, ) { Timber.v("SyncWorker::syncCollection") - val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) - val monitor = - scope.launch { - val backend = CollectionManager.getBackend() - var syncProgress: Progress.NormalSync? = null - while (true) { - val progress = backend.latestProgress() // avoid sending repeated notifications - if (progress.hasNormalSync() && syncProgress != progress.normalSync) { - syncProgress = progress.normalSync - val text = syncProgress.run { "$added\n$removed" } - notify(getProgressNotification(text)) - } - delay(SyncMediaWorker.NOTIFICATION_UPDATE_RATE_MS) - } - } val response = - try { - withCol { - syncCollection(auth, syncMedia = false) + coroutineScope { + val monitor = + launch(Dispatchers.IO) { + val backend = CollectionManager.getBackend() + var syncProgress: Progress.NormalSync? = null + while (true) { + val progress = backend.latestProgress() // avoid sending repeated notifications + if (progress.hasNormalSync() && syncProgress != progress.normalSync) { + syncProgress = progress.normalSync + val text = syncProgress.run { "$added\n$removed" } + notify(getProgressNotification(text)) + } + delay(SyncMediaWorker.NOTIFICATION_UPDATE_RATE_MS) + } + } + try { + withCol { + syncCollection(auth, syncMedia = false) + } + } finally { + Timber.d("Collection sync completed. Cancelling monitor...") + // this doesn't wait for completion; the outer `coroutineScope` does. + monitor.cancel() } - } finally { - Timber.d("Collection sync completed. Cancelling monitor...") - monitor.cancel() } Timber.i("Sync required: %s", response.required) when (response.required) { diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/worker/SyncWorkerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/worker/SyncWorkerTest.kt new file mode 100644 index 000000000000..f63b3177f556 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/worker/SyncWorkerTest.kt @@ -0,0 +1,138 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.worker + +import android.app.Notification +import android.app.NotificationManager +import androidx.core.content.getSystemService +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.work.ListenableWorker.Result +import androidx.work.testing.TestListenableWorkerBuilder +import androidx.work.workDataOf +import anki.collection.Progress +import anki.collection.progress +import anki.sync.SyncAuth +import anki.sync.SyncCollectionResponse +import anki.sync.syncCollectionResponse +import com.ichi2.anki.CollectionManager +import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.notifications.NotificationId +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import net.ankiweb.rsdroid.Backend +import net.ankiweb.rsdroid.BackendFactory +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +class SyncWorkerTest : RobolectricTest() { + /** The current collection sync notification, or null if it has been removed. */ + private val syncNotification: Notification? + get() = shadowOf(targetContext.getSystemService()!!).getNotification(NotificationId.SYNC) + + /** Restores the default backend factory after each test. */ + @After + fun resetBackendFactory() { + BackendFactory.setOverride(null) + } + + @Test + fun `sync completion waits for the final progress update before removing notification`() = + runTest { + val work = runSyncWithBlockedProgress() + + assertEquals(Result.success(), work.await()) + assertNull(syncNotification) + } + + @Test + fun `sync failure waits for the final progress update before showing error`() = + runTest { + val work = runSyncWithBlockedProgress(syncError = IllegalStateException("sync failed")) + + assertEquals(Result.failure(), work.await()) + val notification = assertNotNull(syncNotification) + assertEquals("sync failed", notification.extras.getString(Notification.EXTRA_TEXT)) + assertTrue(notification.flags and Notification.FLAG_ONGOING_EVENT == 0) + } + + @Test + fun `sync cancellation waits for the final progress update before removing notification`() = + runTest { + val work = runSyncWithBlockedProgress(cancelWorker = true) + + work.join() + assertTrue(work.isCancelled) + assertNull(syncNotification) + } + + /** Runs sync with a paused progress update, checks cleanup ordering, then releases the update. */ + private suspend fun TestScope.runSyncWithBlockedProgress( + syncError: Exception? = null, + cancelWorker: Boolean = false, + ): Deferred { + val releaseProgress = CountDownLatch(1) + setUpSyncBackend(releaseProgress, syncError) + val worker = TestListenableWorkerBuilder(targetContext, workDataOf("hkey" to "test")).build() + val work = async { worker.doWork() } + try { + assertCleanupWaitsForProgress(work, cancelWorker) + } finally { + releaseProgress.countDown() + } + return work + } + + /** Installs a backend whose sync finishes while a progress update waits for [releaseProgress]. */ + private suspend fun setUpSyncBackend( + releaseProgress: CountDownLatch, + syncError: Exception?, + ) { + val progressStarted = CountDownLatch(1) + CollectionManager.discardBackend() + BackendFactory.setOverride { + object : Backend() { + override fun latestProgress(): Progress { + progressStarted.countDown() + check(releaseProgress.await(10, TimeUnit.SECONDS)) { "test did not release progress" } + return progress { normalSync = Progress.NormalSync.getDefaultInstance() } + } + + override fun syncCollection( + auth: SyncAuth, + syncMedia: Boolean, + ): SyncCollectionResponse { + check(progressStarted.await(10, TimeUnit.SECONDS)) { "progress monitor did not start" } + syncError?.let { throw it } + return syncCollectionResponse { required = SyncCollectionResponse.ChangesRequired.NO_CHANGES } + } + } + } + } + + /** Checks that [work] stays incomplete while progress is blocked, including after cancellation. */ + private fun TestScope.assertCleanupWaitsForProgress( + work: Job, + cancelWorker: Boolean, + ) { + runCurrent() + assertFalse(work.isCompleted, "cleanup must wait for the in-flight progress update") + if (cancelWorker) { + work.cancel() + runCurrent() + assertFalse(work.isCompleted, "cancellation must also wait for the in-flight update") + } + } +} From fd7d4c3872718376089335d9ed891470d86b6044 Mon Sep 17 00:00:00 2001 From: AnkiDroid Translations Date: Wed, 30 Sep 2026 08:59:15 +0000 Subject: [PATCH 31/43] Updated strings from Crowdin --- AnkiDroid/src/main/res/values-hi/01-core.xml | 2 +- .../src/main/res/values-ja/02-strings.xml | 2 +- .../src/main/res/values-ja/10-preferences.xml | 56 ++++---- .../src/main/res/values-ko/02-strings.xml | 2 +- .../src/main/res/values-sk/03-dialogs.xml | 2 +- .../src/main/res/values-sk/10-preferences.xml | 22 +-- .../src/main/res/values-sk/11-arrays.xml | 2 +- AnkiDroid/src/main/res/values-ug/01-core.xml | 4 +- .../src/main/res/values-ug/02-strings.xml | 4 +- .../src/main/res/values-ug/03-dialogs.xml | 6 +- .../src/main/res/values-ug/10-preferences.xml | 130 +++++++++--------- 11 files changed, 116 insertions(+), 116 deletions(-) diff --git a/AnkiDroid/src/main/res/values-hi/01-core.xml b/AnkiDroid/src/main/res/values-hi/01-core.xml index e5b801a6f53e..59bda09ea1cd 100644 --- a/AnkiDroid/src/main/res/values-hi/01-core.xml +++ b/AnkiDroid/src/main/res/values-hi/01-core.xml @@ -59,7 +59,7 @@ कठिन अच्छा आसान - \'%s\' is enabled + \'%s\' सक्रिय हो गया है। पूर्ववत् करें रीडू diff --git a/AnkiDroid/src/main/res/values-ja/02-strings.xml b/AnkiDroid/src/main/res/values-ja/02-strings.xml index 303ce999cf75..8d809ce29318 100644 --- a/AnkiDroid/src/main/res/values-ja/02-strings.xml +++ b/AnkiDroid/src/main/res/values-ja/02-strings.xml @@ -255,7 +255,7 @@ 有効なメールアドレスを入力してください パスワードが必要です - もう一度「戻る」操作で終了 + もう一度「戻る」操作で終了します %s%% デッキをダウンロード diff --git a/AnkiDroid/src/main/res/values-ja/10-preferences.xml b/AnkiDroid/src/main/res/values-ja/10-preferences.xml index a97ab9518218..172d7986baff 100644 --- a/AnkiDroid/src/main/res/values-ja/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-ja/10-preferences.xml @@ -229,13 +229,13 @@ Schedule reminders リマインダーを追加 学習のリマインダーがありません - Troubleshoot - Notification settings + トラブルシューティング + アプリの通知設定 List of scheduled review reminders Deck not found - Reminders are unavailable - Fix - Successfully added new review reminder + リマインダーが機能していません + 設定変更 + 新しい学習リマインダーが正常に追加されました Successfully deleted review reminder Successfully edited review reminder @@ -252,37 +252,37 @@ Delete this reminder? This action cannot be undone. - Reminders are unavailable. - Reminders may not work correctly. - Your reminders should work as expected. + リマインダーが機能していません。 + リマインダーが正常に動作しない可能性があります。 + リマインダーは正常に動作するはずです。 Different devices have different notification restriction settings. Check your system settings or search online if issues persist. - Notification permission - Notification channel - Do not disturb - Battery optimization - Power saving mode + 通知の許可 + 通知チャンネル + サイレントモード + バッテリーの最適化 + 省電力モード Alarms & reminders permission - Granted - Denied - On - Off - Unrestricted - Optimized - Restricted + 許可済み + 許可されていません + オン + オフ + このアプリには適用されていません + このアプリにも適用しています + このアプリの使用を制限しています - The review reminder notification channel must be enabled + 「学習リマインダー」というカテゴリーの通知を許可する必要があります Do Not Disturb may mute reminder notifications - Battery optimization may delay reminders - Background usage is disabled. Reminders may not be delivered - Power saving mode may prevent timely delivery of reminders + バッテリーの最適化により、リマインダーが遅延する可能性があります + バックグラウンドでの使用が制限されています。リマインダーが配信されない可能性があります。 + 省電力モードによってリマインダーの通知が遅れる可能性があります Required to schedule reminders at exact times - Grant permission - Enable notification channel - Disable battery optimization - Open battery settings + 許可を与える + 上記カテゴリの通知を許可 + このアプリには適用しない + バッテリー設定を開く 学習する時間です %s を学習する時間です diff --git a/AnkiDroid/src/main/res/values-ko/02-strings.xml b/AnkiDroid/src/main/res/values-ko/02-strings.xml index 3afa228db020..b338bd892886 100644 --- a/AnkiDroid/src/main/res/values-ko/02-strings.xml +++ b/AnkiDroid/src/main/res/values-ko/02-strings.xml @@ -112,7 +112,7 @@ 목표 학습 시간에 도달했습니다. - %2$s분 동안 카드 %1$d개를 복습했습니다 + %2$s 동안 카드 %1$d개를 복습했습니다 diff --git a/AnkiDroid/src/main/res/values-sk/03-dialogs.xml b/AnkiDroid/src/main/res/values-sk/03-dialogs.xml index ebffb7dde89f..9d9dd2c2a1d5 100644 --- a/AnkiDroid/src/main/res/values-sk/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-sk/03-dialogs.xml @@ -134,7 +134,7 @@ Chyba pri odstránení obrázka Nepodarilo sa použiť obrázok pozadia %s Pozadie Deck Picker je príliš veľké - Maximum image size %d MB allowed + Najväčšia povolená veľkosť obrázka je %d MB Rozmery obrázka sú priveľké (%1$d x %2$d) diff --git a/AnkiDroid/src/main/res/values-sk/10-preferences.xml b/AnkiDroid/src/main/res/values-sk/10-preferences.xml index 7ec9a48b9025..dd484891b10a 100644 --- a/AnkiDroid/src/main/res/values-sk/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-sk/10-preferences.xml @@ -52,12 +52,12 @@ Upravovanie Zabezpečenie - Globally allow dangerous JavaScript APIs - Allows any card or template to read and modify other cards, or your Anki collection data.\nLeave off unless you trust all cards and templates in your collection. + Globálne povoliť nebezpečné JS API + Umožňuje akejkoľvek kartičke alebo šablóne čítať a meniť kartičky alebo údaje vašej Anki kolekcie.\nPonechajte vypnuté s výnimkou že veríte všetkým kartičkám a šablónam vo vašej kolekcii. Security: Unsafe JavaScript call blocked.\nIf you trust all cards and templates in your collection, this can be unblocked in the advanced settings - 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. + Povoliť kartičkám spúšťanie iných aplikácií + Kartičky môžu otvoriť iné aplikácia vo vašom zariadení, potencionálne spúšťajúc nechcenú aktivitu.\nPonechajte vypnuté s výnimkou že veríte všetkým kartičkám a šablónam vo vašej kolekcii. 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 Hrúbka ťahu @@ -147,9 +147,9 @@ Zvoliť jazyk Vypnúť hardvérové renderovanie kartičky Hardvérové renderovanie je rýchlejšie, ale môže spôsobiť problémy, najmä na Android 8/8.1. Ak nemôžete vidieť časti užívateľského rozhrania pri opakovaní kartičiek, skúste toto nastavenie. - Disable single-field edit mode + Zakázať režim úpravy jedného poľa - Allows \'Cloze deletion\' context menu when in landscape mode. + Povoliť kontextové menu doplňovačky pri horizontálnom zobrazení. Zarovnanie na stred Vycentrovať obsah kartičiek vertikálne Čas oneskorenia dvojitého dotyku @@ -160,8 +160,8 @@ Zobraziť čas ďalšieho opakovania nad tlačidlami odpovedí Ukázať veľké tlačidla pre odpovede Ukázať tlačidlá odpovede vo dvoch riadkoch - Remove animations - Also enabled if animations are removed on the system settings + Odstrániť animácie + Je povolené aj ak sú animácie odstránené systémovými nastaveniami Zobraziť hornú lištu Show card counts, answer timer, flag and mark in top bar Zobraziť zostávajúce @@ -310,7 +310,7 @@ Zdieľať používanie prvkov Môžete prispieť pre AnkiDroid tak, že umožníte vývojovému tímu sledovať, ktoré prvky sa používajú Nahradiť nové riadky s HTML - In the note editor, convert any instances of <br> to newlines when editing a card. + Pri upravovaní poznámky zmení všetky prvky <br> na nové riadky počas upravovania kartičky. Vybrať „napíšte odpoveď“ Automaticky vyberie pole „napíšte odpoveď“ na obrazovke študovania. @@ -358,8 +358,8 @@ Experimental AnkiDroid has introduced a better TTS mechanism which is compatible with other Anki clients and includes more voices and improvements to language playback!\n\nPlease upgrade as soon as possible, as this setting will be removed soon Please upgrade to the new text to speech format - Allow templates to record audio - This template is trying to record audio + Povoliť šablónam nahrávať zvuk + Táto šablóna sa pokúša nahrávať zvuk localStorage na obrazovke študovania Povoliť JavaScript localStorage (lokálne úložisko) použitím fixného portu servera na obrazovke študovania diff --git a/AnkiDroid/src/main/res/values-sk/11-arrays.xml b/AnkiDroid/src/main/res/values-sk/11-arrays.xml index 852cc8588fa9..0a40c7747ffa 100644 --- a/AnkiDroid/src/main/res/values-sk/11-arrays.xml +++ b/AnkiDroid/src/main/res/values-sk/11-arrays.xml @@ -35,7 +35,7 @@ Svetlý Jednoduchý - E-Ink + E-Tuš Čierny Tmavý diff --git a/AnkiDroid/src/main/res/values-ug/01-core.xml b/AnkiDroid/src/main/res/values-ug/01-core.xml index 21d261445901..3adf3cc3eda8 100644 --- a/AnkiDroid/src/main/res/values-ug/01-core.xml +++ b/AnkiDroid/src/main/res/values-ug/01-core.xml @@ -127,8 +127,8 @@ AnkiDroid ساندانىنى ئوقۇيدۇ ۋە يازىدۇ مەۋجۇت خاتىرە، كارتا، خاتىرە تۈرى ۋە دەستىنى زىيارەت قىلىپلا قالماي يەنە يېڭىسىنى قۇرىدۇ - Card browser - Anki card + كارتا كۆرگۈچ + Anki كارتا توپلامنى بىرلەشتۈرگىلى بولمايدۇ.\nقايسى توپلامنى ساقلاپ قالىسىز؟ AnkiDroid diff --git a/AnkiDroid/src/main/res/values-ug/02-strings.xml b/AnkiDroid/src/main/res/values-ug/02-strings.xml index 3c3dfc822322..a7b1617eabc4 100644 --- a/AnkiDroid/src/main/res/values-ug/02-strings.xml +++ b/AnkiDroid/src/main/res/values-ug/02-strings.xml @@ -220,8 +220,8 @@ خەت چوڭلۇقى قورال بالداقنى كۆرسەت - Show preview - Hide preview + ئالدىن كۆزىتىشنى كۆرسەت + ئالدىن كۆزىتىشنى يوشۇر توم پىچىم يانتۇ پىچىم ئاستى سىزىق پىچىم diff --git a/AnkiDroid/src/main/res/values-ug/03-dialogs.xml b/AnkiDroid/src/main/res/values-ug/03-dialogs.xml index 601708e3a98d..b9238fe480f8 100644 --- a/AnkiDroid/src/main/res/values-ug/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-ug/03-dialogs.xml @@ -49,7 +49,7 @@ بۈگۈنكى تەكرارلايدىغان كارتا چېكىنى («3-»)ئاشۇرىدۇ/كېمەيتىدۇ ئۆتكەن x كۈندە ئۇنتۇلغان كارتىنى تەكرارلايدۇ: كېيىنكى كارتىنى تەكرارلاش مۆھلىتى: - Preview new cards added in the last: + يېقىنقى كۈندە قوشقان يېڭى كارتىنى ئالدىن كۆزىتىدۇ: دەستىدىن x كارتا تاللىنىدۇ: ساقلىغۇچ لىق ساقلىغۇچ ئاساسەن لىق @@ -78,7 +78,7 @@ قاپلا تۈزەت ئالماشتۇر - %1$s\n\nCorrect the date and time, or enable automatic date and time, then sync again.\n\nYour device’s current time is: %2$s + %1$s\n\nچېسلا ۋە ۋاقىتنى توغرىلاپ ياكى ئاپتوماتىك چېسلا ۋە ۋاقىتنى قوزغىتىپ ئاندىن قايتا قەدەمداشلاڭ.\n\nئۈسكۈنىڭىزنىڭ نۆۋەتتىكى ۋاقتى: %2$s بەلگىلەنگەن يول ئىناۋەتلىك مۇندەرىجە ئەمەس تەمىنلەنگەن تېكىستنى ئىناۋەتلىك PEM كودىنىڭ X509 گۇۋاھنامىسىنى بىر تەرەپ قىلالمىدى گۇۋاھنامە يېشىش خاتالىقى @@ -87,7 +87,7 @@ چوڭ ۋاسىتە توپلىمىغا ئۇزۇن ۋاقىت كېتىشى مۇمكىن. داۋاملاشتۇرامدۇ؟ ۋاسىتە تەكشۈرۈۋاتىدۇ… بەلگە قوش… - Tags added + بەلگە قوشۇلدى ۋاسىتە ئۆچۈرۈۋاتىدۇ… ئۆچۈرۈش نەتىجىسى diff --git a/AnkiDroid/src/main/res/values-ug/10-preferences.xml b/AnkiDroid/src/main/res/values-ug/10-preferences.xml index 8c590afe6a10..3389003637eb 100644 --- a/AnkiDroid/src/main/res/values-ug/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-ug/10-preferences.xml @@ -24,7 +24,7 @@ --> - Enabled + قوزغىتىلدى چەكلەنگەن %d مىنۇت @@ -108,7 +108,7 @@ ئۈچ بارماقتا چېكىش تۆت بارماقتا چېكىش ئۈسكۈنىنى سىلكى - ‘%s’ menu + «%s» تىزىملىكى «%s» مەزمۇن تىزىملىكىنى پۈتكۈل دائىرىدە قوزغىتىدۇ قوش دومىلات ئېلېكتىرونلۇق ئوقۇغۇچتا قوش دومىلىتىدۇ @@ -224,75 +224,75 @@ ################################## Review reminders ################################### ####################################################################################### --> - Review reminders - App-wide - Deck-specific - An error occurred while loading your review reminders, corrupted reminders have been deleted. Details: + تەكرارلاش ئەسكەرتىشى + ئەپ مىقياسىدا + دەستە بەلگىلەش + تەكرارلاش ئەسكەرتىشىڭىزنى يۈكلەۋاتقاندا خاتالىق كۆرۈلدى، بۇزۇلغان ئەسكەرتىش ئۆچۈرۈلدى. تەپسىلاتى: - Schedule reminders + ئەسكەرتىش كۈنتەرتىپى ئەسكەرتىش قوش تەكرارلاش ئەسكەرتىشىڭىز يوق - Troubleshoot - Notification settings - List of scheduled review reminders - Deck not found - Reminders are unavailable - Fix - Successfully added new review reminder - Successfully deleted review reminder - Successfully edited review reminder + كاشىلا ئوڭشاش + ئۇقتۇرۇش تەڭشىكى + كۈنتەرتىپتىكى تەكرارلاش ئەسكەرتىش تىزىمى + دەستە تېپىلمىدى + ئەسكەرتكۈچنى ئىشلەتكىلى بولمايدۇ + ئوڭشا + يېڭى تەكرارلاش ئەسكەرتىشى مۇۋەپپەقىيەتلىك قوشۇلدى + تەكرارلاش ئەسكەرتىشى مۇۋەپپەقىيەتلىك ئۆچۈرۈلدى + تەكرارلاش ئەسكەرتىشى مۇۋەپپەقىيەتلىك تەھرىرلەندى تەكرارلاش ئەسكەرتىشى قوش تەكرارلاش ئەسكەرتىشى تەھرىر - Time: - Deck: - Advanced settings - Card threshold: - Do not send me notifications if there are less than this many cards due - Only notify me if no reviews have been done today - Please enter a whole number of cards - The threshold must be at least 0 - Delete this reminder? - This action cannot be undone. + ۋاقىت: + دەستە: + ئالىي تەڭشەك + كارتا بوسۇغا قىممىتى: + ئەگەر بۇنىڭدىن ئاز كارتا بولسا ئۇقتۇرۇشنى ماڭا يوللىما + ئەگەر بۈگۈن تەكرارلاشنىنى تاماملىمىغان بولسام ئاندىن مېنى ئەسكەرت + تولۇق كارتا نومۇرىنى كىرگۈزۈڭ + بوسۇغا قىممىتى كەم دېگەندە 0 بولۇشى كېرەك + بۇ ئەسكەرتىشنى ئۆچۈرەمدۇ؟ + بۇ مەشغۇلاتتىن يېنىۋالغىلى بولمايدۇ. - Reminders are unavailable. - Reminders may not work correctly. - Your reminders should work as expected. - Different devices have different notification restriction settings. Check your system settings or search online if issues persist. + ئەسكەرتكۈچنى ئىشلەتكىلى بولمايدۇ. + ئەسكەرتكۈچ توغرا ئىشلىمەسلىكى مۇمكىن. + ئەسكەرتكۈچىڭىز كۈتكەندەك ئىشلىشى كېرەك. + پەرقلىق ئۈسكۈنىنىڭ پەرقلىق ئۇقتۇرۇش چەك تەڭشىكى بولىدۇ. ئەگەر مەسىلە يەنىلا مەۋجۇت بولسا سىستېما تەڭشىكىنى تەكشۈرۈڭ ياكى توردىن ئىزدەڭ. - Notification permission - Notification channel - Do not disturb - Battery optimization - Power saving mode - Alarms & reminders permission + ئۇقتۇرۇش ئىجازىتى + ئۇقتۇرۇش قانىلى + دەخلى قىلماڭ + توكدان ئەلالاشتۇرۇش + توك تېجەش ھالىتى + قوڭغۇراق ۋە ئەسكەرتكۈچ ئىجازىتى - Granted - Denied - On - Off - Unrestricted - Optimized - Restricted + بېرىلدى + رەت قىلىندى + ئوچۇق + تاقاق + چەكلىمىسىز + ئەلالاشتۇرۇلغان + چەكلىمىلىك - The review reminder notification channel must be enabled - Do Not Disturb may mute reminder notifications - Battery optimization may delay reminders - Background usage is disabled. Reminders may not be delivered - Power saving mode may prevent timely delivery of reminders - Required to schedule reminders at exact times + تەكرارلاشنى ئەسكەرتكۈچ ئۇقتۇرۇش قانىلى چوقۇم قوزغىتىلىشى كېرەك + دەخلى قىلماڭ ھالىتى ئەسكەرتكۈچ ئۇقتۇرۇشىنى ئۈنسىزلىشى مۇمكىن + توكدان ئەلالاشتۇرۇش ئەسكەرتكۈچنى كېچىكتۈرۈشى مۇمكىن + ئارقا سۇپىدا ئىشلىتىش چەكلەنگەن. ئەسكەرتكۈچ يەتكۈزۈلمەسلىكى مۇمكىن. + توك تېجەش ھالىتى ئەسكەرتكۈچنىڭ ۋاقتىدا يەتكۈزۈشىنىڭ ئالدىنى ئېلىشى مۇمكىن + ئەسكەرتكۈچنى مۇئەييەن ۋاقىتقا كۈنتەرتىپكە تىزىش ئۈچۈن زۆرۈر - Grant permission - Enable notification channel - Disable battery optimization - Open battery settings + ئىجازەت بەر + ئۇقتۇرۇش قانىلىنى قوزغات + توكدان ئەلالاشتۇرۇشنى چەكلە + توكدان تەڭشىكىنى ئاچ - It\'s time to study your cards - It\'s time to study \"%s\" - Snooze %s + كارتىڭىزنى ئۆگىنىدىغان ۋاقىت بولدى + «%s» نى ئۆگىنىدىغان ۋاقىت بولدى + %s كېچىكتۈر - %d card due - %d cards due + %d كارتا مۆھلىتى + %d كارتا مۆھلىتى بەلگە تاللىنىدۇ @@ -443,15 +443,15 @@ قايتۇرما ئىنكاس ياكى كەمتۈك دوكلاتى يوللاڭ.]]> جاۋاب قايتۇرما ئىنكاسىنى كۆرسىتىدۇ ئېكران - Also assign to ‘%1$s’? - Skip - Assign + «%1$s» غىمۇ تەقسىملەمدۇ؟ + ئاتلا + تەقسىملە تەڭشەكنى ئاچ مۇناسىۋەتلىك تەڭشەك - Not set - Custom path - Enter custom path - Directory is not writable + تەڭشەلمىگەن + ئىختىيارى يول + ئىختىيارى يول كىرگۈزۈلىدۇ + مۇندەرىجىگە يازغىلى بولمايدۇ From 79471b28142f01434189b01d3bf85bc2a6723e3c Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:21:36 +0100 Subject: [PATCH 32/43] docs(tests): explain separate Robolectric SDK configurations Assisted-by: GPT-6 --- AnkiDroid/robolectricDownloader.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AnkiDroid/robolectricDownloader.gradle b/AnkiDroid/robolectricDownloader.gradle index 83756ce8ed29..6e6d61e153dc 100644 --- a/AnkiDroid/robolectricDownloader.gradle +++ b/AnkiDroid/robolectricDownloader.gradle @@ -42,7 +42,7 @@ tasks.register('robolectricSdkDownload') { description = "Downloads all robolectric SDK dependencies into mavenLocal, for use with offline robolectric" } -// Generate the configuration and actual copy tasks. +// Use separate configurations so Gradle retains every SDK version instead of selecting the newest. robolectricAndroidSdkVersions.forEach { robolectricSdkVersion -> // the final part of this `-i` comes from PREINSTRUMENTED_VERSION in upstream DefaultSdkProvider def version = "${robolectricSdkVersion['androidVersion']}-robolectric-${robolectricSdkVersion['frameworkSdkBuildVersion']}-i7" From 98027811973b3994d6a5afda2d15ccb7df37d79c Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Fri, 25 Sep 2026 11:21:46 +0100 Subject: [PATCH 33/43] fix(tests): avoid Robolectric download lock contention Disable on-the-fly Robolectric SDK downloads. On Windows, parallel test workers can fail to open the lock, to resolve dependencies, failing even if the jar is already cached locally, causing unexpected test failures. Now, Gradle resolves the jars into its shared dependency cache before forking tests. Each worktree writes a small properties file pointing to those cached jars, avoiding an additional 1.45 GiB of copies per worktree. `robolectric-deps.properties` is set so PropertiesDependencyResolver is used, which reads the cached jars without downloading or acquiring a Maven download lock. Comments changed: robolectric no longer downloads into ~/.m2 Fixes 22069 java.lang.IllegalStateException: Couldn't create lock file C:\Users\runneradmin\.robolectric-download-lock Assisted-by: GPT-6 --- AnkiDroid/robolectricDownloader.gradle | 38 ++++++++------------------ build.gradle.kts | 9 ++++++ 2 files changed, 20 insertions(+), 27 deletions(-) diff --git a/AnkiDroid/robolectricDownloader.gradle b/AnkiDroid/robolectricDownloader.gradle index 6e6d61e153dc..f70b83d96177 100644 --- a/AnkiDroid/robolectricDownloader.gradle +++ b/AnkiDroid/robolectricDownloader.gradle @@ -1,5 +1,6 @@ /** - * Downloads all android-all-instrumented dependencies and copies them to the mavenLocal() repository + * Downloads android-all-instrumented dependencies into Gradle's cache for offline Robolectric. + * Writes a properties file pointing to the cached jars, so worktrees do not need their own copies. * * Once applied to your gradle project, can be executed with ./gradlew robolectricSdkDownload */ @@ -13,11 +14,8 @@ // Note: the PREINSTRUMENTED_VERSION constant is what you put on the end of the artifact // Only the versions currently used in AnkiDroid Robolectric tests are active, the rest are commented out -// To update these versions, open a terminal in the Anki-Android directory and perform the following steps: -// 1. Run `rm -r ~/.m2` to delete the .m2 directory in your home directory. -// 2. Run `./gradlew jacocoUnitTestReport` to run all unit tests. -// 3. Run `find ~/.m2 -type d -name '*-robolectric-*'`. -// 4. Update the lines below to match the output from `find`. +// When updating Robolectric, match these versions to DefaultSdkProvider and PREINSTRUMENTED_VERSION. +// Run `./gradlew jacocoUnitTestReport` to check that all SDKs used by the tests are included. def robolectricAndroidSdkVersions = [ // [androidVersion: "6.0.1_r3", frameworkSdkBuildVersion: "r1"], // [androidVersion: "7.0.0_r1", frameworkSdkBuildVersion: "r1"], @@ -36,10 +34,10 @@ def robolectricAndroidSdkVersions = [ [androidVersion: "16", frameworkSdkBuildVersion: "13921718"], // current targetSdk ] -// Base, public task - will be displayed in ./gradlew robolectricDownloader:tasks -tasks.register('robolectricSdkDownload') { +def robolectricSdkDownload = tasks.register('robolectricSdkDownload', WriteProperties) { group = "Dependencies" - description = "Downloads all robolectric SDK dependencies into mavenLocal, for use with offline robolectric" + description = "Downloads Robolectric SDKs and writes their cached paths for offline tests" + destinationFile = layout.buildDirectory.file("robolectric-deps.properties") } // Use separate configurations so Gradle retains every SDK version instead of selecting the newest. @@ -52,24 +50,10 @@ robolectricAndroidSdkVersions.forEach { robolectricSdkVersion -> def sdkConfig = configurations.create(version) dependencies.add(version, "org.robolectric:android-all-instrumented:${version}") - // An ArtifactView on the existing configuration gives us the POM alongside the JAR so - // maven local has both. - def sdkPomFiles = sdkConfig.incoming.artifactView { - attributes { - attribute(ArtifactTypeDefinition.ARTIFACT_TYPE_ATTRIBUTE, "pom") - } - // if the POM can't be resolved, return an empty collection rather than failing the build - lenient = true - }.files - - def mavenLocalFile = new File(this.repositories.mavenLocal().url) - def mavenRobolectric = new File(mavenLocalFile, "org/robolectric/android-all-instrumented/${version}") - // Copying all files downloaded for the created configuration into maven local. - tasks.register("robolectricSdkDownload-${version}", Copy) { - from sdkConfig - from sdkPomFiles - into mavenRobolectric + robolectricSdkDownload.configure { + inputs.files(sdkConfig).withPropertyName(version) + property("org.robolectric:android-all-instrumented:${version}", + sdkConfig.elements.map { it.iterator().next().asFile.absolutePath }) } - robolectricSdkDownload.configure { dependsOn "robolectricSdkDownload-${version}" } } diff --git a/build.gradle.kts b/build.gradle.kts index e69928ced7d6..f4683f09810b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -75,6 +75,15 @@ subprojects { isIncludeAndroidResources = true } androidExtension.testOptions.unitTests.all { + // Resolve SDKs before forking tests: Robolectric's shared download lock races on Windows (Issue 22069). + it.dependsOn(":AnkiDroid:robolectricSdkDownload") + val robolectricDependencies = project(":AnkiDroid").layout.buildDirectory.file("robolectric-deps.properties") + it.inputs.file(robolectricDependencies) + it.systemProperty( + "robolectric-deps.properties", + robolectricDependencies.get().asFile.absolutePath, + ) + // tell backend to avoid rollover time, and disable interval fuzzing it.environment("ANKI_TEST_MODE", "1") From 2024629dbabc19db89568175cec47af5c35b882e Mon Sep 17 00:00:00 2001 From: Koc13 Date: Sun, 30 Aug 2026 18:24:03 +0200 Subject: [PATCH 34/43] fix: Note Editor create/edit toolbar item dialogs Fix validation (and layout) for the custom toolbar item dialogs: - disable the create button until an icon and at leas one of before/after text is entered - align margins in the add/edit toolbar item layouts Part of 20508 Co-authored-by: David Allison <62114487+david-allison@users.noreply.github.com> Assisted-by: Claude Fable 5 (David - a11y fixes) --- .../java/com/ichi2/anki/NoteEditorFragment.kt | 66 ++++++++++++++++--- ...og_note_editor_toolbar_add_custom_item.xml | 21 +++--- ...g_note_editor_toolbar_edit_custom_item.xml | 16 ++--- AnkiDroid/src/main/res/values/02-strings.xml | 2 + AnkiDroid/src/main/res/values/styles.xml | 5 ++ 5 files changed, 84 insertions(+), 26 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt index 132aa3c4f565..ca00f223fd6a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/NoteEditorFragment.kt @@ -64,6 +64,7 @@ import androidx.core.view.WindowInsetsCompat.Type.systemBars import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding +import androidx.core.widget.doAfterTextChanged import androidx.draganddrop.DropHelper import androidx.fragment.app.Fragment import androidx.fragment.app.activityViewModels @@ -74,6 +75,7 @@ import anki.notes.NoteFieldsCheckResponse import com.google.android.material.color.MaterialColors import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar +import com.google.android.material.textfield.TextInputLayout import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.NoteEditorFragment.Companion.NoteEditorCaller.Companion.fromValue @@ -2426,18 +2428,66 @@ class NoteEditorFragment : requireContext().openUrl(R.string.link_manual_note_format_toolbar) }.negativeButton(CommonString.dialog_cancel) + @NeedsTest("Create button disabled until name + one HTML field are filled") private fun displayAddToolbarDialog() { val v = layoutInflater.inflate(R.layout.dialog_note_editor_toolbar_add_custom_item, null) - toolbarDialog.show { - title(CommonString.add_toolbar_item) - setView(v) - positiveButton(CommonString.dialog_positive_create) { - val etIcon = v.findViewById(R.id.note_editor_toolbar_item_icon) - val et = v.findViewById(R.id.note_editor_toolbar_before) - val et2 = v.findViewById(R.id.note_editor_toolbar_after) - addToolbarButton(etIcon.text.toString(), et.text.toString(), et2.text.toString()) + + val etIcon = v.findViewById(R.id.note_editor_toolbar_item_icon) + val etBefore = v.findViewById(R.id.note_editor_toolbar_before) + val etAfter = v.findViewById(R.id.note_editor_toolbar_after) + + val etIconLayout = v.findViewById(R.id.note_editor_toolbar_item_icon_layout) + val etBeforeLayout = v.findViewById(R.id.note_editor_toolbar_before_layout) + val etAfterLayout = v.findViewById(R.id.note_editor_toolbar_after_layout) + + // Both HTML fields share one error message, displayed under etAfterLayout only. + // etBeforeLayout still receives the message so accessibility services report the field as + // invalid, but its text is hidden and it is not a live region, which would announce the message twice + etBeforeLayout.setErrorTextAppearance(R.style.TextAppearance_AnkiDroid_HiddenError) + etBeforeLayout.errorAccessibilityLiveRegion = View.ACCESSIBILITY_LIVE_REGION_NONE + + val dialog = + toolbarDialog.show { + title(CommonString.add_toolbar_item) + setView(v) + positiveButton(CommonString.dialog_positive_create) { + addToolbarButton(etIcon.text.toString(), etBefore.text.toString(), etAfter.text.toString()) + } } + + val posButton = dialog.positiveButton + posButton.isEnabled = false + + // Setting an unchanged error would re-announce it to screen readers on every keystroke + fun TextInputLayout.setErrorIfChanged(message: String) { + if (error == message) return + error = message } + + fun updateToolbarItemValidationState() { + val hasIcon = etIcon.text.toString().isNotBlank() + val hasHtml = etBefore.text.toString().isNotBlank() || etAfter.text.toString().isNotBlank() + posButton.isEnabled = hasIcon && hasHtml + + if (hasIcon) { + etIconLayout.isErrorEnabled = false + } else { + etIconLayout.setErrorIfChanged(getString(R.string.note_editor_toolbar_icon_requirement_error)) + } + + if (hasHtml) { + etBeforeLayout.isErrorEnabled = false + etAfterLayout.isErrorEnabled = false + } else { + val message = getString(R.string.before_after_text_requirement_error) + etBeforeLayout.setErrorIfChanged(message) + etAfterLayout.setErrorIfChanged(message) + } + } + + etIcon.doAfterTextChanged { updateToolbarItemValidationState() } + etBefore.doAfterTextChanged { updateToolbarItemValidationState() } + etAfter.doAfterTextChanged { updateToolbarItemValidationState() } } private fun displayEditToolbarDialog(currentButton: CustomToolbarButton) { diff --git a/AnkiDroid/src/main/res/layout/dialog_note_editor_toolbar_add_custom_item.xml b/AnkiDroid/src/main/res/layout/dialog_note_editor_toolbar_add_custom_item.xml index fad10f62a112..e464233ec972 100644 --- a/AnkiDroid/src/main/res/layout/dialog_note_editor_toolbar_add_custom_item.xml +++ b/AnkiDroid/src/main/res/layout/dialog_note_editor_toolbar_add_custom_item.xml @@ -4,7 +4,7 @@ xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" - android:padding="16dp"> + android:padding="24dp"> + android:paddingBottom="16dp"/> + android:layout_height="wrap_content"/> + android:layout_height="wrap_content"/> + android:layout_height="wrap_content"/> diff --git a/AnkiDroid/src/main/res/layout/dialog_note_editor_toolbar_edit_custom_item.xml b/AnkiDroid/src/main/res/layout/dialog_note_editor_toolbar_edit_custom_item.xml index 703128b63690..a874b3bef807 100644 --- a/AnkiDroid/src/main/res/layout/dialog_note_editor_toolbar_edit_custom_item.xml +++ b/AnkiDroid/src/main/res/layout/dialog_note_editor_toolbar_edit_custom_item.xml @@ -8,7 +8,7 @@ xmlns:app="http://schemas.android.com/apk/res-auto" android:layout_width="match_parent" android:layout_height="match_parent" - android:padding="16dp"> + android:padding="24dp"> + android:paddingBottom="16dp"/> + android:layout_height="wrap_content"/> @@ -80,25 +80,25 @@ android:layout_width="match_parent" android:layout_height="wrap_content" + android:layout_marginTop="8dp" android:hint="@string/before_text"> + android:layout_height="wrap_content"/> + android:layout_height="wrap_content"/> diff --git a/AnkiDroid/src/main/res/values/02-strings.xml b/AnkiDroid/src/main/res/values/02-strings.xml index 271234bce2d1..f6ae538d9976 100644 --- a/AnkiDroid/src/main/res/values/02-strings.xml +++ b/AnkiDroid/src/main/res/values/02-strings.xml @@ -259,6 +259,8 @@ Edit toolbar item Enter HTML to be inserted before and after the selected text\n\nLong press a toolbar item to edit or remove it Remove toolbar item? + Button text is required + At least one HTML field is required The image is too large, please insert the image manually The video file is too large, please insert the video manually diff --git a/AnkiDroid/src/main/res/values/styles.xml b/AnkiDroid/src/main/res/values/styles.xml index 2e64bb90f128..f5ba1bb96faa 100644 --- a/AnkiDroid/src/main/res/values/styles.xml +++ b/AnkiDroid/src/main/res/values/styles.xml @@ -291,6 +291,11 @@ ?attr/editTextHighlightColor + + +