From a4461a5f460d9ec6fa5461ea68ee6074f6a7239c Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:16:53 +0100 Subject: [PATCH 01/50] test(change-note-type): screenshots for layout clipping Prep for 21008 Assisted-by: GPT-6 --- .../ChangeNoteTypeDialogScreenshotTest.kt | 232 +++++++++++++++++- 1 file changed, 231 insertions(+), 1 deletion(-) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/ChangeNoteTypeDialogScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/ChangeNoteTypeDialogScreenshotTest.kt index 04188c335e58..827435ecf809 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/ChangeNoteTypeDialogScreenshotTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/ChangeNoteTypeDialogScreenshotTest.kt @@ -2,22 +2,45 @@ package com.ichi2.anki.dialogs +import android.graphics.Rect +import android.view.ViewGroup +import android.widget.ScrollView +import android.widget.TextView import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updateLayoutParams +import com.google.testing.junit.testparameterinjector.TestParameter import com.ichi2.anki.ScreenshotTest +import com.ichi2.anki.ScreenshotTest.DeviceConfig.DESKTOP +import com.ichi2.anki.databinding.DialogChangeNoteTypeBinding +import com.ichi2.anki.databinding.DialogFieldsBinding +import com.ichi2.anki.databinding.DialogTemplatesBinding +import com.ichi2.anki.databinding.ViewTabLayoutIconOnEndBinding +import com.ichi2.anki.dialogs.ChangeNoteTypeViewModel.Tab import com.ichi2.anki.withCardBrowser import com.ichi2.testutils.simulateSystemBars import com.ichi2.testutils.windowInsetsOf import com.ichi2.utils.dp +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test import org.robolectric.RuntimeEnvironment +@ScreenshotTest.IncludeDevices(DESKTOP) class ChangeNoteTypeDialogScreenshotTest : ScreenshotTest() { + enum class LargeTextScreen( + val qualifiers: String, + ) { + NARROW_PORTRAIT("+w320dp-h568dp-port"), + LANDSCAPE("+land"), + } + @Test fun portrait() = captureChangeNoteType( "portrait", with(targetContext) { windowInsetsOf(navBarBottom = 48.dp) }, - ) + ) { dialog -> assertTabsFillWidth(dialog) } @Test fun gestureNavigation() = @@ -39,18 +62,225 @@ class ChangeNoteTypeDialogScreenshotTest : ScreenshotTest() { captureChangeNoteType( "landscape", with(targetContext) { windowInsetsOf(navBarRight = 48.dp, cutoutLeft = 32.dp) }, + ) { dialog -> assertTabsFillWidth(dialog) } + } + + @Test + fun mappingsFitViewport( + @TestParameter tab: Tab, + ) = captureChangeNoteType( + "${tab.name.lowercase()}_fit_viewport", + with(targetContext) { windowInsetsOf(navBarBottom = 48.dp) }, + tab = tab, + ) { dialog -> + val (scrollView, _) = dialog.mappingViews(tab) + assertFalse("The mappings fit without scrolling", scrollView.canScrollVertically(1)) + scrollMappingsToBottom(dialog, tab, requireScrolling = false) + } + + @Test + fun longNoteTypeNamesWithLargeText( + @TestParameter screen: LargeTextScreen, + ) { + RuntimeEnvironment.setQualifiers(screen.qualifiers) + RuntimeEnvironment.setFontScale(2f) + captureChangeNoteType( + "long_note_type_names_large_text_${screen.name.lowercase()}", + with(targetContext) { windowInsetsOf(navBarBottom = 48.dp) }, + prepareCollection = { + val noteType = col.notetypes.byName("Basic")!! + noteType.name = "A very long note type name that should leave room for mapping fields and templates" + for (number in 3..10) { + col.notetypes.addFieldModChanged(noteType, col.notetypes.newField("Field $number")) + } + col.notetypes.save(noteType) + }, + ) { dialog -> + val binding = dialog.binding + assertTrue("The mappings have a viewport", binding.changeNoteTypePager.height > 0) + scrollMappingsToBottom(dialog, requireScrolling = false) + } + } + + @Test + fun templatesTabWithLargeText() { + RuntimeEnvironment.setQualifiers("+w320dp-h568dp-port") + RuntimeEnvironment.setFontScale(2f) + captureChangeNoteType( + "templates_tab_large_text", + with(targetContext) { windowInsetsOf(navBarBottom = 48.dp) }, + tab = Tab.Templates, + ) { dialog -> + val binding = dialog.binding + val root = dialog.requireView() + val originalWidth = root.layoutParams.width + // Expanding and shrinking the same view must restore the appropriate tab widths. + root.updateLayoutParams { width = root.width * 2 } + advanceRobolectricLooper() + assertTabsFillWidth(dialog) + root.updateLayoutParams { width = originalWidth } + advanceRobolectricLooper() + val tab = binding.changeNoteTypeTabLayout.getTabAt(Tab.Templates.position)!! + val tabBinding = ViewTabLayoutIconOnEndBinding.bind(tab.customView!!) + val icon = tabBinding.tabIcon + val visibleBounds = Rect() + assertTrue("The selected tab icon is visible", icon.getGlobalVisibleRect(visibleBounds)) + assertEquals("The selected tab icon is fully visible", icon.width, visibleBounds.width()) + val text = tabBinding.tabText + assertEquals("The selected tab label fits", 0, text.layout.getEllipsisCount(0)) + } + } + + @Test + fun manyMappingsWithLargeTextInLandscape( + @TestParameter tab: Tab, + ) { + RuntimeEnvironment.setQualifiers("+land") + RuntimeEnvironment.setFontScale(2f) + captureManyMappings("many_${tab.name.lowercase()}_large_text_landscape", tab) + } + + @Test + fun manyMappingsScrolledToBottom( + @TestParameter tab: Tab, + ) = captureManyMappings("many_${tab.name.lowercase()}_scrolled_to_bottom", tab) + + private fun captureManyMappings( + name: String, + tab: Tab, + ) { + val noteType = col.notetypes.byName("Basic")!! + when (tab) { + Tab.Fields -> { + for (number in 3..30) { + val name = if (number == 30) "A long final field name that wraps onto several lines of text" else "Field $number" + col.notetypes.addFieldModChanged(noteType, col.notetypes.newField(name)) + } + } + Tab.Templates -> { + for (number in 2..30) { + val name = if (number == 30) "A long final template name that wraps onto several lines of text" else "Card $number" + val template = + col.notetypes.newTemplate(name).apply { + qfmt = "{{Front}}" + afmt = "{{Back}}" + } + col.notetypes.addTemplateModChanged(noteType, template) + } + } + } + col.notetypes.save(noteType) + + captureChangeNoteType( + name, + with(targetContext) { windowInsetsOf(navBarBottom = 48.dp) }, + tab = tab, + ) { dialog -> + scrollMappingsToBottom(dialog, tab) + } + } + + private val ChangeNoteTypeDialog.binding + get() = DialogChangeNoteTypeBinding.bind(requireView()) + + private fun ChangeNoteTypeDialog.mappingViews(tab: Tab): Pair = + when (tab) { + Tab.Fields -> { + val view = + childFragmentManager.fragments + .filterIsInstance() + .single() + .requireView() + val binding = DialogFieldsBinding.bind(view) + binding.root to binding.fieldsContainer + } + Tab.Templates -> { + val view = + childFragmentManager.fragments + .filterIsInstance() + .single() + .requireView() + val binding = DialogTemplatesBinding.bind(view) + binding.root to binding.templatesContainer + } + } + + private fun assertTabsFillWidth(dialog: ChangeNoteTypeDialog) { + val tabs = dialog.binding.changeNoteTypeTabLayout + val widths = (0 until tabs.tabCount).map { tabs.getTabAt(it)!!.view.width } + assertEquals("The tabs fill the available width", tabs.width - tabs.paddingLeft - tabs.paddingRight, widths.sum()) + assertTrue("The tabs have equal widths", widths.max() - widths.min() <= 1) + } + + private fun scrollMappingsToBottom( + dialog: ChangeNoteTypeDialog, + tab: Tab = Tab.Fields, + requireScrolling: Boolean = true, + ) { + val (scrollView, mappings) = dialog.mappingViews(tab) + val canScroll = scrollView.canScrollVertically(1) + if (requireScrolling) { + assertTrue("Mappings extend below the viewport", canScroll) + } + val lastRow = mappings.getChildAt(mappings.childCount - 1) as ViewGroup + val label = lastRow.getChildAt(1) as TextView + val labelFits = label.height <= scrollView.height - scrollView.paddingTop - scrollView.paddingBottom + if (!labelFits) { + val labelBounds = Rect() + label.getDrawingRect(labelBounds) + scrollView.offsetDescendantRectToMyCoords(label, labelBounds) + scrollView.scrollTo(0, labelBounds.top - scrollView.paddingTop) + advanceRobolectricLooper() + assertLineVisible(label, 0) + } + scrollView.scrollTo(0, scrollView.getChildAt(0).bottom) + advanceRobolectricLooper() + if (canScroll) { + assertTrue("The mappings have scrolled", scrollView.scrollY > 0) + } + + val visibleBounds = Rect() + assertTrue("The last mapping label is visible", label.getGlobalVisibleRect(visibleBounds)) + if (labelFits) { + assertEquals("The last mapping label is fully visible", label.height, visibleBounds.height()) + } else { + // A tall label must scroll, but its final line must remain reachable. + assertLineVisible(label, label.layout.lineCount - 1) + } + } + + private fun assertLineVisible( + label: TextView, + line: Int, + ) { + val visibleBounds = Rect() + assertTrue("The mapping label is visible", label.getGlobalVisibleRect(visibleBounds)) + val location = IntArray(2) + label.getLocationOnScreen(location) + val lineTop = location[1] + label.totalPaddingTop + label.layout.getLineTop(line) + val lineBottom = location[1] + label.totalPaddingTop + label.layout.getLineBottom(line) + assertTrue( + "Line $line ($lineTop..$lineBottom) is fully visible within $visibleBounds", + lineTop >= visibleBounds.top && lineBottom <= visibleBounds.bottom, ) } private fun captureChangeNoteType( name: String, insets: WindowInsetsCompat, + tab: Tab = Tab.Fields, + prepareCollection: () -> Unit = {}, + beforeCapture: (ChangeNoteTypeDialog) -> Unit = {}, ) = withCardBrowser(noteCount = 1) { browser -> + prepareCollection() val dialog = ChangeNoteTypeDialog.newInstance(col.findNotes("")) dialog.showNow(browser.supportFragmentManager, "change_note_type") advanceRobolectricLooper() try { + dialog.binding.changeNoteTypePager.setCurrentItem(tab.position, false) + advanceRobolectricLooper() dialog.requireDialog().window!!.simulateSystemBars(insets) + beforeCapture(dialog) captureScreen(name) } finally { dialog.dismissNow() From 95653a858395cf7e867e7ff283ae7de9362e96bc Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 3 Oct 2026 21:16:56 +0100 Subject: [PATCH 02/50] fix(change-note-type): stop mappings being clipped * Fix scrolling spacing so bottom rows are reachable * Wrapped field/template don't overlap vertically * Truncate note-type names * Tabs handle large text Fixes 21008 Assisted-by: GPT-6 --- .../anki/dialogs/ChangeNoteTypeDialog.kt | 103 +++++++++--------- .../res/layout/dialog_change_note_type.xml | 5 +- .../src/main/res/layout/dialog_fields.xml | 3 +- .../src/main/res/layout/dialog_templates.xml | 3 +- .../src/main/res/values-land/integers.xml | 6 + AnkiDroid/src/main/res/values/integers.xml | 5 + 6 files changed, 69 insertions(+), 56 deletions(-) create mode 100644 AnkiDroid/src/main/res/values-land/integers.xml create mode 100644 AnkiDroid/src/main/res/values/integers.xml diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ChangeNoteTypeDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ChangeNoteTypeDialog.kt index f039a9c6866d..6138da019291 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ChangeNoteTypeDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/ChangeNoteTypeDialog.kt @@ -9,6 +9,8 @@ import android.graphics.Color import android.os.Build import android.os.Bundle import android.text.SpannableStringBuilder +import android.text.TextUtils +import android.view.Gravity import android.view.LayoutInflater import android.view.View import android.view.ViewGroup @@ -26,6 +28,7 @@ import androidx.core.view.ViewCompat import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat.Type.displayCutout import androidx.core.view.WindowInsetsCompat.Type.systemBars +import androidx.core.view.doOnNextLayout import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.fragment.app.Fragment @@ -45,6 +48,7 @@ import com.ichi2.anki.CrashReportData.Companion.toCrashReportData import com.ichi2.anki.R import com.ichi2.anki.analytics.AnalyticsDialogFragment import com.ichi2.anki.common.annotations.NeedsTest +import com.ichi2.anki.compat.setTooltipTextCompat import com.ichi2.anki.databinding.DialogChangeNoteTypeBinding import com.ichi2.anki.databinding.DialogFieldsBinding import com.ichi2.anki.databinding.DialogTemplatesBinding @@ -178,6 +182,7 @@ class ChangeNoteTypeDialog : AnalyticsDialogFragment(R.layout.dialog_change_note private fun setupNoteTypeSpinner(binding: DialogChangeNoteTypeBinding) { binding.CardEditorModelText.text = BidiFormatter.getInstance().unicodeWrap(viewModel.inputNoteType.name) + binding.CardEditorModelText.setTooltipTextCompat(viewModel.inputNoteType.name) binding.destNoteTypeSpinner.apply { adapter = createNoteTypeAdapter() @@ -225,7 +230,10 @@ class ChangeNoteTypeDialog : AnalyticsDialogFragment(R.layout.dialog_change_note text = noteType.name setTextColor(if (noteType.isCloze) clozeColor else defaultViewTextColor) isSingleLine = false - ellipsize = null + // Leave room for the mappings even when the selected note type has a long name. + maxLines = resources.getInteger(R.integer.change_note_type_name_max_lines) + ellipsize = TextUtils.TruncateAt.END + setTooltipTextCompat(noteType.name) updateLayoutParams { height = ViewGroup.LayoutParams.WRAP_CONTENT } @@ -264,6 +272,25 @@ class ChangeNoteTypeDialog : AnalyticsDialogFragment(R.layout.dialog_change_note val tabLayout = binding.changeNoteTypeTabLayout createTabMediator(tabLayout, viewPager).attach() + // Measure the labels and icons before fixed tabs constrain their width. + val minimumTabWidth = + (0 until tabLayout.tabCount).maxOf { position -> + val tab = requireNotNull(tabLayout.getTabAt(position)) + val content = requireNotNull(tab.customView) + content.measure(View.MeasureSpec.UNSPECIFIED, View.MeasureSpec.UNSPECIFIED) + content.measuredWidth + tab.view.paddingLeft + tab.view.paddingRight + } + tabLayout.addOnLayoutChangeListener { _, left, _, right, _, _, _, _, _ -> + val availableWidth = right - left - tabLayout.paddingLeft - tabLayout.paddingRight + val mode = + if (minimumTabWidth * tabLayout.tabCount <= availableWidth) TabLayout.MODE_FIXED else TabLayout.MODE_AUTO + if (tabLayout.tabMode != mode) { + tabLayout.tabMode = mode + tabLayout.doOnNextLayout { + tabLayout.setScrollPosition(tabLayout.selectedTabPosition, 0f, true) + } + } + } // Explicitly set initial tab in ViewModel to match UI viewPager.setCurrentItem(0, false) @@ -423,16 +450,6 @@ class ChangeNoteTypeDialog : AnalyticsDialogFragment(R.layout.dialog_change_note val inputFieldNames = viewModel.inputNoteType.fieldsNames val outputFieldNames = viewModel.outputNoteType.fieldsNames - fun buildFieldLayout() = - LinearLayout(requireContext()).apply { - orientation = LinearLayout.HORIZONTAL - layoutParams = - LinearLayout.LayoutParams( - MATCH_PARENT, - WRAP_CONTENT, - ) - } - fun buildFieldSpinner(spinnerIndex: Int) = Spinner(requireContext()) .apply { @@ -475,20 +492,8 @@ class ChangeNoteTypeDialog : AnalyticsDialogFragment(R.layout.dialog_change_note } } - fun buildFieldText(initialText: String) = - MaterialTextView(requireContext()).apply { - layoutParams = - LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f) - text = initialText - textAlignment = View.TEXT_ALIGNMENT_CENTER - } - for (i in outputFieldNames.indices) { - val fieldLayout = - buildFieldLayout().apply { - addView(buildFieldSpinner(i)) - addView(buildFieldText(outputFieldNames[i])) - } - binding.fieldsContainer.addView(fieldLayout) + for ((i, name) in outputFieldNames.withIndex()) { + binding.fieldsContainer.addView(createMappingRow(buildFieldSpinner(i), name)) } } } @@ -623,16 +628,6 @@ class ChangeNoteTypeDialog : AnalyticsDialogFragment(R.layout.dialog_change_note val inputTemplateNames = viewModel.inputNoteType.templatesNames val outputTemplateNames = viewModel.outputNoteType.templatesNames - fun buildTemplateLayout() = - LinearLayout(requireContext()).apply { - orientation = LinearLayout.HORIZONTAL - layoutParams = - LinearLayout.LayoutParams( - MATCH_PARENT, - WRAP_CONTENT, - ) - } - fun buildTemplateSpinner(spinnerIndex: Int) = Spinner(requireContext()) .apply { @@ -668,30 +663,32 @@ class ChangeNoteTypeDialog : AnalyticsDialogFragment(R.layout.dialog_change_note } } - fun buildTemplateText(templateName: String) = - MaterialTextView(requireContext()).apply { - layoutParams = - LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f) - text = templateName - textAlignment = View.TEXT_ALIGNMENT_CENTER - } - for ((i, name) in outputTemplateNames.withIndex()) { - val templateLayout = - buildTemplateLayout().apply { - addView( - buildTemplateSpinner( - spinnerIndex = i, - ), - ) - addView(buildTemplateText(templateName = name)) - } - binding.templatesContainer.addView(templateLayout) + binding.templatesContainer.addView(createMappingRow(buildTemplateSpinner(i), name)) } } } } +private fun createMappingRow( + spinner: Spinner, + label: String, +) = LinearLayout(spinner.context).apply { + orientation = LinearLayout.HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + // Baseline alignment can push wrapped labels below the row's measured height. + isBaselineAligned = false + layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, WRAP_CONTENT) + addView(spinner) + addView( + MaterialTextView(context).apply { + layoutParams = LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f) + text = label + textAlignment = View.TEXT_ALIGNMENT_CENTER + }, + ) +} + /** * Changes note type of multiple notes, displaying a message on success */ diff --git a/AnkiDroid/src/main/res/layout/dialog_change_note_type.xml b/AnkiDroid/src/main/res/layout/dialog_change_note_type.xml index 30aaf9c9a07d..62fc684fff49 100644 --- a/AnkiDroid/src/main/res/layout/dialog_change_note_type.xml +++ b/AnkiDroid/src/main/res/layout/dialog_change_note_type.xml @@ -98,6 +98,8 @@ android:id="@+id/CardEditorModelText" android:layout_width="match_parent" android:layout_height="wrap_content" + android:maxLines="@integer/change_note_type_name_max_lines" + android:ellipsize="end" android:textDirection="locale" android:textStyle="bold" android:textAppearance="?attr/textAppearanceBodyLarge" /> @@ -178,6 +180,7 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:background="@android:color/transparent" + app:tabMode="fixed" app:tabMaxWidth="0dp" app:tabGravity="fill"/> @@ -189,4 +192,4 @@ /> - \ No newline at end of file + diff --git a/AnkiDroid/src/main/res/layout/dialog_fields.xml b/AnkiDroid/src/main/res/layout/dialog_fields.xml index 0a60821370fa..d59297f79471 100644 --- a/AnkiDroid/src/main/res/layout/dialog_fields.xml +++ b/AnkiDroid/src/main/res/layout/dialog_fields.xml @@ -10,12 +10,13 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:paddingTop="8dp" + android:paddingBottom="16dp" android:fadeScrollbars="false"> + android:paddingTop="16dp"> + android:paddingTop="16dp"> + + + + 1 + diff --git a/AnkiDroid/src/main/res/values/integers.xml b/AnkiDroid/src/main/res/values/integers.xml new file mode 100644 index 000000000000..ba990e5b9f22 --- /dev/null +++ b/AnkiDroid/src/main/res/values/integers.xml @@ -0,0 +1,5 @@ + + + + 2 + From 11498e714180c63486477d11dbb8fed9a02bfdad Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sun, 4 Oct 2026 19:22:47 +0100 Subject: [PATCH 03/50] fix(change-note-type): remove destination down arrow The design of 'To' already indicates it's clickable and we could do with the space to render the note type name From a review comment on 21008 Assisted-by: GPT-6 --- .../src/main/res/layout/dialog_change_note_type.xml | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/AnkiDroid/src/main/res/layout/dialog_change_note_type.xml b/AnkiDroid/src/main/res/layout/dialog_change_note_type.xml index 62fc684fff49..9f1ffc1ba5e6 100644 --- a/AnkiDroid/src/main/res/layout/dialog_change_note_type.xml +++ b/AnkiDroid/src/main/res/layout/dialog_change_note_type.xml @@ -156,21 +156,10 @@ android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_below="@id/to_label" - android:layout_toStartOf="@+id/to_dropdown_arrow" android:layout_alignParentStart="true" android:background="@null" android:padding="0dp" android:minHeight="0dp" /> - - From 2ff52b0d8f86de05b7f53cc13adad46031d4255d Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:37:47 +0100 Subject: [PATCH 04/50] rename: build.gradle => gradle.kts [breaks build] Prepare the Kotlin DSL conversion while preserving git blame. Break the build to maintain git history across the migrated files Issue 20910 --- AnkiDroid/{build.gradle => build.gradle.kts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename AnkiDroid/{build.gradle => build.gradle.kts} (100%) diff --git a/AnkiDroid/build.gradle b/AnkiDroid/build.gradle.kts similarity index 100% rename from AnkiDroid/build.gradle rename to AnkiDroid/build.gradle.kts From 1940c932790adb926df3355d4b0073f7cdfaa7e0 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 3 Oct 2026 19:51:29 +0100 Subject: [PATCH 05/50] refactor(build): convert `build.gradle` to kts Part of Issue 20910 Assisted-by: GPT-6 --- AnkiDroid/build.gradle.kts | 772 ++++++++++-------- .../main/res/values/analytic_constants.xml | 2 +- buildSrc/build.gradle.kts | 2 +- .../anki/gradle/ReleaseMetadataPathTest.kt | 2 +- docs/analytics/README.md | 2 +- tools/release.sh | 10 +- 6 files changed, 425 insertions(+), 365 deletions(-) diff --git a/AnkiDroid/build.gradle.kts b/AnkiDroid/build.gradle.kts index 4faaea5d3f8e..d7a70bc2b197 100644 --- a/AnkiDroid/build.gradle.kts +++ b/AnkiDroid/build.gradle.kts @@ -1,29 +1,36 @@ +// Keep the existing AGP variant/task APIs until the separate AGP DSL migration. +@file:Suppress("DEPRECATION") + import com.ichi2.anki.gradle.VersionCodeTasks import com.ichi2.anki.gradle.WriteReleaseMetadata +import java.io.ByteArrayOutputStream +import java.util.Locale +import java.util.Properties +import java.util.regex.Pattern plugins { // Gradle plugin portal alias(libs.plugins.tripletPlay) - id 'ankidroid.android.app' - id 'ankidroid.plugins.jacoco' + id("ankidroid.android.app") + id("ankidroid.plugins.jacoco") alias(libs.plugins.androidx.baselineprofile) alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kotlin.compose) alias(libs.plugins.keeper) alias(libs.plugins.roborazzi) - id 'idea' + id("idea") } repositories { google() mavenCentral() - maven { url = "https://jitpack.io" } + maven { url = uri("https://jitpack.io") } } keeper { traceReferences { // Silence missing definitions - arguments.set(["--map-diagnostics:MissingDefinitionsDiagnostic", "error", "none"]) + arguments.set(listOf("--map-diagnostics:MissingDefinitionsDiagnostic", "error", "none")) } } @@ -37,20 +44,28 @@ baselineProfile { idea { module { - downloadJavadoc = System.getenv("CI") != "true" - downloadSources = System.getenv("CI") != "true" + isDownloadJavadoc = System.getenv("CI") != "true" + isDownloadSources = System.getenv("CI") != "true" } } -def homePath = System.properties['user.home'] +val testReleaseBuild = rootProject.extra["testReleaseBuild"] as Boolean +val universalApkEnabled = rootProject.extra["universalApkEnabled"] as Boolean +val androidTestVariantName = rootProject.extra["androidTestVariantName"] as String + +val homePath = System.getProperty("user.home") + /** * Calculates the current git hash, invalidates configuration cache if changed * @example edf739d95bad7b370a6ed4398d46723f8219b3cd + * @see https://docs.gradle.org/current/userguide/configuration_cache_requirements.html#config_cache:requirements:external_processes */ -// https://docs.gradle.org/current/userguide/configuration_cache_requirements.html#config_cache:requirements:external_processes -def gitCommitHash = providers.exec { - commandLine "git", "rev-parse", "HEAD" -}.standardOutput.asText.map { it.trim() } +val gitCommitHash = + providers + .exec { + commandLine("git", "rev-parse", "HEAD") + }.standardOutput.asText + .map { it.trim() } /** * Epoch millis of the build. Displayed on the 'About' screen. @@ -60,8 +75,10 @@ def gitCommitHash = providers.exec { * A local build uses a stale value (captured in the configuration cache) so the configuration cache * is not invalidated on every build. */ -def buildTimeMillis = providers.gradleProperty("buildTime") - .orElse(providers.provider { System.currentTimeMillis().toString() }) +val buildTimeMillis = + providers + .gradleProperty("buildTime") + .orElse(providers.provider { System.currentTimeMillis().toString() }) android { namespace = "com.ichi2.anki" @@ -74,7 +91,7 @@ android { compose = true } - if (rootProject.testReleaseBuild) { + if (testReleaseBuild) { testBuildType = "release" } else { testBuildType = "debug" @@ -82,15 +99,15 @@ android { defaultConfig { applicationId = "com.ichi2.anki" - buildConfigField "Boolean", "CI", (System.getenv("CI") == "true").toString() - buildConfigField "String", "ACRA_URL", '"https://ankidroid.org/acra/report"' - buildConfigField "String", "BACKEND_VERSION", "\"${libs.versions.ankiBackend.get()}\"" - buildConfigField "Boolean", "ENABLE_LEAK_CANARY", "false" - buildConfigField "String", "GIT_COMMIT_HASH", "\"${gitCommitHash.get()}\"" - buildConfigField "long", "BUILD_TIME", buildTimeMillis.get() + buildConfigField("Boolean", "CI", (System.getenv("CI") == "true").toString()) + buildConfigField("String", "ACRA_URL", "\"https://ankidroid.org/acra/report\"") + buildConfigField("String", "BACKEND_VERSION", "\"${libs.versions.ankiBackend.get()}\"") + buildConfigField("Boolean", "ENABLE_LEAK_CANARY", "false") + buildConfigField("String", "GIT_COMMIT_HASH", "\"${gitCommitHash.get()}\"") + buildConfigField("long", "BUILD_TIME", buildTimeMillis.get()) // whether UI elements linking to external donation pages are shown ('play' hides them) - buildConfigField "Boolean", "SHOW_DONATE_LINKS", "true" - resValue "string", "app_name", "AnkiDroid" + buildConfigField("Boolean", "SHOW_DONATE_LINKS", "true") + resValue("string", "app_name", "AnkiDroid") // The version number is of the form: // ..[dev|alpha|beta|] @@ -113,12 +130,12 @@ 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=22600102 + versionCode = 22600102 // 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.0alpha2" + versionName = "2.26.0alpha2" testApplicationId = "com.ichi2.anki.tests" vectorDrawables.useSupportLibrary = true - testInstrumentationRunner = 'com.ichi2.testutils.NewCollectionPathTestRunner' + testInstrumentationRunner = "com.ichi2.testutils.NewCollectionPathTestRunner" // GA4 Measurement Protocol api_secret. Sourced from `local.properties` // (ANALYTICS_API_KEY) or the ANALYTICS_API_KEY env var; falls back to @@ -135,113 +152,115 @@ android { // the property, which would corrupt our reporting. We accept that: there is no // server-side path for an offline-first app, the property holds no user data, // and a polluted property can be rotated by issuing a new secret. - Properties properties = new Properties() - def localPropsFile = project.rootProject.file("local.properties") + val properties = Properties() + val localPropsFile = project.rootProject.file("local.properties") if (localPropsFile.exists()) { - properties.load(localPropsFile.newDataInputStream()) + properties.load(localPropsFile.inputStream()) } - def apiKey = properties.getProperty("ANALYTICS_API_KEY") ?: System.getenv("ANALYTICS_API_KEY") ?: "DUMMY_API_XXX" + val apiKey = + properties.getProperty("ANALYTICS_API_KEY")?.takeIf { it.isNotEmpty() } + ?: System.getenv("ANALYTICS_API_KEY")?.takeIf { it.isNotEmpty() } ?: "DUMMY_API_XXX" // Keep credentials out of BuildConfig: ACRA collects its fields through reflection. - resValue "string", "ga_api_secret", apiKey + resValue("string", "ga_api_secret", apiKey) } signingConfigs { - release { - def keystorePath = System.getenv("KEYSTOREPATH") + create("release") { + val keystorePath = System.getenv("KEYSTOREPATH") if (keystorePath != null && !keystorePath.trim().isEmpty()) { - storeFile file(keystorePath) - storePassword System.getenv("KEYSTOREPWD") ?: System.getenv("KSTOREPWD") - keyAlias System.getenv("KEYALIAS") - keyPassword System.getenv("KEYPWD") + storeFile = file(keystorePath) + storePassword = System.getenv("KEYSTOREPWD")?.takeIf { it.isNotEmpty() } ?: System.getenv("KSTOREPWD") + keyAlias = System.getenv("KEYALIAS") + keyPassword = System.getenv("KEYPWD") } else { - storeFile file("${rootDir}/tools/fallback-release-keystore.jks") - storePassword "Test@123" - keyAlias "my-key" - keyPassword "Test@123" + storeFile = file("$rootDir/tools/fallback-release-keystore.jks") + storePassword = "Test@123" + keyAlias = "my-key" + keyPassword = "Test@123" } } } buildTypes { - named('debug') { - versionNameSuffix "-debug" - debuggable true - applicationIdSuffix ".debug" - splits.abi.universalApk = true // Build universal APK for debug always + named("debug") { + versionNameSuffix = "-debug" + isDebuggable = true + applicationIdSuffix = ".debug" + splits.abi.isUniversalApk = true // Build universal APK for debug always // Check Crash Reports page on developer wiki for info on ACRA testing // buildConfigField "String", "ACRA_URL", '"https://918f7f55-f238-436c-b34f-c8b5f1331fe5-bluemix.cloudant.com/acra-ankidroid/_design/acra-storage/_update/report"' - if (project.rootProject.file('local.properties').exists()) { - Properties localProperties = new Properties() - localProperties.load(project.rootProject.file('local.properties').newDataInputStream()) + if (project.rootProject.file("local.properties").exists()) { + val localProperties = Properties() + localProperties.load(project.rootProject.file("local.properties").inputStream()) // #6009 Allow optional disabling of JaCoCo for general build (assembleDebug). // jacocoDebug task was slow, hung, and wasn't required unless I wanted coverage - testCoverageEnabled = localProperties['enable_coverage'] != "false" + isTestCoverageEnabled = localProperties["enable_coverage"] != "false" // not profiled: optimization for build times - if (localProperties['enable_languages'] == "false") { - android.defaultConfig.resConfigs "en" + if (localProperties["enable_languages"] == "false") { + android.defaultConfig.resourceConfigurations += "en" } // allow disabling leak canary if (localProperties["enable_leak_canary"] != null) { - buildConfigField "Boolean", "ENABLE_LEAK_CANARY", localProperties["enable_leak_canary"] + buildConfigField("Boolean", "ENABLE_LEAK_CANARY", localProperties.getProperty("enable_leak_canary")) } else { - buildConfigField "Boolean", "ENABLE_LEAK_CANARY", "true" + buildConfigField("Boolean", "ENABLE_LEAK_CANARY", "true") } } else { - testCoverageEnabled true + isTestCoverageEnabled = true } // make the icon red if in debug mode - resValue 'color', 'anki_foreground_icon_color_0', "#FFFF0000" - resValue 'color', 'anki_foreground_icon_color_1', "#FFFF0000" + resValue("color", "anki_foreground_icon_color_0", "#FFFF0000") + resValue("color", "anki_foreground_icon_color_1", "#FFFF0000") } - named('release') { - testCoverageEnabled = testReleaseBuild - minifyEnabled System.getenv("MINIFY_ENABLED") ? System.getenv("MINIFY_ENABLED") != "false" : true - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - testProguardFile 'proguard-test-rules.pro' - splits.abi.universalApk = universalApkEnabled // Build universal APK for release with `-Duniversal-apk=true` - signingConfig = signingConfigs.release + named("release") { + isTestCoverageEnabled = testReleaseBuild + isMinifyEnabled = System.getenv("MINIFY_ENABLED").let { if (!it.isNullOrEmpty()) it != "false" else true } + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + testProguardFile("proguard-test-rules.pro") + splits.abi.isUniversalApk = universalApkEnabled // Build universal APK for release with `-Duniversal-apk=true` + signingConfig = signingConfigs.getByName("release") // syntax: assembleRelease -PcustomSuffix="suffix" -PcustomName="New name" if (project.hasProperty("customSuffix")) { // the suffix needs a '.' at the start - applicationIdSuffix project.property("customSuffix").replaceFirst(/^\.*/, ".") + applicationIdSuffix = project.property("customSuffix").toString().replaceFirst(Regex("^\\.*"), ".") } if (project.hasProperty("customName")) { - resValue "string", "app_name", project.property("customName") + resValue("string", "app_name", project.property("customName").toString()) } - resValue 'color', 'anki_foreground_icon_color_0', "#FF29B6F6" - resValue 'color', 'anki_foreground_icon_color_1', "#FF0288D1" + resValue("color", "anki_foreground_icon_color_0", "#FF29B6F6") + resValue("color", "anki_foreground_icon_color_1", "#FF0288D1") } - create('benchmark') { - initWith release - signingConfig signingConfigs.debug - matchingFallbacks = ['release'] - debuggable false + create("benchmark") { + initWith(getByName("release")) + signingConfig = signingConfigs.getByName("debug") + matchingFallbacks += listOf("release") + isDebuggable = false } } - /** + /* * Product Flavors are used for Amazon App Store and Google Play Store. * This is because we cannot use Camera Permissions in Amazon App Store (for FireTv etc...) * Therefore, different AndroidManifest for Camera Permissions is used in Amazon flavor. */ flavorDimensions += "appStore" productFlavors { - create('play') { - getIsDefault().set(true) - dimension "appStore" + create("play") { + isDefault = true + dimension = "appStore" // Google Play no longer considers Open Collective to be acceptable for // a 'tax-exempt' link. Temporarily remove our links - buildConfigField "Boolean", "SHOW_DONATE_LINKS", "false" + buildConfigField("Boolean", "SHOW_DONATE_LINKS", "false") } - create('amazon') { - dimension "appStore" + create("amazon") { + dimension = "appStore" } // A 'full' build has no restrictions on storage/camera. Distributed on GitHub/F-Droid - create('full') { - dimension "appStore" + create("full") { + dimension = "appStore" } } @@ -254,51 +273,53 @@ android { * Upload all the APKs to the Play Store and people will download * the correct one based on the CPU architecture of their device. */ - def enableSeparateBuildPerCPUArchitecture = true + val enableSeparateBuildPerCPUArchitecture = true /** * Restricts the build to a single CPU architecture, e.g. `-PabiFilter=arm64-v8a`. * Allows F-Droid to build one APK per architecture without patching this file (#21399). */ - def abiFilter = providers.gradleProperty("abiFilter").getOrNull() - def supportedAbis = ["armeabi-v7a", "x86", "arm64-v8a", "x86_64"] + val abiFilter = providers.gradleProperty("abiFilter").getOrNull() + val supportedAbis = listOf("armeabi-v7a", "x86", "arm64-v8a", "x86_64") if (abiFilter != null && !supportedAbis.contains(abiFilter)) { - throw new GradleException("Unsupported -PabiFilter value '$abiFilter'. Expected one of: $supportedAbis") + throw GradleException("Unsupported -PabiFilter value '$abiFilter'. Expected one of: $supportedAbis") } splits { abi { reset() - enable = enableSeparateBuildPerCPUArchitecture - //universalApk enableUniversalApk // set in debug + release config blocks above + isEnable = enableSeparateBuildPerCPUArchitecture + // universalApk enableUniversalApk // set in debug + release config blocks above if (abiFilter != null) { - include abiFilter + include(abiFilter) } else { - include "armeabi-v7a", "x86", "arm64-v8a", "x86_64" + include("armeabi-v7a", "x86", "arm64-v8a", "x86_64") } } } // applicationVariants are e.g. debug, release - applicationVariants.configureEach { variant -> + applicationVariants.configureEach { + val variant = this // We want the same version stream for all ABIs in debug but for release we can split them - if (variant.buildType.name == 'release') { - variant.outputs.configureEach { output -> + if (variant.buildType.name == "release") { + variant.outputs.configureEach { + val output = this as com.android.build.gradle.internal.api.ApkVariantOutputImpl // For each separate APK per architecture, set a unique version code as described here: // https://developer.android.com/studio/build/configure-apk-splits.html - def versionCodes = ["armeabi-v7a": 1, "x86": 2, "arm64-v8a": 3, "x86_64": 4] - def outputFile = output.outputFile - if (outputFile != null && outputFile.name.endsWith('.apk')) { - def abi = output.getFilter("ABI") - if (abi != null) { // null for the universal-debug, universal-release variants + val versionCodes = mapOf("armeabi-v7a" to 1, "x86" to 2, "arm64-v8a" to 3, "x86_64" to 4) + val outputFile = output.outputFile + if (outputFile != null && outputFile.name.endsWith(".apk")) { + val abi = output.getFilter("ABI") + if (abi != null) { // null for the universal-debug, universal-release variants // From: https://developer.android.com/studio/publish/versioning#appversioning // "Warning: The greatest value Google Play allows for versionCode is 2100000000" // AnkiDroid versionCodes have a budget 8 digits (through AnkiDroid 9) // This style does ABI version code ranges with the 9th digit as 0-4. // This consumes ~20% of the version range space, w/50 years of versioning at our major-version pace output.versionCodeOverride = - // ex: 321200106 = 3 * 100000000 + 21200106 - versionCodes.get(abi) * 100000000 + defaultConfig.versionCode + // ex: 321200106 = 3 * 100000000 + 21200106 + versionCodes.getValue(abi) * 100000000 + defaultConfig.versionCode!! } } } @@ -312,31 +333,38 @@ android { test.useJUnitPlatform { // ./gradlew testFullDebugUnitTest -Pscreenshot if (project.hasProperty("screenshot")) { - includeTags "com.ichi2.anki.ScreenshotTestCategory" + includeTags("com.ichi2.anki.ScreenshotTestCategory") // ./gradlew :AnkiDroid:recordRoborazziPlayDebug -Pscreenshot -Ptheme=black -Pdevice=tablet,foldable - def theme = project.findProperty("theme") ?: "light" - def device = project.findProperty("device") ?: "phone" - def validateInput = { Object input, List validOptions -> - def requested = input.toString().split(',').collect { it.trim().toLowerCase() } - def invalid = requested - validOptions + val theme = project.findProperty("theme")?.takeIf { it.toString().isNotEmpty() } ?: "light" + val device = project.findProperty("device")?.takeIf { it.toString().isNotEmpty() } ?: "phone" + val validateInput = { input: Any, validOptions: List -> + val requested = + Pattern + .compile( + ",", + ).split(input.toString()) + .map { it.trim().lowercase(Locale.getDefault()) } + val invalid = requested - validOptions.toSet() if (!invalid.isEmpty()) { - throw new GradleException("Invalid param: '${invalid.join(', ')}'. Valid options: ${validOptions.join(', ')}.") + throw GradleException( + "Invalid param: '${invalid.joinToString(", ")}'. Valid options: ${validOptions.joinToString(", ")}.", + ) } } - validateInput(theme, ["light", "plain", "dark", "black", "eink", "all"]) - validateInput(device, ["phone", "tablet", "foldable", "desktop", "all"]) + validateInput(theme, listOf("light", "plain", "dark", "black", "eink", "all")) + validateInput(device, listOf("phone", "tablet", "foldable", "desktop", "all")) - systemProperty "screenshot.theme", theme - systemProperty "screenshot.device", device + test.systemProperty("screenshot.theme", theme) + test.systemProperty("screenshot.device", device) } else { - excludeTags "com.ichi2.anki.ScreenshotTestCategory" + excludeTags("com.ichi2.anki.ScreenshotTestCategory") } // Ensures that no EmptyApplication test relies on AnkiDroidApp // by only running EmptyApplication tests // ./gradlew testFullDebugUnitTest -PemptyApplication if (project.hasProperty("emptyApplication")) { - includeTags "com.ichi2.anki.EmptyApplicationCategory" + includeTags("com.ichi2.anki.EmptyApplicationCategory") } } } @@ -353,12 +381,12 @@ android { } compileOptions { - coreLibraryDesugaringEnabled = true + isCoreLibraryDesugaringEnabled = true } packagingOptions { resources { - excludes += ['META-INF/DEPENDENCIES'] + excludes += listOf("META-INF/DEPENDENCIES") } } } @@ -366,15 +394,15 @@ android { // register release tasks using the finalized version configuration. androidComponents.finalizeDsl { androidDsl -> // output versionName etc... to `build/outputs/release-metadata.json` for use in CI - tasks.register('writeReleaseMetadata', WriteReleaseMetadata) { - group = 'release' - description = 'Writes the configured release version and type as JSON.' + tasks.register("writeReleaseMetadata") { + group = "release" + description = "Writes the configured release version and type as JSON." versionName.set(androidDsl.defaultConfig.versionName) versionCode.set(androidDsl.defaultConfig.versionCode) } // register `validateVersionCode` with the finalized version code. - def validation = VersionCodeTasks.registerValidationTask(project, androidDsl.defaultConfig.versionCode) + val validation = VersionCodeTasks.registerValidationTask(project, androidDsl.defaultConfig.versionCode) // handle all 'publish' calls, even if run directly tasks.matching { it.name.startsWith("publish") }.configureEach { dependsOn(validation) @@ -382,13 +410,14 @@ androidComponents.finalizeDsl { androidDsl -> } play { - serviceAccountCredentials.set(file("${homePath}/src/AnkiDroid-GCP-Publish-Credentials.json")) - track.set('alpha') + serviceAccountCredentials.set(file("$homePath/src/AnkiDroid-GCP-Publish-Credentials.json")) + track.set("alpha") // any time we bump minSdk we want Play Store to retain the old artifacts by version code, // so that they remain available for older devices retain { - artifacts.set([ + artifacts.set( + listOf( 20700300L, // (2.7, minSdk 10, universal APK) 20804300L, // (2.8.4, minSdk 10, universal APK) 21004300L, // (2.10.4, minSdk 15, universal APK) @@ -401,7 +430,8 @@ play { 221905300L, // (2.19.5, minSdk 23, ABI x86) 321905300L, // (2.19.5, minSdk 23, ABI arm64-v8a) 421905300L, // (2.19.5, minSdk 23, ABI x86_64) - ]) + ), + ) } // If you retain APKs in a release with different names as we do above, @@ -412,22 +442,32 @@ play { // Install Git pre-commit hook for Ktlint // Resolve via git so worktrees (where `.git` is a file) are handled correctly. -def gitHooksDir = providers.exec { - workingDir = rootProject.rootDir - commandLine "git", "rev-parse", "--git-path", "hooks" -}.standardOutput.asText.map { rootProject.rootDir.toPath().resolve(it.trim()).toFile() } - -tasks.register('installGitHook', Copy) { - from new File(rootProject.rootDir, 'pre-commit') - into gitHooksDir +val gitHooksDir = + providers + .exec { + workingDir = rootProject.rootDir + commandLine("git", "rev-parse", "--git-path", "hooks") + }.standardOutput.asText + .map { + rootProject.rootDir + .toPath() + .resolve(it.trim()) + .toFile() + } + +tasks.register("installGitHook") { + from(File(rootProject.rootDir, "pre-commit")) + into(gitHooksDir) filePermissions { user { - read = write = execute = true + read = true + write = true + execute = true } } } // to run manually: `./gradlew installGitHook` -tasks.named('preBuild').configure { dependsOn('installGitHook') } +tasks.named("preBuild").configure { dependsOn("installGitHook") } // Fail fast when an emulator's primary user (user 0) is still locked after boot. // @@ -441,48 +481,49 @@ tasks.named('preBuild').configure { dependsOn('installGitHook') } // - Only user 0 (secondary profiles being locked must not fail the build). // - Device set and selector precedence exactly match AGP connected tests: // task --serial, connectedCheck --serial, ANDROID_SERIAL, then every online device. -abstract class AssertDevicesUnlocked extends DefaultTask { - @InputFile - abstract RegularFileProperty getAdbExecutable() +abstract class AssertDevicesUnlocked : DefaultTask() { + @get:InputFile + abstract val adbExecutable: RegularFileProperty /** --serial values supplied directly to connectedPlayAndroidTest. */ - @Input - abstract ListProperty getDirectSerials() + @get:Input + abstract val directSerials: ListProperty /** --serial values inherited from connectedCheck. */ - @Input - abstract ListProperty getConnectedCheckSerials() + @get:Input + abstract val connectedCheckSerials: ListProperty /** ANDROID_SERIAL, tracked as task input for configuration-cache correctness. */ - @Input - @Optional - abstract Property getAndroidSerialEnv() + @get:Input + @get:Optional + abstract val androidSerialEnv: Property - @javax.inject.Inject - abstract ExecOperations getExecOperations() + @get:javax.inject.Inject + abstract val execOperations: ExecOperations @TaskAction - void assertDevicesUnlocked() { - def adb = adbExecutable.get().asFile.absolutePath - def selection = selectSerials( - directSerials.present, - directSerials.getOrElse([]), - connectedCheckSerials.getOrElse([]), - androidSerialEnv.getOrNull(), - ) - def onlineSerials = [] - for (String line : adbOutput(adb, 'devices').readLines()) { - if (!(line ==~ /\S+\tdevice/)) { + fun assertDevicesUnlocked() { + val adb = adbExecutable.get().asFile.absolutePath + val selection = + selectSerials( + directSerials.isPresent, + directSerials.getOrElse(emptyList()), + connectedCheckSerials.getOrElse(emptyList()), + androidSerialEnv.getOrNull(), + ) + val onlineSerials = mutableListOf() + for (line in adbOutput(adb, "devices").lines()) { + if (!Regex("""\S+\tdevice""").matches(line)) { continue } - onlineSerials.add(line.split('\t')[0]) + onlineSerials.add(line.split("\t")[0]) } - if (!onlineSerials) { - throw new GradleScriptException("No online devices found.", null) + if (onlineSerials.isEmpty()) { + throw GradleException("No online devices found.") } - def serialsToCheck = onlineSerials + var serialsToCheck: List = onlineSerials if (selection.serials != null) { // Match AGP's intended multiset behavior for task-option lists: each online device // consumes one requested serial. Repeating the same --serial twice therefore leaves @@ -496,38 +537,41 @@ abstract class AssertDevicesUnlocked extends DefaultTask { // public Issue Tracker ID found as of 2026-09-22. ANDROID_SERIAL still works because // that path never calls getFilteredDevices(). This preflight copies into an ArrayList // so we do not inherit the same crash. - def unmatched = new ArrayList(selection.serials) - for (String onlineSerial : onlineSerials) { + val unmatched = ArrayList(selection.serials) + for (onlineSerial in onlineSerials) { unmatched.remove(onlineSerial) } - if (unmatched) { - throw new GradleScriptException( + if (unmatched.isNotEmpty()) { + throw GradleException( selection.source + " lists device(s) not connected (or not in 'device' state): " + - unmatched.join(', ') + ". Connected: " + onlineSerials.join(', ') + ".", - null, + unmatched.joinToString(", ") + ". Connected: " + onlineSerials.joinToString(", ") + ".", ) } - serialsToCheck = selection.serials.unique() + serialsToCheck = selection.serials.distinct() } - logger.info("Checking user-0 unlock state on ${serialsToCheck} selected by ${selection.source}") - def locked = [] - for (String serial : serialsToCheck) { - if (isUser0Locked(adbOutput(adb, '-s', serial, 'shell', 'dumpsys', 'user'))) { + logger.info("Checking user-0 unlock state on $serialsToCheck selected by ${selection.source}") + val locked = mutableListOf() + for (serial in serialsToCheck) { + if (isUser0Locked(adbOutput(adb, "-s", serial, "shell", "dumpsys", "user"))) { locked.add(serial) } } - if (locked) { - throw new GradleScriptException( - "Device(s) " + locked.join(', ') + " have booted but user 0 is still locked, so every " + + if (locked.isNotEmpty()) { + throw GradleException( + "Device(s) " + locked.joinToString(", ") + " have booted but user 0 is still locked, so every " + "test would fail in Application.onCreate. Unlock the screen (a Play AVD with an " + "account + startup PIN stays locked until the PIN is entered), or select an unlocked " + "device with --serial / ANDROID_SERIAL.", - null, ) } } + data class SerialSelection( + val source: String, + val serials: List?, + ) + /** * Select serials using AGP DeviceProviderInstrumentTestTask.getTargetSerials() + run(). * @@ -537,23 +581,23 @@ abstract class AssertDevicesUnlocked extends DefaultTask { * ANDROID_SERIAL is Splitter.on(',').split with no trim (ConnectedDeviceProvider). * A null serial list means AGP will run against every online device. */ - static Map selectSerials( - boolean directSerialsPresent, - List directSerials, - List connectedCheckSerials, - String androidSerialEnv - ) { - def targetSerials = directSerialsPresent ? directSerials : connectedCheckSerials - def source = directSerialsPresent ? 'Task --serial' : 'connectedCheck --serial' - if (targetSerials) { - return [source: source, serials: targetSerials] + fun selectSerials( + directSerialsPresent: Boolean, + directSerials: List, + connectedCheckSerials: List, + androidSerialEnv: String?, + ): SerialSelection { + val targetSerials = if (directSerialsPresent) directSerials else connectedCheckSerials + val source = if (directSerialsPresent) "Task --serial" else "connectedCheck --serial" + if (targetSerials.isNotEmpty()) { + return SerialSelection(source, targetSerials) } if (androidSerialEnv != null && !androidSerialEnv.isEmpty()) { // AGP uses Sets.newHashSet(Splitter.on(',').split(androidSerialEnv)). - def serials = androidSerialEnv.split(',', -1).toList().unique() - return [source: 'ANDROID_SERIAL', serials: serials] + val serials = androidSerialEnv.split(",").distinct() + return SerialSelection("ANDROID_SERIAL", serials) } - return [source: 'connected devices', serials: null] + return SerialSelection("connected devices", null) } /** @@ -564,72 +608,88 @@ abstract class AssertDevicesUnlocked extends DefaultTask { * exactly so a locked secondary profile (e.g. 10=RUNNING_LOCKED / 10=1) cannot false-positive, * including via the "0=RUNNING_LOCKED" substring inside "10=RUNNING_LOCKED". */ - static boolean isUser0Locked(String dumpsysUser) { - def matcher = dumpsysUser =~ /Started users state: [\[{]([^\]}]*)[\]}]/ - if (!matcher.find()) { + fun isUser0Locked(dumpsysUser: String): Boolean { + val matcher = Regex("""Started users state: [\[{]([^\]}]*)[\]}]""").find(dumpsysUser) + if (matcher == null) { return false } - for (String entry : matcher.group(1).split(',')) { - def parts = entry.trim().split('=', 2) - if (parts.length != 2 || parts[0] != '0') { + for (entry in matcher.groupValues[1].split(",")) { + val parts = entry.trim().split("=", limit = 2) + if (parts.size != 2 || parts[0] != "0") { continue } - def state = parts[1] + val state = parts[1] // Numeric (API 30 dumps) or named (newer dumps). STATE_RUNNING_LOCKED = 1. - return state == 'RUNNING_LOCKED' || state == '1' + return state == "RUNNING_LOCKED" || state == "1" } return false } - private String adbOutput(String adb, String... args) { - def output = new ByteArrayOutputStream() + private fun adbOutput( + adb: String, + vararg args: String, + ): String { + val output = ByteArrayOutputStream() execOperations.exec { - it.commandLine([adb, *args]) - it.standardOutput = output + commandLine(adb, *args) + standardOutput = output } return output.toString() } } -tasks.register('assertDevicesUnlocked', AssertDevicesUnlocked) { +tasks.register("assertDevicesUnlocked") { adbExecutable.set(androidComponents.sdkComponents.adb) // No convention([]) on the --serial lists: AGP getTargetSerials() keys off isPresent(), // and an empty convention would make the direct property look intentionally set. - androidSerialEnv.set(providers.environmentVariable('ANDROID_SERIAL')) + androidSerialEnv.set(providers.environmentVariable("ANDROID_SERIAL")) // Device state is not an input Gradle can track, so never consider this task up to date outputs.upToDateWhen { false } } // Issue 11078 - some emulators run, but run zero tests, and still report success -tasks.register('assertNonzeroAndroidTests') { +tasks.register("assertNonzeroAndroidTests") { // Resolve the directory at configuration time, which is Gradle Configuration Cache compatible. // The build type segment must track the variant the connected test task runs under, so it is // derived from the same property that names that task. - def buildType = rootProject.androidTestVariantName.toLowerCase(Locale.ROOT) - def resultsDir = layout.buildDirectory.dir("outputs/androidTest-results/connected/${buildType}/flavors/play") + val buildType = androidTestVariantName.lowercase(Locale.ROOT) + val resultsDir = layout.buildDirectory.dir("outputs/androidTest-results/connected/$buildType/flavors/play") doLast { // androidTest currently creates one .xml file per emulator with aggregate results in this dir - File folder = resultsDir.get().asFile - File[] listOfFiles = folder.listFiles({ d, f -> f ==~ /.*.xml/ } as FilenameFilter) + val folder = resultsDir.get().asFile + val listOfFiles = folder.listFiles { _, f -> Regex(".*.xml").matches(f) } // No results at all is the same failure this guard exists to catch, and it is how the guard // itself goes unnoticed if the results layout moves again - if (!listOfFiles) { - throw new GradleScriptException("No androidTest result files in " + folder + " - the connected test run reported success without producing results.", null) + if (listOfFiles.isNullOrEmpty()) { + throw GradleException( + "No androidTest result files in " + folder + " - the connected test run reported success without producing results.", + ) } - for (File file : listOfFiles) { + for (file in listOfFiles) { // The root element carries the aggregate count for one device, across any number of // nested per-class elements - def declaredTests = new groovy.xml.XmlSlurper().parse(file).@tests.text() - if (!declaredTests.isInteger()) { - throw new GradleScriptException("Unable to determine count of tests executed for " + file.name + ". Results format changed?", null) + val declaredTests = + groovy.xml + .XmlSlurper() + .parse(file) + .getProperty("@tests") + .toString() + if (declaredTests.trim().toIntOrNull() == null) { + throw GradleException("Unable to determine count of tests executed for " + file.name + ". Results format changed?") } - if (declaredTests.toInteger() == 0) { - throw new GradleScriptException("androidTest executed 0 tests for " + file.name + " - Probably a bug with the emulator. Try another image.", null) + if (declaredTests.trim().toInt() == 0) { + throw GradleException( + "androidTest executed 0 tests for " + file.name + " - Probably a bug with the emulator. Try another image.", + ) } } } } afterEvaluate { - def connectedTest = tasks.named("connectedPlay${rootProject.androidTestVariantName}AndroidTest").get() + val connectedTest = + tasks + .named( + "connectedPlay${androidTestVariantName}AndroidTest", + ).get() // Keep the preflight device set in lockstep with AGP's connected-test selection. The nested // properties include task --serial and connectedCheck --serial after Gradle parses command-line // task options; AGP applies the same precedence in getTargetSerials(). @@ -638,27 +698,27 @@ afterEvaluate { // DeviceProviderInstrumentTestTask.getFilteredDevices(), which calls List.remove on the // ImmutableList returned by ListProperty.get(). Reproduced without this wiring; no public // tracker ID found as of 2026-09-22. Use ANDROID_SERIAL until AGP copies the list first. - tasks.named('assertDevicesUnlocked').configure { + tasks.named("assertDevicesUnlocked").configure { directSerials.set(connectedTest.testRunnerFactory.deviceSerialValues) connectedCheckSerials.set(connectedTest.testRunnerFactory.connectedCheckDeviceSerials) } - connectedTest.dependsOn('assertDevicesUnlocked') - connectedTest.finalizedBy('assertNonzeroAndroidTests') + connectedTest.dependsOn("assertDevicesUnlocked") + connectedTest.finalizedBy("assertNonzeroAndroidTests") // AGP bug: when testFixtures is enabled on an application module, the androidTest R8 task's // TESTED_CODE scope only includes the app's dependency artifacts, not the app's own compiled // classes. This causes R8 to report "Missing class" errors for all app classes referenced from // androidTest code. Work around by adding the app classes as referenced (library) input. if (testReleaseBuild) { - tasks.named("minifyPlayReleaseAndroidTestWithR8").configure { + tasks.named("minifyPlayReleaseAndroidTestWithR8").configure { referencedClasses.from( - files("build/intermediates/compile_app_classes_jar/playRelease/bundlePlayReleaseClassesToCompileJar/classes.jar") + files("build/intermediates/compile_app_classes_jar/playRelease/bundlePlayReleaseClassesToCompileJar/classes.jar"), ) } } } -apply from: "./robolectricDownloader.gradle" +apply(from = "./robolectricDownloader.gradle") android { lint { @@ -669,187 +729,187 @@ android { } } -dependencies { - configurations.configureEach { - resolutionStrategy { - // Timber has this as a dependency but they are not up to date. We want to force our version. - force libs.jetbrains.annotations - } +configurations.configureEach { + resolutionStrategy { + // Timber has this as a dependency but they are not up to date. We want to force our version. + force(libs.jetbrains.annotations) } - api project(":api") - implementation libs.androidx.work.runtime - lintChecks project(":lint-rules") - coreLibraryDesugaring libs.desugar.jdk.libs.nio +} + +dependencies { + api(project(":api")) + implementation(libs.androidx.work.runtime) + lintChecks(project(":lint-rules")) + coreLibraryDesugaring(libs.desugar.jdk.libs.nio) - compileOnly libs.jetbrains.annotations - compileOnly libs.auto.service.annotations - annotationProcessor libs.auto.service + compileOnly(libs.jetbrains.annotations) + compileOnly(libs.auto.service.annotations) + annotationProcessor(libs.auto.service) // modules - implementation project(":anki-common") - implementation project(":common") - implementation project(":common:android") - implementation project(":compat") - implementation project(":libanki") - implementation project(":vbpd") - "baselineProfile" project(":baselineprofile") - implementation project(":widgets") + implementation(project(":anki-common")) + implementation(project(":common")) + implementation(project(":common:android")) + implementation(project(":compat")) + implementation(project(":libanki")) + implementation(project(":vbpd")) + "baselineProfile"(project(":baselineprofile")) + implementation(project(":widgets")) implementation(libs.google.analytics.kt) - implementation libs.androidx.activity + implementation(libs.androidx.activity) // TODO: hoist the Compose enablement (kotlin.compose plugin, buildFeatures.compose, // and the dependencies below) into a convention plugin under `buildSrc/` once the // Kotlin-DSL migration of this build script lands - implementation libs.androidx.activity.compose - implementation platform(libs.androidx.compose.bom) - implementation libs.androidx.compose.ui - implementation libs.androidx.compose.ui.tooling.preview - implementation libs.androidx.compose.material3 - implementation libs.androidx.lifecycle.viewmodel.compose - implementation libs.androidx.lifecycle.runtime.compose - debugImplementation libs.androidx.compose.ui.tooling - implementation libs.androidx.annotation - implementation libs.androidx.appcompat - implementation libs.androidx.browser - implementation libs.androidx.core.ktx - implementation libs.androidx.draganddrop - implementation libs.androidx.exifinterface - implementation libs.androidx.fragment.ktx - implementation libs.androidx.lifecycle.process - implementation libs.androidx.preference.ktx - implementation libs.androidx.profileinstaller - implementation libs.androidx.recyclerview - implementation libs.androidx.sqlite.framework - implementation libs.androidx.swiperefreshlayout - implementation libs.androidx.viewpager2 - implementation libs.androidx.constraintlayout - implementation libs.androidx.webkit - implementation libs.google.material - implementation libs.android.image.cropper - implementation libs.nanohttpd - implementation libs.kotlinx.serialization.json - implementation libs.seismic - - debugImplementation libs.androidx.fragment.testing.manifest + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.compose.ui) + implementation(libs.androidx.compose.ui.tooling.preview) + implementation(libs.androidx.compose.material3) + implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.lifecycle.runtime.compose) + debugImplementation(libs.androidx.compose.ui.tooling) + implementation(libs.androidx.annotation) + implementation(libs.androidx.appcompat) + implementation(libs.androidx.browser) + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.draganddrop) + implementation(libs.androidx.exifinterface) + implementation(libs.androidx.fragment.ktx) + implementation(libs.androidx.lifecycle.process) + implementation(libs.androidx.preference.ktx) + implementation(libs.androidx.profileinstaller) + implementation(libs.androidx.recyclerview) + implementation(libs.androidx.sqlite.framework) + implementation(libs.androidx.swiperefreshlayout) + implementation(libs.androidx.viewpager2) + implementation(libs.androidx.constraintlayout) + implementation(libs.androidx.webkit) + implementation(libs.google.material) + implementation(libs.android.image.cropper) + implementation(libs.nanohttpd) + implementation(libs.kotlinx.serialization.json) + implementation(libs.seismic) + + debugImplementation(libs.androidx.fragment.testing.manifest) // Backend libraries - implementation libs.protobuf.kotlin.lite // This is required when loading from a file + implementation(libs.protobuf.kotlin.lite) // This is required when loading from a file // TODO: migrate to `addAnkiBackendDependencies(...)` when this is a gradle.kts file - Properties localProperties = new Properties() - if (project.rootProject.file('local.properties').exists()) { - localProperties.load(project.rootProject.file('local.properties').newDataInputStream()) + val localProperties = Properties() + if (project.rootProject.file("local.properties").exists()) { + localProperties.load(project.rootProject.file("local.properties").inputStream()) } - if (localProperties['local_backend'] == "true") { + if (localProperties["local_backend"] == "true") { implementation(files(rootProject.file("../Anki-Android-Backend/rsdroid/build/outputs/aar/rsdroid-release.aar"))) testImplementation(files(rootProject.file("../Anki-Android-Backend/rsdroid-testing/build/libs/rsdroid-testing.jar"))) } else { - implementation libs.ankiBackend.backend - testImplementation libs.ankiBackend.testing + implementation(libs.ankiBackend.backend) + testImplementation(libs.ankiBackend.testing) } // May need a resolution strategy for support libs to our versions - implementation libs.acra.limiter - implementation libs.acra.toast - implementation libs.acra.dialog - implementation libs.acra.http - - implementation libs.commons.compress - implementation libs.commons.collections4 // SetUniqueList - implementation libs.commons.io // FileUtils.contentEquals - implementation libs.okhttp - implementation libs.slf4j.timber - implementation libs.jakewharton.timber - implementation libs.jsoup - implementation libs.java.semver // For AnkiDroid JS API Versioning - implementation libs.drakeet.drawer - implementation libs.skydoves.colorpickerview - implementation libs.kotlin.reflect - implementation libs.kotlin.test - implementation libs.search.preference + implementation(libs.acra.limiter) + implementation(libs.acra.toast) + implementation(libs.acra.dialog) + implementation(libs.acra.http) + + implementation(libs.commons.compress) + implementation(libs.commons.collections4) // SetUniqueList + implementation(libs.commons.io) // FileUtils.contentEquals + implementation(libs.okhttp) + implementation(libs.slf4j.timber) + implementation(libs.jakewharton.timber) + implementation(libs.jsoup) + implementation(libs.java.semver) // For AnkiDroid JS API Versioning + implementation(libs.drakeet.drawer) + implementation(libs.skydoves.colorpickerview) + implementation(libs.kotlin.reflect) + implementation(libs.kotlin.test) + implementation(libs.search.preference) // Cannot use debugImplementation since classes need to be imported in AnkiDroidApp // and there's no no-op version for release build. Usage has been disabled for release // build via AnkiDroidApp. - implementation libs.leakcanary.android + implementation(libs.leakcanary.android) - testImplementation testFixtures(project(":libanki")) - testImplementation testFixtures(project(":common")) - testImplementation testFixtures(project(":common:android")) - testImplementation testFixtures(project(":compat")) - testImplementation testFixtures(project(":anki-common")) - androidTestImplementation testFixtures(project(":anki-common")) + testImplementation(testFixtures(project(":libanki"))) + testImplementation(testFixtures(project(":common"))) + testImplementation(testFixtures(project(":common:android"))) + testImplementation(testFixtures(project(":compat"))) + testImplementation(testFixtures(project(":anki-common"))) + androidTestImplementation(testFixtures(project(":anki-common"))) // A path for a testing library which provide Parameterized Test - testImplementation libs.junit.jupiter - testImplementation libs.junit.jupiter.params - testImplementation libs.junit.vintage.engine - testImplementation libs.mockito.inline - testImplementation libs.mockito.kotlin - testImplementation libs.hamcrest - testImplementation libs.test.parameter.injector + testImplementation(libs.junit.jupiter) + testImplementation(libs.junit.jupiter.params) + testImplementation(libs.junit.vintage.engine) + testImplementation(libs.mockito.inline) + testImplementation(libs.mockito.kotlin) + testImplementation(libs.hamcrest) + testImplementation(libs.test.parameter.injector) // robolectricDownloader.gradle *may* need a new SDK jar entry if they release one or if we change targetSdk. Instructions in that gradle file. - testImplementation libs.robolectric - testImplementation libs.androidx.compose.ui.test.junit4 + testImplementation(libs.robolectric) + testImplementation(libs.androidx.compose.ui.test.junit4) // Needed for createComposeRule(), but not for createAndroidComposeRule() - debugImplementation libs.androidx.compose.ui.test.manifest - testImplementation libs.androidx.test.core - testImplementation libs.androidx.test.junit - testImplementation libs.kotlin.reflect - testImplementation libs.kotlin.test - testImplementation libs.kotlin.test.junit5 - testImplementation libs.kotlinx.coroutines.test - testImplementation libs.mockk - testImplementation libs.androidx.fragment.testing + debugImplementation(libs.androidx.compose.ui.test.manifest) + testImplementation(libs.androidx.test.core) + testImplementation(libs.androidx.test.junit) + testImplementation(libs.kotlin.reflect) + testImplementation(libs.kotlin.test) + testImplementation(libs.kotlin.test.junit5) + testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.mockk) + testImplementation(libs.androidx.fragment.testing) // in a JvmTest we need org.json.JSONObject to not be mocked - testImplementation libs.json - testImplementation libs.ivanshafran.shared.preferences.mock - testImplementation libs.androidx.test.runner - testImplementation libs.androidx.test.rules - testImplementation libs.androidx.espresso.core + testImplementation(libs.json) + testImplementation(libs.ivanshafran.shared.preferences.mock) + testImplementation(libs.androidx.test.runner) + testImplementation(libs.androidx.test.rules) + testImplementation(libs.androidx.espresso.core) testImplementation(libs.androidx.espresso.contrib) { - exclude module: "protobuf-lite" + exclude(module = "protobuf-lite") } - testImplementation libs.androidx.work.testing - testImplementation libs.roborazzi - testImplementation libs.roborazzi.rule + testImplementation(libs.androidx.work.testing) + testImplementation(libs.roborazzi) + testImplementation(libs.roborazzi.rule) // for testing flows - testImplementation libs.cashapp.turbine + testImplementation(libs.cashapp.turbine) - androidTestImplementation testFixtures(project(":anki-common")) + androidTestImplementation(testFixtures(project(":anki-common"))) // May need a resolution strategy for support libs to our versions - androidTestImplementation libs.androidx.espresso.core + androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.contrib) { - exclude module: "protobuf-lite" + exclude(module = "protobuf-lite") } - androidTestImplementation libs.androidx.test.core - androidTestImplementation libs.androidx.test.junit - androidTestImplementation libs.androidx.test.rules - androidTestImplementation libs.androidx.uiautomator - androidTestImplementation libs.kotlin.test - androidTestImplementation libs.kotlin.test.junit - androidTestImplementation libs.androidx.fragment.testing + androidTestImplementation(libs.androidx.test.core) + androidTestImplementation(libs.androidx.test.junit) + androidTestImplementation(libs.androidx.test.rules) + androidTestImplementation(libs.androidx.uiautomator) + androidTestImplementation(libs.kotlin.test) + androidTestImplementation(libs.kotlin.test.junit) + androidTestImplementation(libs.androidx.fragment.testing) - implementation libs.androidx.media3.exoplayer - implementation libs.androidx.media3.exoplayer.dash - implementation libs.androidx.media3.ui + implementation(libs.androidx.media3.exoplayer) + implementation(libs.androidx.media3.exoplayer.dash) + implementation(libs.androidx.media3.ui) // ---- testFixtures setup ---- - testFixturesImplementation libs.kotlin.stdlib - testFixturesImplementation libs.jakewharton.timber - testFixturesImplementation libs.hamcrest - testFixturesImplementation libs.androidx.test.junit + testFixturesImplementation(libs.kotlin.stdlib) + testFixturesImplementation(libs.jakewharton.timber) + testFixturesImplementation(libs.hamcrest) + testFixturesImplementation(libs.androidx.test.junit) // Required so the ExperimentalCoroutinesApi opt-in (applied globally) doesn't cause // an "unresolved" warning, which is treated as an error due to allWarningsAsErrors - testFixturesImplementation libs.kotlinx.coroutines.core + testFixturesImplementation(libs.kotlinx.coroutines.core) // The Kotlin Compose Compiler plugin attaches to every Kotlin compilation in the // module including testFixtures, which has no @Composable code and refuses to // run unless the Compose Runtime is on the classpath. compileOnly satisfies the // plugin's version check without shipping the runtime in the testFixtures output. - testFixturesCompileOnly platform(libs.androidx.compose.bom) - testFixturesCompileOnly libs.androidx.compose.runtime - + testFixturesCompileOnly(platform(libs.androidx.compose.bom)) + testFixturesCompileOnly(libs.androidx.compose.runtime) } diff --git a/AnkiDroid/src/main/res/values/analytic_constants.xml b/AnkiDroid/src/main/res/values/analytic_constants.xml index 5b777324e5ab..c3dd464ce67d 100644 --- a/AnkiDroid/src/main/res/values/analytic_constants.xml +++ b/AnkiDroid/src/main/res/values/analytic_constants.xml @@ -11,7 +11,7 @@ 4. Copy the Measurement ID shown at the top of the stream details The matching Measurement Protocol api_secret is read at build time as - BuildConfig.ANALYTICS_API_KEY (see AnkiDroid/build.gradle). Create it + BuildConfig.ANALYTICS_API_KEY (see AnkiDroid/build.gradle.kts). Create it under the same Data Stream -> Measurement Protocol API secrets. --> G-XY625S5VVE diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 7dc138d8ff8f..02e06c222126 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -27,7 +27,7 @@ dependencies { tasks.test { useJUnitPlatform() // Rerun the path guard if the app build file is moved. - inputs.files("../AnkiDroid/build.gradle") + inputs.files("../AnkiDroid/build.gradle.kts") } configure { diff --git a/buildSrc/src/test/kotlin/com/ichi2/anki/gradle/ReleaseMetadataPathTest.kt b/buildSrc/src/test/kotlin/com/ichi2/anki/gradle/ReleaseMetadataPathTest.kt index 474381f7af04..e1d259787ea1 100644 --- a/buildSrc/src/test/kotlin/com/ichi2/anki/gradle/ReleaseMetadataPathTest.kt +++ b/buildSrc/src/test/kotlin/com/ichi2/anki/gradle/ReleaseMetadataPathTest.kt @@ -10,7 +10,7 @@ class ReleaseMetadataPathTest { @Test fun `app build file stays at the path assumed by the publishing script`() { assertTrue( - File("../AnkiDroid/build.gradle").isFile, + File("../AnkiDroid/build.gradle.kts").isFile, "Update .github/check_alpha_release.js if the AnkiDroid module is moved.", ) } diff --git a/docs/analytics/README.md b/docs/analytics/README.md index 99ec4339f330..74dafea0810d 100644 --- a/docs/analytics/README.md +++ b/docs/analytics/README.md @@ -118,7 +118,7 @@ them into AnkiDroid like so: | Value | Where it goes | |---|---| | Measurement ID (`G-XXXXXXXX`) | `AnkiDroid/src/main/res/values/analytic_constants.xml` → `ga_trackingId` | -| API secret | `local.properties` → `ANALYTICS_API_KEY=...` (read at compile time into `BuildConfig.ANALYTICS_API_KEY`, see [`AnkiDroid/build.gradle`](../../AnkiDroid/build.gradle)) | +| API secret | `local.properties` → `ANALYTICS_API_KEY=...` (read at compile time into `BuildConfig.ANALYTICS_API_KEY`, see [`AnkiDroid/build.gradle.kts`](../../AnkiDroid/build.gradle.kts)) | Builds without an `ANALYTICS_API_KEY` fall back to `DUMMY_API_XXX`, which GA rejects at ingest contributor builds can't accidentally write to our diff --git a/tools/release.sh b/tools/release.sh index 80ffc4f2ed21..a7aad6573cd6 100755 --- a/tools/release.sh +++ b/tools/release.sh @@ -31,16 +31,16 @@ if [ "$PUBLIC" = "public" ] && ! [ -f ../ankidroiddocs/changelog.asc ]; then fi # Captured once so every APK in this release reports the same BUILD_TIME -# (consumed by AnkiDroid/build.gradle via -PbuildTime). +# (consumed by AnkiDroid/build.gradle.kts via -PbuildTime). BUILD_TIME_MS=$(date +%s000) export BUILD_TIME_MS # Define the location of the manifest file SRC_DIR="./AnkiDroid" -GRADLEFILE="$SRC_DIR/build.gradle" +GRADLEFILE="$SRC_DIR/build.gradle.kts" CHANGELOG="$SRC_DIR/src/main/assets/changelog.html" -if ! VERSION=$(grep 'versionName=' $GRADLEFILE | sed -e 's/.*="//' | sed -e 's/".*//') +if ! VERSION=$(grep 'versionName = ' $GRADLEFILE | sed -e 's/.*= "//' | sed -e 's/".*//') then echo "Unable to get current version. Is sed installed?" exit 1 @@ -87,13 +87,13 @@ if [ "$PUBLIC" != "public" ]; then # Increment version code # It is an integer in AndroidManifest that nobody actually sees. # Ex: 72 to 73 - PREVIOUS_CODE=$(grep 'versionCode=' $GRADLEFILE | sed -e 's/.*=//') + PREVIOUS_CODE=$(grep 'versionCode = ' $GRADLEFILE | sed -e 's/.*= //') GUESSED_CODE=$((PREVIOUS_CODE + 1)) # Edit AndroidManifest.xml to bump version string echo "Bumping version from $PREVIOUS_VERSION$SUFFIX to $VERSION (and code from $PREVIOUS_CODE to $GUESSED_CODE)" sed -i -e s/"$PREVIOUS_VERSION""$SUFFIX"/"$VERSION"/g $GRADLEFILE - sed -i -e s/versionCode="$PREVIOUS_CODE"/versionCode="$GUESSED_CODE"/g $GRADLEFILE + sed -i -e s/"versionCode = $PREVIOUS_CODE"/"versionCode = $GUESSED_CODE"/g $GRADLEFILE fi # Validate the resulting versionCode before committing or tagging the release. From 471eb7c9d0fdd2592ca8c859023836556d69a912 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:42:53 +0100 Subject: [PATCH 06/50] fix(sync): use network timeout in background syncs `SyncAuth` was not constructed correctly, dropping ioTimeoutSecs We now handle this via `toProto().toByteArray()` Fixes 22293 Assisted-by: GPT-6 --- .../com/ichi2/anki/worker/SyncAuthWorkData.kt | 28 ++++ .../com/ichi2/anki/worker/SyncMediaWorker.kt | 21 +-- .../java/com/ichi2/anki/worker/SyncWorker.kt | 17 +-- .../anki/worker/BackgroundSyncAuthTest.kt | 140 ++++++++++++++++++ .../ichi2/anki/worker/SyncMediaWorkerTest.kt | 5 +- 5 files changed, 175 insertions(+), 36 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncAuthWorkData.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/worker/BackgroundSyncAuthTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncAuthWorkData.kt b/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncAuthWorkData.kt new file mode 100644 index 000000000000..65ad454bd628 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncAuthWorkData.kt @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.worker + +import androidx.work.Data +import androidx.work.workDataOf +import anki.sync.syncAuth +import com.ichi2.anki.settings.Prefs +import com.ichi2.anki.sync.SyncAuth + +private const val SYNC_AUTH_KEY = "syncAuth" + +/** Preserve all sync settings, including the network timeout, when passing auth to either worker. */ +internal fun SyncAuth.toWorkData(): Data = workDataOf(SYNC_AUTH_KEY to toProto().toByteArray()) + +internal fun Data.toSyncAuth(): SyncAuth? { + getByteArray(SYNC_AUTH_KEY)?.let { return SyncAuth(anki.sync.SyncAuth.parseFrom(it)) } + + // Temporary workaround for an app update from <2.26.0 + val hkey = getString("hkey") ?: return null + return SyncAuth( + syncAuth { + this.hkey = hkey + getString("endpoint")?.let { endpoint = it } + ioTimeoutSecs = Prefs.networkTimeoutSecs + }, + ) +} diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncMediaWorker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncMediaWorker.kt index 54c2e7d95df7..80813cf2d99d 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncMediaWorker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncMediaWorker.kt @@ -12,7 +12,6 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.work.Constraints import androidx.work.CoroutineWorker -import androidx.work.Data import androidx.work.ExistingWorkPolicy import androidx.work.ForegroundInfo import androidx.work.NetworkType @@ -22,7 +21,6 @@ import androidx.work.OutOfQuotaPolicy import androidx.work.WorkManager import androidx.work.WorkerParameters import anki.sync.MediaSyncProgress -import anki.sync.syncAuth import com.ichi2.anki.CollectionManager import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CommonString @@ -58,13 +56,7 @@ class SyncMediaWorker( Timber.v("SyncMediaWorker::doWork") try { - val auth = - syncAuth { - hkey = inputData.getString(HKEY_KEY)!! - inputData.getString(ENDPOINT_KEY)?.let { - endpoint = it - } - }.let(::SyncAuth) + val auth = requireNotNull(inputData.toSyncAuth()) // The collection must be open, but we should not block collection operations while // `syncMedia` is executing, the app should be usable during a background media sync @@ -193,8 +185,6 @@ class SyncMediaWorker( } companion object { - private const val HKEY_KEY = "hkey" - private const val ENDPOINT_KEY = "endpoint" const val NOTIFICATION_UPDATE_RATE_MS = 500L fun getWorkRequest(auth: SyncAuth): OneTimeWorkRequest { @@ -204,15 +194,8 @@ class SyncMediaWorker( .setRequiredNetworkType(NetworkType.CONNECTED) .build() - val data = - Data - .Builder() - .putString(HKEY_KEY, auth.hkey) - .putString(ENDPOINT_KEY, auth.endpoint) - .build() - return OneTimeWorkRequestBuilder() - .setInputData(data) + .setInputData(auth.toWorkData()) .setConstraints(constraints) .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .build() 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 6cae7ab73c93..bd0888b7d718 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncWorker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncWorker.kt @@ -20,7 +20,6 @@ import androidx.work.WorkManager import androidx.work.WorkerParameters import anki.collection.Progress import anki.sync.SyncCollectionResponse -import anki.sync.syncAuth import com.ichi2.anki.CollectionManager import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CollectionManager.withCol @@ -72,16 +71,7 @@ class SyncWorker( Timber.v("SyncWorker::doWork") trySetForeground(getForegroundInfo()) - val hkey = - inputData.getString(HKEY_KEY) - ?: return Result.failure() - val auth = - syncAuth { - this.hkey = hkey - inputData.getString(ENDPOINT_KEY)?.let { - endpoint = it - } - }.let(::SyncAuth) + val auth = inputData.toSyncAuth() ?: return Result.failure() val shouldSyncMedia = inputData.getBoolean(SYNC_MEDIA_KEY, false) try { @@ -229,8 +219,6 @@ class SyncWorker( } companion object { - private const val HKEY_KEY = "hkey" - private const val ENDPOINT_KEY = "endpoint" private const val SYNC_MEDIA_KEY = "syncMedia" fun start( @@ -247,8 +235,7 @@ class SyncWorker( val data = Data .Builder() - .putString(HKEY_KEY, syncAuth.hkey) - .putString(ENDPOINT_KEY, syncAuth.endpoint) + .putAll(syncAuth.toWorkData()) .putBoolean(SYNC_MEDIA_KEY, syncMedia) .build() diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/worker/BackgroundSyncAuthTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/worker/BackgroundSyncAuthTest.kt new file mode 100644 index 000000000000..0609860926f4 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/worker/BackgroundSyncAuthTest.kt @@ -0,0 +1,140 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.worker + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.work.Data +import androidx.work.ListenableWorker.Result +import androidx.work.OneTimeWorkRequest +import androidx.work.WorkManager +import androidx.work.testing.TestListenableWorkerBuilder +import androidx.work.workDataOf +import anki.sync.SyncCollectionResponse +import anki.sync.syncAuth +import anki.sync.syncCollectionResponse +import com.ichi2.anki.CollectionManager +import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.settings.Prefs +import com.ichi2.anki.sync.SyncAuth +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.slot +import io.mockk.unmockkObject +import kotlinx.coroutines.CompletableDeferred +import net.ankiweb.rsdroid.Backend +import net.ankiweb.rsdroid.BackendFactory +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.equalTo +import org.junit.After +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class BackgroundSyncAuthTest : RobolectricTest() { + @After + fun resetBackendFactory() { + BackendFactory.setOverride(null) + } + + @Test + fun `collection worker preserves all authentication settings`() = + runTest { + val auth = + syncAuth { + hkey = "test" + ioTimeoutSecs = 123 + } + val input = collectionSyncInput(SyncAuth(auth)) + + assertThat(syncAuthReceivedByBackend(input, media = false), equalTo(auth)) + } + + @Test + fun `media worker preserves all authentication settings`() = + runTest { + val auth = + syncAuth { + hkey = "test" + endpoint = "https://sync.example.com/" + ioTimeoutSecs = 123 + } + val input = SyncMediaWorker.getWorkRequest(SyncAuth(auth)).workSpec.input + + assertThat(syncAuthReceivedByBackend(input, media = true), equalTo(auth)) + } + + @Test + fun `collection worker accepts work queued before auth serialization changed`() = + runTest { + Prefs.networkTimeoutSecs = 123 + val input = workDataOf("hkey" to "test") + val expected = + syncAuth { + hkey = "test" + ioTimeoutSecs = 123 + } + + assertThat(syncAuthReceivedByBackend(input, media = false), equalTo(expected)) + } + + @Test + fun `media worker accepts work queued before auth serialization changed`() = + runTest { + Prefs.networkTimeoutSecs = 123 + val input = workDataOf("hkey" to "test", "endpoint" to "https://sync.example.com/") + val expected = + syncAuth { + hkey = "test" + endpoint = "https://sync.example.com/" + ioTimeoutSecs = 123 + } + + assertThat(syncAuthReceivedByBackend(input, media = true), equalTo(expected)) + } + + private fun collectionSyncInput(auth: SyncAuth): Data { + val manager = mockk(relaxed = true) + val request = slot() + mockkObject(WorkManager.Companion) + try { + every { WorkManager.getInstance(any()) } returns manager + every { manager.enqueueUniqueWork(any(), any(), capture(request)) } returns mockk(relaxed = true) + SyncWorker.start(targetContext, auth, syncMedia = false) + return request.captured.workSpec.input + } finally { + unmockkObject(WorkManager.Companion) + } + } + + private suspend fun syncAuthReceivedByBackend( + input: Data, + media: Boolean, + ): anki.sync.SyncAuth { + val receivedAuth = CompletableDeferred() + CollectionManager.discardBackend() + BackendFactory.setOverride { + object : Backend() { + override fun syncCollection( + auth: anki.sync.SyncAuth, + syncMedia: Boolean, + ): SyncCollectionResponse { + receivedAuth.complete(auth) + return syncCollectionResponse { required = SyncCollectionResponse.ChangesRequired.NO_CHANGES } + } + + override fun syncMedia(input: anki.sync.SyncAuth) { + receivedAuth.complete(input) + } + } + } + val worker = + if (media) { + TestListenableWorkerBuilder(targetContext, input).build() + } else { + TestListenableWorkerBuilder(targetContext, input).build() + } + assertThat(worker.doWork(), equalTo(Result.success())) + return receivedAuth.await() + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/worker/SyncMediaWorkerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/worker/SyncMediaWorkerTest.kt index 3b4a55767c83..c132bf72db80 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/worker/SyncMediaWorkerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/worker/SyncMediaWorkerTest.kt @@ -47,9 +47,10 @@ class SyncMediaWorkerTest { val auth = SyncAuth(syncAuth { hkey = "test" }) val input = SyncMediaWorker.getWorkRequest(auth).workSpec.input + val restoredAuth = input.toSyncAuth()!! - assertThat(input.getString("endpoint"), nullValue()) - assertThat(input.getString("hkey"), equalTo("test")) + assertThat(restoredAuth.endpoint, nullValue()) + assertThat(restoredAuth.hkey, equalTo("test")) } // https://github.com/ankidroid/Anki-Android/issues/20826 From 61747dea5817a5d9f140a4e9bc4079034df8157e Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:52:59 +0100 Subject: [PATCH 07/50] test(dialogs): screenshots for keyboard-resizing dialogs * Tags * Locale Selection Prep for 7110 Assisted-by: GPT-6 --- .../com/ichi2/anki/DialogScreenshotTest.kt | 38 +++++++++++++++ .../java/com/ichi2/anki/ScreenshotTest.kt | 6 +++ .../LocaleSelectionDialogScreenshotTest.kt | 30 ++++++++++++ .../dialogs/tags/TagsDialogScreenshotTest.kt | 46 +++++++++++++++++++ .../AddEditReminderDialogScreenshotTest.kt | 19 ++++++++ 5 files changed, 139 insertions(+) create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/DialogScreenshotTest.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/dialogs/LocaleSelectionDialogScreenshotTest.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/dialogs/tags/TagsDialogScreenshotTest.kt diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/DialogScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/DialogScreenshotTest.kt new file mode 100644 index 000000000000..7c70f86a472d --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/DialogScreenshotTest.kt @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki + +import android.view.MenuItem +import androidx.appcompat.widget.SearchView +import androidx.fragment.app.DialogFragment +import androidx.recyclerview.widget.RecyclerView +import org.robolectric.Robolectric.buildActivity + +/** Screenshot fixtures for dialogs that can be hosted in an otherwise empty activity. */ +abstract class DialogScreenshotTest : ScreenshotTest() { + protected inline fun withDialog( + fragment: T, + block: (T) -> Unit, + ) { + buildActivity(AnkiActivity::class.java).use { controller -> + val activity = controller.setup().get() + fragment.showNow(activity.supportFragmentManager, "screenshot_dialog") + advanceRobolectricLooper() + block(fragment) + } + } + + /** Expands search, waits for a query which narrows the list, and captures the results. */ + protected fun captureSearchResults( + name: String, + list: RecyclerView, + searchItem: MenuItem, + query: String, + ) { + val initialCount = list.adapter!!.itemCount + searchItem.expandActionView() + (searchItem.actionView as SearchView).setQuery(query, false) + advanceRobolectricLooperUntil { list.adapter!!.itemCount in 1 until initialCount } + captureScreen("${name}_search") + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt index 4bf33d50b7db..f0dd6c3072fb 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ScreenshotTest.kt @@ -128,6 +128,12 @@ abstract class ScreenshotTest : RobolectricTest() { } } + /** Applies the orientation and returns its component of the screenshot filename. */ + protected fun setOrientation(landscape: Boolean): String { + if (landscape) RuntimeEnvironment.setQualifiers("+land") + return if (landscape) "landscape" else "portrait" + } + /** Pixel-class phone in portrait */ protected fun setPhoneQualifiers() = RuntimeEnvironment.setQualifiers(RobolectricDeviceQualifiers.MediumPhone) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/LocaleSelectionDialogScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/LocaleSelectionDialogScreenshotTest.kt new file mode 100644 index 000000000000..683accaa88ad --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/LocaleSelectionDialogScreenshotTest.kt @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.dialogs + +import androidx.appcompat.widget.Toolbar +import com.google.testing.junit.testparameterinjector.TestParameter +import com.ichi2.anki.DialogScreenshotTest +import com.ichi2.anki.R +import org.junit.Test + +class LocaleSelectionDialogScreenshotTest : DialogScreenshotTest() { + @Test + fun `locale selection and search`( + @TestParameter landscape: Boolean, + ) { + val name = setOrientation(landscape) + withDialog(LocaleSelectionDialog()) { fragment -> + captureScreen(name) + + val dialog = fragment.requireDialog() + val toolbar = dialog.findViewById(R.id.locale_dialog_selection_toolbar) + captureSearchResults( + name, + list = dialog.findViewById(R.id.locale_dialog_selection_list), + searchItem = toolbar.menu.findItem(R.id.locale_dialog_action_search), + query = "English", + ) + } + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/tags/TagsDialogScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/tags/TagsDialogScreenshotTest.kt new file mode 100644 index 000000000000..713db41fb08d --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/tags/TagsDialogScreenshotTest.kt @@ -0,0 +1,46 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.dialogs.tags + +import com.google.testing.junit.testparameterinjector.TestParameter +import com.ichi2.anki.DialogScreenshotTest +import com.ichi2.anki.R +import com.ichi2.anki.dialogs.tags.TagsDialog.DialogType +import org.junit.Test + +class TagsDialogScreenshotTest : DialogScreenshotTest() { + @Test + fun `tag selection and search`( + @TestParameter type: DialogType, + @TestParameter landscape: Boolean, + ) = runTest { + val orientation = setOrientation(landscape) + val name = "${orientation}_${type.name.lowercase()}" + val note = addBasicNote() + col.tags.bulkAdd( + listOf(note.id), + "biology::animals biology::plants chemistry geography history languages::English mathematics physics revision::priority", + ) + val dialog = + TagsDialog().withArguments( + targetContext, + type, + noteIds = if (type == DialogType.CUSTOM_STUDY) listOf(note.id) else emptyList(), + checkedTags = arrayListOf("biology::plants", "chemistry"), + ) + withDialog(dialog) { + dialog.viewModel.tags.await() + advanceRobolectricLooperUntil { dialog.binding.tagsList.adapter != null } + captureScreen(name) + + captureSearchResults( + name, + list = dialog.binding.tagsList, + searchItem = + dialog.binding.toolbar.root.menu + .findItem(R.id.tags_dialog_action_filter), + query = "biology", + ) + } + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/reviewreminders/AddEditReminderDialogScreenshotTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/reviewreminders/AddEditReminderDialogScreenshotTest.kt index 571a980b4c29..9714ec00cd5a 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/reviewreminders/AddEditReminderDialogScreenshotTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/reviewreminders/AddEditReminderDialogScreenshotTest.kt @@ -43,6 +43,25 @@ class AddEditReminderDialogScreenshotTest : ScreenshotTest() { } } + @Test + fun `card threshold entry`( + @TestParameter landscape: Boolean, + ) { + val orientation = setOrientation(landscape) + withReminderDialog(DialogMode.Add(ReviewReminderScope.Global)) { + binding.addEditReminderAdvancedDropdown.performClick() + binding.addEditReminderCardThresholdInput.apply { + requestFocus() + setText("25") + setSelection(text!!.length) + } + advanceRobolectricLooper() + binding.addEditReminderScrollview.fullScroll(View.FOCUS_DOWN) + advanceRobolectricLooper() + captureScreen("${orientation}_card_threshold_entry") + } + } + @Test fun `time picker`( @TestParameter use24HourClock: Boolean, From 7e571ef275f287ccf66b0cd1c9a21a57a0c0f099 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:47:23 +0100 Subject: [PATCH 08/50] fix(tags): stop overriding activity keyboard settings The dialog should not be responsible for the parent. Code was unnecessary. Part of 7110 Code added in 409455eee42a4e437ee452fa2757bcbdcdb638eb (2014); ADJUST_RESIZE was set so controls were usable when the keyboard was open, but this also applied it to the calling window. Assisted-by: GPT-6 --- .../src/main/java/com/ichi2/anki/dialogs/tags/TagsDialog.kt | 1 - 1 file changed, 1 deletion(-) 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 0a63dc85b07e..766bfce80237 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 @@ -161,7 +161,6 @@ class TagsDialog : AnalyticsDialogFragment { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - resizeWhenSoftInputShown(requireActivity().window) type = requireArguments().requireParcelable(ARG_DIALOG_TYPE) isCancelable = true From b076787ea7963c6299315c7fc6aea9399f2ebf5e Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:52:26 +0100 Subject: [PATCH 09/50] refactor: make 'resizeDialogWhenSoftInputShown' dialog-only and document it Part of 7110 Assisted-by: GPT-6 --- .../ichi2/anki/dialogs/LocaleSelectionDialog.kt | 4 ++-- .../com/ichi2/anki/dialogs/tags/TagsDialog.kt | 13 ++++++------- .../reviewreminders/AddEditReminderDialog.kt | 4 ++-- .../main/java/com/ichi2/utils/DisplayUtils.kt | 17 +++++++++++------ 4 files changed, 21 insertions(+), 17 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/LocaleSelectionDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/LocaleSelectionDialog.kt index f865edd7e019..a6519952c5cb 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/LocaleSelectionDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/LocaleSelectionDialog.kt @@ -21,7 +21,7 @@ import com.ichi2.anki.databinding.DialogLocaleSelectionBinding import com.ichi2.anki.dialogs.LocaleSelectionDialog.LocaleListAdapter.TextViewHolder import com.ichi2.anki.servicelayer.LanguageHintService import com.ichi2.ui.AccessibleSearchView -import com.ichi2.utils.DisplayUtils.resizeWhenSoftInputShown +import com.ichi2.utils.DisplayUtils.resizeDialogWhenSoftInputShown import com.ichi2.utils.TypedFilter import com.ichi2.utils.cancelable import com.ichi2.utils.customView @@ -55,7 +55,7 @@ class LocaleSelectionDialog : AnalyticsDialogFragment() { style: Int, ) { super.setupDialog(dialog, style) - dialog.window?.let { resizeWhenSoftInputShown(it) } + resizeDialogWhenSoftInputShown(dialog) // this is required for the keyboard to appear: https://stackoverflow.com/a/10133603/ dialog.window?.clearFlags(FLAG_NOT_FOCUSABLE or FLAG_ALT_FOCUSABLE_IM) } 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 766bfce80237..b9abb7003b7a 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 @@ -13,7 +13,6 @@ 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 @@ -48,7 +47,7 @@ import com.ichi2.anki.model.CardStateFilter import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.utils.ext.requireParcelable import com.ichi2.ui.AccessibleSearchView -import com.ichi2.utils.DisplayUtils.resizeWhenSoftInputShown +import com.ichi2.utils.DisplayUtils.resizeDialogWhenSoftInputShown import com.ichi2.utils.TagsUtil import com.ichi2.utils.customView import com.ichi2.utils.getInputField @@ -265,7 +264,7 @@ class TagsDialog : AnalyticsDialogFragment { positiveButton?.isEnabled = true } - dialog.window?.updateSoftInputMode(resources.configuration) + dialog.updateSoftInputMode(resources.configuration) return dialog } @@ -306,14 +305,14 @@ class TagsDialog : AnalyticsDialogFragment { override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) - dialog?.window?.updateSoftInputMode(newConfig) + dialog?.updateSoftInputMode(newConfig) } - private fun Window.updateSoftInputMode(configuration: Configuration) { + private fun Dialog.updateSoftInputMode(configuration: Configuration) { if (configuration.screenHeightDp < COMPACT_HEIGHT_DP) { - setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN) + window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN) } else { - resizeWhenSoftInputShown(this) + resizeDialogWhenSoftInputShown(this) } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt index 265d8c6aa04c..4f953a2a77a6 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt @@ -39,7 +39,7 @@ import com.ichi2.anki.utils.ext.getParcelableCompat import com.ichi2.anki.utils.ext.requireParcelable import com.ichi2.anki.utils.ext.showDialogFragment import com.ichi2.anki.utils.showDialogFragment -import com.ichi2.utils.DisplayUtils.resizeWhenSoftInputShown +import com.ichi2.utils.DisplayUtils.resizeDialogWhenSoftInputShown import com.ichi2.utils.Permissions import com.ichi2.utils.customView import com.ichi2.utils.negativeButton @@ -125,7 +125,7 @@ class AddEditReminderDialog : DialogFragment() { setUpCardThresholdInput() setUpOnlyNotifyIfNoReviewsCheckbox() - dialog.window?.let { resizeWhenSoftInputShown(it) } + resizeDialogWhenSoftInputShown(dialog) return dialog } diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/DisplayUtils.kt b/AnkiDroid/src/main/java/com/ichi2/utils/DisplayUtils.kt index e69cc4aa4dc1..c1642286da0c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/utils/DisplayUtils.kt +++ b/AnkiDroid/src/main/java/com/ichi2/utils/DisplayUtils.kt @@ -2,11 +2,11 @@ package com.ichi2.utils +import android.app.Dialog import android.content.Context import android.graphics.Insets import android.graphics.Point import android.os.Build -import android.view.Window import android.view.WindowInsets import android.view.WindowManager @@ -37,10 +37,15 @@ object DisplayUtils { return getDisplayDimensions(wm) } - /** Allow the window to be resized when an input method is shown, - * so that its contents are not covered by the input method */ - @Suppress("DEPRECATION") // 7110: SOFT_INPUT_ADJUST_RESIZE - fun resizeWhenSoftInputShown(window: Window) { - window.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + /** + * Keep a floating dialog above the keyboard by letting the window manager resize it. + * + * The API 30 replacement (edge-to-edge with IME insets on the content view) is for + * non-floating windows. Floating dialogs still rely on decor fitting to constrain their + * height. Activities should handle IME insets in their own layout instead (Issue 7110). + */ + @Suppress("DEPRECATION") // Floating dialogs still need SOFT_INPUT_ADJUST_RESIZE. + fun resizeDialogWhenSoftInputShown(dialog: Dialog) { + dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) } } From 38c8b090f0729004786af5d6d9ea2f03b07bc61b Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:17:03 +0100 Subject: [PATCH 10/50] refactor: extract `Int.setBitFlag` Prep for 7110 Assisted-by: GPT-6 --- .../com/ichi2/anki/browser/BrowserMultiColumnAdapter.kt | 8 ++------ .../anki/reviewreminders/ScheduleRemindersAdapter.kt | 8 ++------ .../src/main/java/com/ichi2/anki/common/utils/ext/Int.kt | 6 ++++++ 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserMultiColumnAdapter.kt b/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserMultiColumnAdapter.kt index cb8a409c4220..08b1f03a2e84 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserMultiColumnAdapter.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/browser/BrowserMultiColumnAdapter.kt @@ -29,6 +29,7 @@ import com.ichi2.anki.common.annotations.NeedsTest import com.ichi2.anki.common.utils.android.darkenColor import com.ichi2.anki.common.utils.android.lightenColorAbsolute import com.ichi2.anki.common.utils.ext.replaceWith +import com.ichi2.anki.common.utils.ext.setBitFlag import com.ichi2.anki.databinding.ItemCardBrowserBinding import com.ichi2.anki.databinding.ViewBrowserColumnCellBinding import com.ichi2.themes.Themes @@ -198,12 +199,7 @@ class BrowserMultiColumnAdapter( } private fun TextView.setStrikeThrough(strikeThrough: Boolean) { - paintFlags = - if (strikeThrough) { - paintFlags or Paint.STRIKE_THRU_TEXT_FLAG - } else { - paintFlags and Paint.STRIKE_THRU_TEXT_FLAG.inv() - } + paintFlags = paintFlags.setBitFlag(Paint.STRIKE_THRU_TEXT_FLAG, enabled = strikeThrough) } private fun TextView.setupTextSize() { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ScheduleRemindersAdapter.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ScheduleRemindersAdapter.kt index 8673fd41fb62..a3e71cbe688a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ScheduleRemindersAdapter.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/ScheduleRemindersAdapter.kt @@ -17,6 +17,7 @@ import com.google.android.material.materialswitch.MaterialSwitch import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CommonString import com.ichi2.anki.R +import com.ichi2.anki.common.utils.ext.setBitFlag import com.ichi2.anki.databinding.ItemScheduleRemindersBinding import com.ichi2.anki.libanki.DeckId import com.ichi2.anki.ui.internationalization.sentenceCase @@ -131,12 +132,7 @@ class ScheduleRemindersAdapter( textView: TextView, setStrikethrough: Boolean, ) { - textView.paintFlags = - if (setStrikethrough) { - textView.paintFlags or Paint.STRIKE_THRU_TEXT_FLAG - } else { - textView.paintFlags and Paint.STRIKE_THRU_TEXT_FLAG.inv() - } + textView.paintFlags = textView.paintFlags.setBitFlag(Paint.STRIKE_THRU_TEXT_FLAG, enabled = setStrikethrough) } /** diff --git a/common/src/main/java/com/ichi2/anki/common/utils/ext/Int.kt b/common/src/main/java/com/ichi2/anki/common/utils/ext/Int.kt index c0f4bb22c366..4132305e488d 100644 --- a/common/src/main/java/com/ichi2/anki/common/utils/ext/Int.kt +++ b/common/src/main/java/com/ichi2/anki/common/utils/ext/Int.kt @@ -6,3 +6,9 @@ inline fun Int.ifNotZero(block: (value: Int) -> Unit) { if (this == 0) return block(this) } + +/** Sets or clears the bits in [flag], preserving all other bits. */ +fun Int.setBitFlag( + flag: Int, + enabled: Boolean, +): Int = if (enabled) this or flag else this and flag.inv() From 76745d9db22e892700bd6c452f482561552c054e Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:05:48 +0100 Subject: [PATCH 11/50] fix(dialogs): fit dialogs to IME insets Replaces `SOFT_INPUT_ADJUST_RESIZE` (Android 11+ and later) On resize, we now fit the IME insets. On pan (compact screens), we remove the insets and apply `SOFT_INPUT_ADJUST_PAN` Part of 7110 Assisted-by: GPT-6 --- .../anki/dialogs/LocaleSelectionDialog.kt | 4 +- .../com/ichi2/anki/dialogs/tags/TagsDialog.kt | 8 +- .../reviewreminders/AddEditReminderDialog.kt | 4 +- .../main/java/com/ichi2/utils/DisplayUtils.kt | 16 ++-- .../ichi2/anki/dialogs/tags/TagsDialogTest.kt | 22 +++-- .../java/com/ichi2/utils/DisplayUtilsTest.kt | 82 +++++++++++++++++++ .../java/com/ichi2/anki/compat/BaseCompat.kt | 15 ++++ .../main/java/com/ichi2/anki/compat/Compat.kt | 14 ++++ .../java/com/ichi2/anki/compat/CompatV30.kt | 16 ++++ 9 files changed, 159 insertions(+), 22 deletions(-) create mode 100644 AnkiDroid/src/test/java/com/ichi2/utils/DisplayUtilsTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/LocaleSelectionDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/LocaleSelectionDialog.kt index a6519952c5cb..533894f8727e 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/LocaleSelectionDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/LocaleSelectionDialog.kt @@ -21,7 +21,7 @@ import com.ichi2.anki.databinding.DialogLocaleSelectionBinding import com.ichi2.anki.dialogs.LocaleSelectionDialog.LocaleListAdapter.TextViewHolder import com.ichi2.anki.servicelayer.LanguageHintService import com.ichi2.ui.AccessibleSearchView -import com.ichi2.utils.DisplayUtils.resizeDialogWhenSoftInputShown +import com.ichi2.utils.DisplayUtils.setDialogKeyboardResize import com.ichi2.utils.TypedFilter import com.ichi2.utils.cancelable import com.ichi2.utils.customView @@ -55,7 +55,7 @@ class LocaleSelectionDialog : AnalyticsDialogFragment() { style: Int, ) { super.setupDialog(dialog, style) - resizeDialogWhenSoftInputShown(dialog) + setDialogKeyboardResize(dialog) // this is required for the keyboard to appear: https://stackoverflow.com/a/10133603/ dialog.window?.clearFlags(FLAG_NOT_FOCUSABLE or FLAG_ALT_FOCUSABLE_IM) } 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 b9abb7003b7a..5d230e702bb5 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 @@ -47,7 +47,7 @@ import com.ichi2.anki.model.CardStateFilter import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.utils.ext.requireParcelable import com.ichi2.ui.AccessibleSearchView -import com.ichi2.utils.DisplayUtils.resizeDialogWhenSoftInputShown +import com.ichi2.utils.DisplayUtils.setDialogKeyboardResize import com.ichi2.utils.TagsUtil import com.ichi2.utils.customView import com.ichi2.utils.getInputField @@ -309,11 +309,7 @@ class TagsDialog : AnalyticsDialogFragment { } private fun Dialog.updateSoftInputMode(configuration: Configuration) { - if (configuration.screenHeightDp < COMPACT_HEIGHT_DP) { - window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN) - } else { - resizeDialogWhenSoftInputShown(this) - } + setDialogKeyboardResize(this, resize = configuration.screenHeightDp >= COMPACT_HEIGHT_DP) } private fun radioButtonIdToCardState(id: Int) = diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt index 4f953a2a77a6..8f94360dc9d0 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/reviewreminders/AddEditReminderDialog.kt @@ -39,7 +39,7 @@ import com.ichi2.anki.utils.ext.getParcelableCompat import com.ichi2.anki.utils.ext.requireParcelable import com.ichi2.anki.utils.ext.showDialogFragment import com.ichi2.anki.utils.showDialogFragment -import com.ichi2.utils.DisplayUtils.resizeDialogWhenSoftInputShown +import com.ichi2.utils.DisplayUtils.setDialogKeyboardResize import com.ichi2.utils.Permissions import com.ichi2.utils.customView import com.ichi2.utils.negativeButton @@ -125,7 +125,7 @@ class AddEditReminderDialog : DialogFragment() { setUpCardThresholdInput() setUpOnlyNotifyIfNoReviewsCheckbox() - resizeDialogWhenSoftInputShown(dialog) + setDialogKeyboardResize(dialog) return dialog } diff --git a/AnkiDroid/src/main/java/com/ichi2/utils/DisplayUtils.kt b/AnkiDroid/src/main/java/com/ichi2/utils/DisplayUtils.kt index c1642286da0c..50ff13edf07c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/utils/DisplayUtils.kt +++ b/AnkiDroid/src/main/java/com/ichi2/utils/DisplayUtils.kt @@ -9,6 +9,7 @@ import android.graphics.Point import android.os.Build import android.view.WindowInsets import android.view.WindowManager +import com.ichi2.anki.compat.CompatHelper object DisplayUtils { @Suppress("DEPRECATION") // #9333: defaultDisplay & getSize @@ -38,14 +39,15 @@ object DisplayUtils { } /** - * Keep a floating dialog above the keyboard by letting the window manager resize it. + * Fit a floating dialog above the keyboard, or pan to its focused input when [resize] is false. * - * The API 30 replacement (edge-to-edge with IME insets on the content view) is for - * non-floating windows. Floating dialogs still rely on decor fitting to constrain their - * height. Activities should handle IME insets in their own layout instead (Issue 7110). + * Activities with edge-to-edge content should handle IME insets in their own layout. */ - @Suppress("DEPRECATION") // Floating dialogs still need SOFT_INPUT_ADJUST_RESIZE. - fun resizeDialogWhenSoftInputShown(dialog: Dialog) { - dialog.window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE) + fun setDialogKeyboardResize( + dialog: Dialog, + resize: Boolean = true, + ) { + val window = dialog.window ?: return + CompatHelper.compat.setDialogKeyboardResize(window, resize) } } 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 ce195028f923..7251922738cb 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,9 +15,11 @@ */ package com.ichi2.anki.dialogs.tags +import android.annotation.SuppressLint import android.content.res.Configuration import android.os.Bundle import android.view.View +import android.view.WindowInsets import android.view.WindowManager import android.widget.EditText import androidx.appcompat.app.AlertDialog @@ -718,19 +720,20 @@ class TagsDialogTest : RobolectricTest() { @Test @Config(qualifiers = "w411dp-h914dp") - @Suppress("DEPRECATION") - fun `keyboard resizes the dialog on a tall screen`() { + fun `keyboard fits the dialog using IME insets on a tall screen`() { runTagsDialogScenario(editTagsArguments()) { f: TagsDialog -> - assertThat(f.softInputAdjustment, equalTo(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE)) + assertThat(f.softInputAdjustment, equalTo(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)) + assertThat(f.fitsKeyboardInsets, equalTo(true)) } } @Test @Config(qualifiers = "w411dp-h914dp") - fun `rotating to a short screen pans the dialog 22082`() { + fun `rotation switches between fitting the IME and panning 22082`() { runTagsDialogScenario(editTagsArguments()) { f: TagsDialog -> + val portrait = Configuration(f.resources.configuration) val landscape = - Configuration(f.resources.configuration).apply { + Configuration(portrait).apply { screenWidthDp = 914 screenHeightDp = 411 orientation = Configuration.ORIENTATION_LANDSCAPE @@ -738,9 +741,18 @@ class TagsDialogTest : RobolectricTest() { f.onConfigurationChanged(landscape) assertThat(f.softInputAdjustment, equalTo(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN)) + assertThat(f.fitsKeyboardInsets, equalTo(false)) + + f.onConfigurationChanged(portrait) + assertThat(f.softInputAdjustment, equalTo(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING)) + assertThat(f.fitsKeyboardInsets, equalTo(true)) } } + @get:SuppressLint("NewApi") // These dialog tests run on the target SDK (API 30+). + private val TagsDialog.fitsKeyboardInsets: Boolean + get() = requireDialog().window!!.attributes.fitInsetsTypes and WindowInsets.Type.ime() != 0 + private val TagsDialog.softInputAdjustment: Int get() = requireDialog().window!!.attributes.softInputMode and WindowManager.LayoutParams.SOFT_INPUT_MASK_ADJUST diff --git a/AnkiDroid/src/test/java/com/ichi2/utils/DisplayUtilsTest.kt b/AnkiDroid/src/test/java/com/ichi2/utils/DisplayUtilsTest.kt new file mode 100644 index 000000000000..83d06205fe3a --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/utils/DisplayUtilsTest.kt @@ -0,0 +1,82 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.utils + +import android.annotation.SuppressLint +import android.app.Activity +import android.os.Build +import android.view.WindowInsets +import android.view.WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING +import android.view.WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN +import android.view.WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE +import android.view.WindowManager.LayoutParams.SOFT_INPUT_MASK_ADJUST +import android.view.WindowManager.LayoutParams.SOFT_INPUT_MASK_STATE +import android.view.WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN +import android.widget.EditText +import androidx.appcompat.app.AlertDialog +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.EmptyApplicationCategory +import com.ichi2.anki.R +import com.ichi2.testutils.EmptyApplication +import com.ichi2.utils.DisplayUtils.setDialogKeyboardResize +import org.junit.Test +import org.junit.experimental.categories.Category +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.annotation.Config +import kotlin.test.assertEquals + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [29, 30, 36], application = EmptyApplication::class) +@Category(EmptyApplicationCategory::class) +class DisplayUtilsTest { + @Test + fun `resizing preserves keyboard visibility settings before and after show`() = + withDialog { dialog -> + val window = dialog.window!! + window.setSoftInputMode(SOFT_INPUT_STATE_ALWAYS_HIDDEN or SOFT_INPUT_ADJUST_PAN) + repeat(2) { + setDialogKeyboardResize(dialog) + assertEquals(SOFT_INPUT_STATE_ALWAYS_HIDDEN, window.attributes.softInputMode and SOFT_INPUT_MASK_STATE) + @Suppress("DEPRECATION") + val expectedAdjustment = if (Build.VERSION.SDK_INT >= 30) SOFT_INPUT_ADJUST_NOTHING else SOFT_INPUT_ADJUST_RESIZE + assertEquals(expectedAdjustment, window.attributes.softInputMode and SOFT_INPUT_MASK_ADJUST) + if (Build.VERSION.SDK_INT >= 30) { + assertEquals(WindowInsets.Type.ime(), window.attributes.fitInsetsTypes and WindowInsets.Type.ime()) + } + dialog.show() + } + } + + @Test + @Config(sdk = [30, 36]) + @SuppressLint("NewApi") // @Config restricts this test to API 30+. + fun `switching to pan removes IME fitting and retains other inset types`() = + withDialog { dialog -> + val window = dialog.window!! + val initialTypes = WindowInsets.Type.systemBars() or WindowInsets.Type.displayCutout() + window.attributes = window.attributes.apply { fitInsetsTypes = initialTypes } + dialog.show() + repeat(2) { + setDialogKeyboardResize(dialog) + assertEquals(initialTypes or WindowInsets.Type.ime(), window.attributes.fitInsetsTypes) + setDialogKeyboardResize(dialog, resize = false) + assertEquals(initialTypes, window.attributes.fitInsetsTypes) + assertEquals(SOFT_INPUT_ADJUST_PAN, window.attributes.softInputMode and SOFT_INPUT_MASK_ADJUST) + } + } + + private fun withDialog(block: (AlertDialog) -> Unit) { + Robolectric.buildActivity(Activity::class.java).use { controller -> + val activity = controller.get() + activity.setTheme(R.style.Theme_Light) + controller.setup() + val dialog = AlertDialog.Builder(activity).setView(EditText(activity)).create() + try { + block(dialog) + } finally { + dialog.dismiss() + } + } + } +} diff --git a/compat/src/main/java/com/ichi2/anki/compat/BaseCompat.kt b/compat/src/main/java/com/ichi2/anki/compat/BaseCompat.kt index 2802679fe083..4e5ca2e1de3f 100644 --- a/compat/src/main/java/com/ichi2/anki/compat/BaseCompat.kt +++ b/compat/src/main/java/com/ichi2/anki/compat/BaseCompat.kt @@ -23,6 +23,9 @@ import android.provider.MediaStore import android.view.View import android.view.Window import android.view.WindowManager +import android.view.WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN +import android.view.WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE +import android.view.WindowManager.LayoutParams.SOFT_INPUT_MASK_ADJUST import android.view.inputmethod.InputMethodManager import androidx.annotation.AnimRes import androidx.appcompat.widget.TooltipCompat @@ -55,6 +58,18 @@ open class BaseCompat : Compat { ) } + override fun setDialogKeyboardResize( + window: Window, + resize: Boolean, + ) { + window.setSoftInputAdjustment(if (resize) SOFT_INPUT_ADJUST_RESIZE else SOFT_INPUT_ADJUST_PAN) + } + + /** Changes the adjustment mode while preserving keyboard visibility settings. */ + protected fun Window.setSoftInputAdjustment(adjustment: Int) { + setSoftInputMode((attributes.softInputMode and SOFT_INPUT_MASK_ADJUST.inv()) or adjustment) + } + // Until API36, SHOW_IMPLICIT marks the request as one the system may ignore override fun showSoftInput(view: View): Boolean { val imm = view.context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager diff --git a/compat/src/main/java/com/ichi2/anki/compat/Compat.kt b/compat/src/main/java/com/ichi2/anki/compat/Compat.kt index 21f32fe5e622..522168e801f3 100644 --- a/compat/src/main/java/com/ichi2/anki/compat/Compat.kt +++ b/compat/src/main/java/com/ichi2/anki/compat/Compat.kt @@ -80,6 +80,20 @@ interface Compat { fun hideStatusBar(window: Window) + /** + * Fit a floating dialog above the keyboard, or pan to its focused input when [resize] is false. + * + * On API 30+, fit the whole window to IME insets so its background and buttons stay above the + * keyboard. On older APIs, use the legacy soft input adjustment mode. + * + * [window] must belong to a floating dialog. Activities with edge-to-edge content should handle + * IME insets in their own layout. + */ + fun setDialogKeyboardResize( + window: Window, + resize: Boolean, + ) + /** * Request that the current input method's soft input area be shown to the * user, if needed. diff --git a/compat/src/main/java/com/ichi2/anki/compat/CompatV30.kt b/compat/src/main/java/com/ichi2/anki/compat/CompatV30.kt index c0b949f9012f..4869ffd52fdb 100644 --- a/compat/src/main/java/com/ichi2/anki/compat/CompatV30.kt +++ b/compat/src/main/java/com/ichi2/anki/compat/CompatV30.kt @@ -4,13 +4,29 @@ package com.ichi2.anki.compat import android.view.Window +import android.view.WindowInsets.Type.ime +import android.view.WindowManager.LayoutParams.SOFT_INPUT_ADJUST_NOTHING +import android.view.WindowManager.LayoutParams.SOFT_INPUT_ADJUST_PAN import androidx.annotation.RequiresApi import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat.Type.statusBars +import com.ichi2.anki.common.utils.ext.setBitFlag @RequiresApi(30) @Suppress("ktlint:standard:property-naming") open class CompatV30 : CompatV29() { + override fun setDialogKeyboardResize( + window: Window, + resize: Boolean, + ) { + window.attributes = + window.attributes.apply { + fitInsetsTypes = fitInsetsTypes.setBitFlag(ime(), enabled = resize) + } + // ADJUST_NOTHING => disable legacy adjustment and let the insets handle the work + window.setSoftInputAdjustment(if (resize) SOFT_INPUT_ADJUST_NOTHING else SOFT_INPUT_ADJUST_PAN) + } + // As of API30, insetsController is the correct way to hide the status bar @Suppress("SENSELESS_COMPARISON", "FoldInitializerAndIfToElvis") override fun hideStatusBar(window: Window) { From e3cb98d3dfa14e7aee7876270262b5ab3cc4fa8c Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:05:08 +0100 Subject: [PATCH 12/50] fix(api): load card search results lazily List the CardIds and return the current row of the cursor, rather than pre-fetching all rows. Reduces memory usage Part of 20253 Assisted-by: GPT-6 --- .../anki/provider/CardContentProvider.kt | 23 +--- .../com/ichi2/anki/provider/LazyCursor.kt | 85 ++++++++++++++ .../com/ichi2/anki/provider/LazyCursorTest.kt | 107 ++++++++++++++++++ 3 files changed, 198 insertions(+), 17 deletions(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/provider/LazyCursor.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/provider/LazyCursorTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt b/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt index d6113e638c43..8212e2d2c5ca 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt @@ -84,9 +84,6 @@ import java.io.IOException * */ -// TODO: Consider streaming Cursor results instead of materializing all rows -// to avoid potential OOM for large queries. -// Tracked in: https://github.com/ankidroid/Anki-Android/issues/20253 class CardContentProvider : ContentProvider() { private val applicationStartup = ContentProviderStartup() @@ -446,21 +443,13 @@ class CardContentProvider : ContentProvider() { // Fast path: Only if _id is requested val onlyRequestingId = columns.singleOrNull() == FlashCardsContract.Card._ID - if (onlyRequestingId) { - // Return IDs without fetching card objects - val rv = MatrixCursor(columns, cardIds.size) - for (cardId in cardIds) { - rv.newRow().add(cardId) - } - rv - } else { - // Get all requested fields - val rv = MatrixCursor(columns, cardIds.size) - for (cardId in cardIds) { - val card = col.getCard(cardId) - addCardToCursor(card, rv, col, columns) + LazyCursor(columns, cardIds.size) { position, row -> + val cardId = cardIds[position] + if (onlyRequestingId) { + row.newRow().add(cardId) + } else { + addCardToCursor(col.getCard(cardId), row, col, columns) } - rv } } CARD_ID -> { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/provider/LazyCursor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/provider/LazyCursor.kt new file mode 100644 index 000000000000..5db06b46b147 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/provider/LazyCursor.kt @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.provider + +import android.database.AbstractCursor +import android.database.MatrixCursor + +/** + * A cursor that loads rows on demand and retains at most one loaded row. + * + * Creating the cursor or reading its count does not load any rows. Moving to a different valid + * position calls [loadRow], which must add exactly one row to the supplied [MatrixCursor]. + * The previous row is discarded, so returning to it calls [loadRow] again and may yield updated + * values. Android can copy rows into a cursor window without this cursor retaining all the results. + * + * For example, defer fetching card details until the cursor moves to a result: + * ```kotlin + * val cursor = LazyCursor(arrayOf("_id", "reps"), cardIds.size) { position, row -> + * val card = col.getCardOrNull(cardIds[position]) + * row.newRow().add(card.id).add(card.reps) + * } + * ``` + */ +internal class LazyCursor( + private val columns: Array, + private val rowCount: Int, + private var loadRow: ((Int, MatrixCursor) -> Unit)?, +) : AbstractCursor() { + private var row: MatrixCursor? = null + + override fun getCount(): Int = rowCount + + override fun getColumnNames(): Array = columns + + override fun onMove( + oldPosition: Int, + newPosition: Int, + ): Boolean { + check(!isClosed) + row?.close() + row = null + val next = MatrixCursor(columns, 1) + try { + loadRow!!(newPosition, next) + check(next.count == 1) + next.moveToFirst() + row = next + } catch (e: Exception) { + next.close() + throw e + } + return true + } + + private fun currentRow(): MatrixCursor { + check(!isClosed) + checkPosition() + return checkNotNull(row) + } + + override fun getString(column: Int): String? = currentRow().getString(column) + + override fun getShort(column: Int): Short = currentRow().getShort(column) + + override fun getInt(column: Int): Int = currentRow().getInt(column) + + override fun getLong(column: Int): Long = currentRow().getLong(column) + + override fun getFloat(column: Int): Float = currentRow().getFloat(column) + + override fun getDouble(column: Int): Double = currentRow().getDouble(column) + + override fun getBlob(column: Int): ByteArray? = currentRow().getBlob(column) + + override fun getType(column: Int): Int = currentRow().getType(column) + + override fun isNull(column: Int): Boolean = currentRow().isNull(column) + + override fun close() { + row?.close() + row = null + loadRow = null + super.close() + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/provider/LazyCursorTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/provider/LazyCursorTest.kt new file mode 100644 index 000000000000..d67bfef58278 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/provider/LazyCursorTest.kt @@ -0,0 +1,107 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.provider + +import android.database.Cursor +import android.database.CursorIndexOutOfBoundsException +import android.database.CursorWindow +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.EmptyApplicationCategory +import com.ichi2.testutils.EmptyApplication +import org.hamcrest.MatcherAssert.assertThat +import org.hamcrest.Matchers.contains +import org.hamcrest.Matchers.empty +import org.hamcrest.Matchers.equalTo +import org.junit.Assert.assertThrows +import org.junit.Test +import org.junit.experimental.categories.Category +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(application = EmptyApplication::class) +@Category(EmptyApplicationCategory::class) +class LazyCursorTest { + @Test + fun `large results only load visited rows`() { + val loaded = mutableListOf() + LazyCursor(arrayOf("_id"), 1_000_000) { position, row -> + loaded.add(position) + row.addRow(arrayOf(position.toLong())) + }.use { cursor -> + assertThat(cursor.count, equalTo(1_000_000)) + assertThat(loaded, empty()) + assertThrows(CursorIndexOutOfBoundsException::class.java) { cursor.getLong(0) } + assertThat(cursor.moveToLast(), equalTo(true)) + assertThat(cursor.getLong(0), equalTo(999_999L)) + assertThat(loaded, contains(999_999)) + assertThat(cursor.moveToFirst(), equalTo(true)) + assertThat(cursor.getLong(0), equalTo(0L)) + assertThat(cursor.moveToFirst(), equalTo(true)) + assertThat(loaded, contains(999_999, 0)) + assertThat(cursor.moveToPrevious(), equalTo(false)) + assertThrows(CursorIndexOutOfBoundsException::class.java) { cursor.getLong(0) } + assertThat(cursor.moveToLast(), equalTo(true)) + assertThat(loaded, contains(999_999, 0, 999_999)) + assertThat(cursor.moveToNext(), equalTo(false)) + assertThrows(CursorIndexOutOfBoundsException::class.java) { cursor.getLong(0) } + } + } + + @Test + fun `empty results never load a row`() { + LazyCursor(arrayOf("_id"), 0) { _, _ -> error("No rows to load") }.use { cursor -> + assertThat(cursor.count, equalTo(0)) + assertThat(cursor.moveToFirst(), equalTo(false)) + } + } + + @Test + fun `moving releases the previous row`() { + val rows = mutableListOf() + LazyCursor(arrayOf("_id"), 2) { position, row -> + rows.add(row) + row.addRow(arrayOf(position)) + }.use { cursor -> + cursor.moveToFirst() + cursor.moveToNext() + assertThat(rows[0].isClosed, equalTo(true)) + assertThat(rows[1].isClosed, equalTo(false)) + cursor.moveToPrevious() + assertThat(rows[1].isClosed, equalTo(true)) + assertThat(cursor.getInt(0), equalTo(0)) + } + assertThat(rows.all { it.isClosed }, equalTo(true)) + } + + @Test + fun `rows can be copied into a cursor window`() { + LazyCursor(arrayOf("_id", "text", "nullable", "real"), 3) { position, row -> + row.addRow(arrayOf(position.toLong(), "row $position", null, 1.5)) + }.use { cursor -> + CursorWindow("test").use { window -> + cursor.fillWindow(1, window) + assertThat(window.numRows, equalTo(2)) + assertThat(window.getLong(1, 0), equalTo(1L)) + assertThat(window.getString(2, 1), equalTo("row 2")) + assertThat(window.getType(1, 2), equalTo(Cursor.FIELD_TYPE_NULL)) + assertThat(window.getDouble(1, 3), equalTo(1.5)) + } + } + } + + @Test + fun `closing releases the loaded row`() { + var loadedRow: Cursor? = null + val cursor = + LazyCursor(arrayOf("_id"), 1) { _, row -> + loadedRow = row + row.addRow(arrayOf(1L)) + } + cursor.moveToFirst() + cursor.close() + assertThat(cursor.isClosed, equalTo(true)) + assertThat(loadedRow!!.isClosed, equalTo(true)) + assertThrows(IllegalStateException::class.java) { cursor.getLong(0) } + } +} From 169d07b23ffb5e98e5c0f66af82ffda97b99c886 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:06:40 +0100 Subject: [PATCH 13/50] refactor(api): extract card template name lookup Part of 20253 Assisted-by: GPT-6 --- .../ichi2/anki/provider/CardContentProvider.kt | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt b/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt index 8212e2d2c5ca..64e8e4f0d9b2 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt @@ -120,6 +120,16 @@ class CardContentProvider : ContentProvider() { */ private val defaultNoteProjectionDbAccess = FlashCardsContract.Note.DEFAULT_COLUMNS + private fun cardName( + card: Card, + col: Collection, + ): String = + try { + card.template(col).name + } catch (je: JSONException) { + throw IllegalArgumentException("Card is using an invalid template", je) + } + private fun sanitizeNoteProjection(projection: Array?): Array { if (projection.isNullOrEmpty()) { return defaultNoteProjectionDbAccess @@ -1192,12 +1202,7 @@ class CardContentProvider : ContentProvider() { col: Collection, columns: Array, ) { - val cardName: String = - try { - currentCard.template(col).name - } catch (je: JSONException) { - throw IllegalArgumentException("Card is using an invalid template", je) - } + val cardName = cardName(currentCard, col) val question = currentCard.renderOutput(col).questionWithFixedSoundTags() val answer = currentCard.renderOutput(col).answerWithFixedSoundTags() val rb = rv.newRow() From 4e9a6cf7df173bf3df8c2087c599ececc7f0ead8 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:02:57 +0100 Subject: [PATCH 14/50] perf(api): load cursor rows on read, not move Only load when a column is read, so restoring the cursor position does not fetch an unused row. Part of 20253 Assisted-by: GPT-6 --- .../ichi2/anki/tests/ContentProviderTest.kt | 42 ++++++++ .../com/ichi2/anki/provider/LazyCursor.kt | 31 +++--- .../com/ichi2/anki/provider/LazyCursorTest.kt | 96 ++++++++++++++++++- .../java/com/ichi2/anki/FlashCardsContract.kt | 3 + 4 files changed, 157 insertions(+), 15 deletions(-) diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/ContentProviderTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/ContentProviderTest.kt index 9705445506b0..8a5ec84a4ae9 100644 --- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/ContentProviderTest.kt +++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/ContentProviderTest.kt @@ -54,6 +54,8 @@ import org.hamcrest.Matchers.equalTo import org.hamcrest.Matchers.greaterThan import org.hamcrest.Matchers.greaterThanOrEqualTo import org.hamcrest.Matchers.hasItem +import org.hamcrest.Matchers.hasSize +import org.hamcrest.Matchers.notNullValue import org.json.JSONObject.NULL import org.junit.After import org.junit.Assert.assertEquals @@ -367,6 +369,46 @@ class ContentProviderTest : InstrumentedTest() { } } + @Test + fun testSearchCards_fillWindowCopiesAllRows() { + val note = addTempClozeNote("{{c1::A}} {{c2::B}}") + val cursor = + contentResolver.query(FlashCardsContract.Card.CONTENT_URI, null, "nid:${note.id}", null, null) + assertThat(cursor, notNullValue()) + cursor!!.use { + val initialPosition = it.position + CursorWindow("test").use { window -> + cursorFillWindow(it, 0, window) + assertThat(it.position, equalTo(initialPosition)) + assertThat(window.numRows, equalTo(2)) + val noteIdColumn = it.getColumnIndexOrThrow(FlashCardsContract.Card.NOTE_ID) + assertThat(window.getLong(0, noteIdColumn), equalTo(note.id)) + assertThat(window.getLong(1, noteIdColumn), equalTo(note.id)) + } + } + } + + @Test + fun testSearchCards_randomAccessIsStable() { + val note = addTempClozeNote("{{c1::A}} {{c2::B}}") + val cursor = + contentResolver.query(FlashCardsContract.Card.CONTENT_URI, null, "nid:${note.id}", null, null) + assertThat(cursor, notNullValue()) + cursor!!.use { + fun rowAt(position: Int): List { + assertThat(it.moveToPosition(position), equalTo(true)) + return (0 until it.columnCount).map { column -> it.getString(column) } + } + + val forwards = (0 until it.count).map { position -> rowAt(position) } + assertThat(forwards, hasSize(2)) + for (position in it.count - 1 downTo 0) { + assertThat(rowAt(position), equalTo(forwards[position])) + } + assertThat(rowAt(0), equalTo(forwards[0])) + } + } + @Test fun testQueryCardById() { val card = getFirstCardFromScheduler(col) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/provider/LazyCursor.kt b/AnkiDroid/src/main/java/com/ichi2/anki/provider/LazyCursor.kt index 5db06b46b147..dd6e2090db40 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/provider/LazyCursor.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/provider/LazyCursor.kt @@ -8,12 +8,13 @@ import android.database.MatrixCursor /** * A cursor that loads rows on demand and retains at most one loaded row. * - * Creating the cursor or reading its count does not load any rows. Moving to a different valid - * position calls [loadRow], which must add exactly one row to the supplied [MatrixCursor]. - * The previous row is discarded, so returning to it calls [loadRow] again and may yield updated - * values. Android can copy rows into a cursor window without this cursor retaining all the results. + * Creating the cursor, reading its count, and moving between positions do not load any rows. + * Reading a column calls [loadRow], which must add exactly one row to the supplied [MatrixCursor]. + * Further reads at the same position reuse that row. Moving discards it, so reading it again after + * moving away calls [loadRow] again and may yield updated values. Android can copy rows into a + * cursor window without this cursor retaining all the results. * - * For example, defer fetching card details until the cursor moves to a result: + * For example, defer fetching card details until a result is read: * ```kotlin * val cursor = LazyCursor(arrayOf("_id", "reps"), cardIds.size) { position, row -> * val card = col.getCardOrNull(cardIds[position]) @@ -24,8 +25,9 @@ import android.database.MatrixCursor internal class LazyCursor( private val columns: Array, private val rowCount: Int, - private var loadRow: ((Int, MatrixCursor) -> Unit)?, + loadRow: (Int, MatrixCursor) -> Unit, ) : AbstractCursor() { + private var loadRow: ((Int, MatrixCursor) -> Unit)? = loadRow private var row: MatrixCursor? = null override fun getCount(): Int = rowCount @@ -39,9 +41,16 @@ internal class LazyCursor( check(!isClosed) row?.close() row = null + return true + } + + private fun currentRow(): MatrixCursor { + check(!isClosed) + checkPosition() + row?.let { return it } val next = MatrixCursor(columns, 1) try { - loadRow!!(newPosition, next) + loadRow!!(position, next) check(next.count == 1) next.moveToFirst() row = next @@ -49,13 +58,7 @@ internal class LazyCursor( next.close() throw e } - return true - } - - private fun currentRow(): MatrixCursor { - check(!isClosed) - checkPosition() - return checkNotNull(row) + return next } override fun getString(column: Int): String? = currentRow().getString(column) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/provider/LazyCursorTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/provider/LazyCursorTest.kt index d67bfef58278..0828c7cc8e3d 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/provider/LazyCursorTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/provider/LazyCursorTest.kt @@ -5,13 +5,20 @@ package com.ichi2.anki.provider import android.database.Cursor import android.database.CursorIndexOutOfBoundsException import android.database.CursorWindow +import android.os.Build import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.SdkSuppress import com.ichi2.anki.EmptyApplicationCategory import com.ichi2.testutils.EmptyApplication import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.contains import org.hamcrest.Matchers.empty import org.hamcrest.Matchers.equalTo +import org.hamcrest.Matchers.greaterThan +import org.hamcrest.Matchers.hasItem +import org.hamcrest.Matchers.hasSize +import org.hamcrest.Matchers.lessThan +import org.hamcrest.Matchers.not import org.junit.Assert.assertThrows import org.junit.Test import org.junit.experimental.categories.Category @@ -23,7 +30,7 @@ import org.robolectric.annotation.Config @Category(EmptyApplicationCategory::class) class LazyCursorTest { @Test - fun `large results only load visited rows`() { + fun `large results only load rows when read`() { val loaded = mutableListOf() LazyCursor(arrayOf("_id"), 1_000_000) { position, row -> loaded.add(position) @@ -32,7 +39,9 @@ class LazyCursorTest { assertThat(cursor.count, equalTo(1_000_000)) assertThat(loaded, empty()) assertThrows(CursorIndexOutOfBoundsException::class.java) { cursor.getLong(0) } + assertThat(cursor.moveToFirst(), equalTo(true)) assertThat(cursor.moveToLast(), equalTo(true)) + assertThat(loaded, empty()) assertThat(cursor.getLong(0), equalTo(999_999L)) assertThat(loaded, contains(999_999)) assertThat(cursor.moveToFirst(), equalTo(true)) @@ -42,6 +51,7 @@ class LazyCursorTest { assertThat(cursor.moveToPrevious(), equalTo(false)) assertThrows(CursorIndexOutOfBoundsException::class.java) { cursor.getLong(0) } assertThat(cursor.moveToLast(), equalTo(true)) + assertThat(cursor.getLong(0), equalTo(999_999L)) assertThat(loaded, contains(999_999, 0, 999_999)) assertThat(cursor.moveToNext(), equalTo(false)) assertThrows(CursorIndexOutOfBoundsException::class.java) { cursor.getLong(0) } @@ -64,8 +74,11 @@ class LazyCursorTest { row.addRow(arrayOf(position)) }.use { cursor -> cursor.moveToFirst() + cursor.getInt(0) cursor.moveToNext() assertThat(rows[0].isClosed, equalTo(true)) + assertThat(rows, hasSize(1)) + cursor.getInt(0) assertThat(rows[1].isClosed, equalTo(false)) cursor.moveToPrevious() assertThat(rows[1].isClosed, equalTo(true)) @@ -76,11 +89,16 @@ class LazyCursorTest { @Test fun `rows can be copied into a cursor window`() { + val loaded = mutableListOf() LazyCursor(arrayOf("_id", "text", "nullable", "real"), 3) { position, row -> + loaded.add(position) row.addRow(arrayOf(position.toLong(), "row $position", null, 1.5)) }.use { cursor -> + cursor.moveToFirst() CursorWindow("test").use { window -> cursor.fillWindow(1, window) + assertThat(loaded, contains(1, 2)) + assertThat(cursor.position, equalTo(0)) assertThat(window.numRows, equalTo(2)) assertThat(window.getLong(1, 0), equalTo(1L)) assertThat(window.getString(2, 1), equalTo("row 2")) @@ -90,6 +108,52 @@ class LazyCursorTest { } } + @Test + @Config(sdk = [Build.VERSION_CODES.P]) + @SdkSuppress(minSdkVersion = Build.VERSION_CODES.P) + fun `results spanning multiple windows retain every row`() { + val loaded = mutableListOf() + val payload = "x".repeat(1024) + LazyCursor(arrayOf("_id", "text"), 10) { position, row -> + loaded.add(position) + row.addRow(arrayOf(position, "$position:$payload")) + }.use { cursor -> + cursor.moveToLast() + val originalPosition = cursor.position + // Each row fits, but the complete result cannot fit in this window. + CursorWindow("small", 4096).use { window -> + var nextPosition = 0 + var windowCount = 0 + while (nextPosition < cursor.count) { + cursor.fillWindow(nextPosition, window) + assertThat(cursor.position, equalTo(originalPosition)) + assertThat(window.startPosition, equalTo(nextPosition)) + assertThat("The window must hold at least one row", window.numRows, greaterThan(0)) + if (windowCount == 0) { + assertThat("The results must span multiple windows", window.numRows, lessThan(cursor.count)) + assertThat("Filling the first window must not load the last row", loaded, not(hasItem(cursor.count - 1))) + } + for (position in nextPosition until nextPosition + window.numRows) { + assertThat(window.getInt(position, 0), equalTo(position)) + assertThat(window.getString(position, 1), equalTo("$position:$payload")) + } + nextPosition += window.numRows + windowCount++ + } + assertThat(nextPosition, equalTo(cursor.count)) + assertThat(windowCount, greaterThan(1)) + + // Refill a previous window after reading the entire result. + cursor.fillWindow(0, window) + assertThat(cursor.position, equalTo(originalPosition)) + assertThat(window.startPosition, equalTo(0)) + assertThat(window.numRows, greaterThan(0)) + assertThat(window.getInt(0, 0), equalTo(0)) + assertThat(window.getString(0, 1), equalTo("0:$payload")) + } + } + } + @Test fun `closing releases the loaded row`() { var loadedRow: Cursor? = null @@ -99,9 +163,39 @@ class LazyCursorTest { row.addRow(arrayOf(1L)) } cursor.moveToFirst() + cursor.getLong(0) cursor.close() assertThat(cursor.isClosed, equalTo(true)) assertThat(loadedRow!!.isClosed, equalTo(true)) assertThrows(IllegalStateException::class.java) { cursor.getLong(0) } } + + @Test + fun `closing an unread cursor prevents loading`() { + val cursor = LazyCursor(arrayOf("_id"), 1) { _, _ -> error("Cursor is closed") } + cursor.moveToFirst() + cursor.close() + assertThrows(IllegalStateException::class.java) { cursor.getLong(0) } + } + + @Test + fun `numeric strings use MatrixCursor conversions`() { + var loads = 0 + LazyCursor(arrayOf("number", "invalid", "nullable"), 1) { _, row -> + loads++ + row.addRow(arrayOf("123", "not a number", null)) + }.use { cursor -> + cursor.moveToFirst() + assertThat(cursor.getShort(0), equalTo(123.toShort())) + assertThat(cursor.getInt(0), equalTo(123)) + assertThat(cursor.getLong(0), equalTo(123L)) + assertThat(cursor.getFloat(0), equalTo(123f)) + assertThat(cursor.getDouble(0), equalTo(123.0)) + assertThat(cursor.getType(0), equalTo(Cursor.FIELD_TYPE_STRING)) + assertThrows(NumberFormatException::class.java) { cursor.getInt(1) } + assertThat(cursor.getInt(2), equalTo(0)) + assertThat(cursor.isNull(2), equalTo(true)) + assertThat(loads, equalTo(1)) + } + } } diff --git a/api/src/main/java/com/ichi2/anki/FlashCardsContract.kt b/api/src/main/java/com/ichi2/anki/FlashCardsContract.kt index cc4faaf3c662..bbe2305db902 100644 --- a/api/src/main/java/com/ichi2/anki/FlashCardsContract.kt +++ b/api/src/main/java/com/ichi2/anki/FlashCardsContract.kt @@ -936,6 +936,9 @@ public object FlashCardsContract { /** * The content:// style URI for cards. Can be used to search for cards or access specific cards. * For examples on how to use the URI for queries see the overview in [FlashCardsContract]. + * + * For card searches, the matching card IDs and result count are fixed when the query runs. + * Other fields are loaded lazily and may reflect changes made while the cursor is open. */ @JvmField // required for Java API public val CONTENT_URI: Uri = Uri.withAppendedPath(AUTHORITY_URI, "cards") From d9697dd808d8336bee9b25048080f60767cf882f Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:03:46 +0100 Subject: [PATCH 15/50] perf(api): compute only requested card columns Defers rendering questions/answers/card names until they're requested. Part of 20253 Assisted-by: GPT-6 --- .../anki/provider/CardContentProvider.kt | 70 ++++++++++--------- 1 file changed, 36 insertions(+), 34 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt b/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt index 64e8e4f0d9b2..fe49f8d9bfc7 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt @@ -120,6 +120,38 @@ class CardContentProvider : ContentProvider() { */ private val defaultNoteProjectionDbAccess = FlashCardsContract.Note.DEFAULT_COLUMNS + private val cardColumns: Map Any?> = + mapOf( + FlashCardsContract.Card._ID to { card, _ -> card.id }, + FlashCardsContract.Card.NOTE_ID to { card, _ -> card.nid }, + FlashCardsContract.Card.CARD_ORD to { card, _ -> card.ord }, + FlashCardsContract.Card.CARD_NAME to { card, col -> cardName(card, col) }, + FlashCardsContract.Card.DECK_ID to { card, _ -> card.did }, + FlashCardsContract.Card.REPS to { card, _ -> card.reps }, + FlashCardsContract.Card.LAPSES to { card, _ -> card.lapses }, + FlashCardsContract.Card.TYPE to { card, _ -> card.type.code }, + FlashCardsContract.Card.ORIGINAL_DECK_ID to { card, _ -> card.oDid }, + FlashCardsContract.Card.QUESTION to { card, col -> card.renderOutput(col).questionWithFixedSoundTags() }, + FlashCardsContract.Card.ANSWER to { card, col -> card.renderOutput(col).answerWithFixedSoundTags() }, + FlashCardsContract.Card.QUESTION_SIMPLE to { card, col -> card.renderOutput(col).questionText }, + FlashCardsContract.Card.ANSWER_SIMPLE to { card, col -> card.renderOutput(col, false).answerText }, + FlashCardsContract.Card.ANSWER_PURE to { card, col -> card.pureAnswer(col) }, + FlashCardsContract.Card.RAW_QUEUE to { card, _ -> card.queue.code }, + FlashCardsContract.Card.RAW_DUE to { card, _ -> card.due }, + FlashCardsContract.Card.RAW_ORIGINAL_DUE to { card, _ -> card.oDue }, + FlashCardsContract.Card.INTERVAL to { card, _ -> card.ivl }, + FlashCardsContract.Card.RAW_SM2_FACTOR to { card, _ -> card.factor }, + FlashCardsContract.Card.RAW_LEFT to { card, _ -> card.left }, + FlashCardsContract.Card.ORIGINAL_POSITION to { card, _ -> card.originalPosition }, + FlashCardsContract.Card.RAW_CUSTOM_DATA to { card, _ -> card.customData }, + FlashCardsContract.Card.FSRS_STABILITY to { card, _ -> card.memoryStateStability }, + FlashCardsContract.Card.FSRS_DIFFICULTY to { card, _ -> card.memoryStateDifficulty }, + FlashCardsContract.Card.FSRS_DESIRED_RETENTION to { card, _ -> card.fsrsDesiredRetention }, + FlashCardsContract.Card.FSRS_DECAY to { card, _ -> card.decay }, + FlashCardsContract.Card.LAST_REVIEW_TIME_SECONDS to { card, _ -> card.lastReviewTimeSecs }, + FlashCardsContract.Card.FLAGS to { card, _ -> card.flags and 7 }, // return only 3 first bits + ) + private fun cardName( card: Card, col: Collection, @@ -1202,45 +1234,15 @@ class CardContentProvider : ContentProvider() { col: Collection, columns: Array, ) { - val cardName = cardName(currentCard, col) - val question = currentCard.renderOutput(col).questionWithFixedSoundTags() - val answer = currentCard.renderOutput(col).answerWithFixedSoundTags() val rb = rv.newRow() for (column in columns) { - when (column) { - FlashCardsContract.Card._ID -> rb.add(currentCard.id) - FlashCardsContract.Card.NOTE_ID -> rb.add(currentCard.nid) - FlashCardsContract.Card.CARD_ORD -> rb.add(currentCard.ord) - FlashCardsContract.Card.CARD_NAME -> rb.add(cardName) - FlashCardsContract.Card.DECK_ID -> rb.add(currentCard.did) - FlashCardsContract.Card.REPS -> rb.add(currentCard.reps) - FlashCardsContract.Card.LAPSES -> rb.add(currentCard.lapses) - FlashCardsContract.Card.TYPE -> rb.add(currentCard.type.code) - FlashCardsContract.Card.ORIGINAL_DECK_ID -> rb.add(currentCard.oDid) - FlashCardsContract.Card.QUESTION -> rb.add(question) - FlashCardsContract.Card.ANSWER -> rb.add(answer) - FlashCardsContract.Card.QUESTION_SIMPLE -> rb.add(currentCard.renderOutput(col).questionText) - FlashCardsContract.Card.ANSWER_SIMPLE -> rb.add(currentCard.renderOutput(col, false).answerText) - FlashCardsContract.Card.ANSWER_PURE -> rb.add(currentCard.pureAnswer(col)) - FlashCardsContract.Card.RAW_QUEUE -> rb.add(currentCard.queue.code) - FlashCardsContract.Card.RAW_DUE -> rb.add(currentCard.due) - FlashCardsContract.Card.RAW_ORIGINAL_DUE -> rb.add(currentCard.oDue) - FlashCardsContract.Card.INTERVAL -> rb.add(currentCard.ivl) - FlashCardsContract.Card.RAW_SM2_FACTOR -> rb.add(currentCard.factor) - FlashCardsContract.Card.RAW_LEFT -> rb.add(currentCard.left) - FlashCardsContract.Card.ORIGINAL_POSITION -> rb.add(currentCard.originalPosition) - FlashCardsContract.Card.RAW_CUSTOM_DATA -> rb.add(currentCard.customData) - FlashCardsContract.Card.FSRS_STABILITY -> rb.add(currentCard.memoryStateStability) - FlashCardsContract.Card.FSRS_DIFFICULTY -> rb.add(currentCard.memoryStateDifficulty) - FlashCardsContract.Card.FSRS_DESIRED_RETENTION -> rb.add(currentCard.fsrsDesiredRetention) - FlashCardsContract.Card.FSRS_DECAY -> rb.add(currentCard.decay) - FlashCardsContract.Card.LAST_REVIEW_TIME_SECONDS -> rb.add(currentCard.lastReviewTimeSecs) - FlashCardsContract.Card.FLAGS -> rb.add(currentCard.flags and 7) // return only 3 first bits - else -> throw UnsupportedOperationException("Queue \"$column\" is unknown") - } + rb.add(cardColumnAccessor(column)(currentCard, col)) } } + private fun cardColumnAccessor(column: String): (Card, Collection) -> Any? = + cardColumns[column] ?: throw UnsupportedOperationException("Column \"$column\" is unknown") + private fun addReviewInfoToCursor( currentCard: Card, nextReviewTimesJson: JSONArray, From 39919a79189d0db75f1e321d6b40e035500f3a8c Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:04:39 +0100 Subject: [PATCH 16/50] fix(api): validate column names during query Part of 20253 Assisted-by: GPT-6 --- .../ichi2/anki/tests/ContentProviderTest.kt | 19 +++++++++++++++++++ .../anki/provider/CardContentProvider.kt | 3 +++ 2 files changed, 22 insertions(+) diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/ContentProviderTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/ContentProviderTest.kt index 8a5ec84a4ae9..532c29bbe427 100644 --- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/ContentProviderTest.kt +++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/ContentProviderTest.kt @@ -409,6 +409,25 @@ class ContentProviderTest : InstrumentedTest() { } } + @Test + fun testSearchCards_unknownColumnThrowsFromQuery() { + // Validate the projection even when the search has no results. + for (query in listOf("", "cid:0")) { + val exception = + assertThrows { + contentResolver + .query( + FlashCardsContract.Card.CONTENT_URI, + arrayOf(FlashCardsContract.Card._ID, "not_a_column"), + query, + null, + null, + )?.close() + } + assertThat(exception.message, containsString("not_a_column")) + } + } + @Test fun testQueryCardById() { val card = getFirstCardFromScheduler(col) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt b/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt index fe49f8d9bfc7..ce3aa1c6de7a 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt @@ -472,6 +472,9 @@ class CardContentProvider : ContentProvider() { val columns = projection ?: FlashCardsContract.Card.DEFAULT_COLUMNS val query = selection ?: "" + // validate the columns + columns.forEach { cardColumnAccessor(it) } + val cardIds = try { col.findCards(query) From 9b4bd9525c3e7b1721493d9fdef8ca30c4e12957 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 3 Oct 2026 23:05:41 +0100 Subject: [PATCH 17/50] fix(api): handle deleted cards When loading a row, check if the card is deleted. If it is, all columns except the ID will return `null` Part of 20253 Assisted-by: GPT-6 --- .../ichi2/anki/tests/ContentProviderTest.kt | 23 +++++++++++++++++++ .../anki/provider/CardContentProvider.kt | 18 ++++++++++++++- .../java/com/ichi2/anki/FlashCardsContract.kt | 2 ++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/ContentProviderTest.kt b/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/ContentProviderTest.kt index 532c29bbe427..c3672a7eb83b 100644 --- a/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/ContentProviderTest.kt +++ b/AnkiDroid/src/androidTest/java/com/ichi2/anki/tests/ContentProviderTest.kt @@ -409,6 +409,29 @@ class ContentProviderTest : InstrumentedTest() { } } + @Test + fun testSearchCards_deletedCardRetainsIdWithNullFields() { + val note = addTempClozeNote("{{c1::A}} {{c2::B}}") + val cardIds = col.findCards("nid:${note.id}") + val cursor = + contentResolver.query(FlashCardsContract.Card.CONTENT_URI, null, "nid:${note.id}", null, null) + assertThat(cursor, notNullValue()) + cursor!!.use { + col.removeNotes(noteIds = listOf(note.id)) + assertThat(it.count, equalTo(cardIds.size)) + val idColumn = it.getColumnIndexOrThrow(FlashCardsContract.Card._ID) + val returnedIds = mutableListOf() + while (it.moveToNext()) { + returnedIds.add(it.getLong(idColumn)) + for (column in 0 until it.columnCount) { + if (column == idColumn) continue + assertThat("${it.getColumnName(column)} should be null", it.isNull(column), equalTo(true)) + } + } + assertThat(returnedIds.toSet(), equalTo(cardIds.toSet())) + } + } + @Test fun testSearchCards_unknownColumnThrowsFromQuery() { // Validate the projection even when the search has no results. diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt b/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt index ce3aa1c6de7a..fccd2f992dbe 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/provider/CardContentProvider.kt @@ -52,6 +52,7 @@ import com.ichi2.utils.FileUtil.internalizeUri import com.ichi2.utils.Permissions.arePermissionsDefinedInManifest import net.ankiweb.rsdroid.BackendException import net.ankiweb.rsdroid.exceptions.BackendDeckIsFilteredException +import net.ankiweb.rsdroid.exceptions.BackendNotFoundException import org.json.JSONArray import org.json.JSONException import timber.log.Timber @@ -493,7 +494,22 @@ class CardContentProvider : ContentProvider() { if (onlyRequestingId) { row.newRow().add(cardId) } else { - addCardToCursor(col.getCard(cardId), row, col, columns) + val currentCol = getColUnsafe() + val card = + try { + currentCol.getCard(cardId) + } catch (e: BackendNotFoundException) { + Timber.d(e, "card %d was deleted after the query returned", cardId) + null + } + if (card != null) { + addCardToCursor(card, row, currentCol, columns) + } else { + val rb = row.newRow() + for (column in columns) { + rb.add(if (column == FlashCardsContract.Card._ID) cardId else null) + } + } } } } diff --git a/api/src/main/java/com/ichi2/anki/FlashCardsContract.kt b/api/src/main/java/com/ichi2/anki/FlashCardsContract.kt index bbe2305db902..287f32182841 100644 --- a/api/src/main/java/com/ichi2/anki/FlashCardsContract.kt +++ b/api/src/main/java/com/ichi2/anki/FlashCardsContract.kt @@ -939,6 +939,8 @@ public object FlashCardsContract { * * For card searches, the matching card IDs and result count are fixed when the query runs. * Other fields are loaded lazily and may reflect changes made while the cursor is open. + * If a card is deleted before its row is loaded, the row retains its [_ID] and returns `null` + * for all other requested columns. Rows already loaded may still contain the earlier values. */ @JvmField // required for Java API public val CONTENT_URI: Uri = Uri.withAppendedPath(AUTHORITY_URI, "cards") From 394126da46fc848ed6b61e7fdc8088bbf62d13a6 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:33:26 +0100 Subject: [PATCH 18/50] ci(emulator): remove legacy 'inputs.clearCaches' reference No longer exists. Part of 21487 Assisted-by: GPT-6 --- .github/workflows/tests_emulator.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/tests_emulator.yml b/.github/workflows/tests_emulator.yml index b6da152e6d51..f5a2ebdcfaef 100644 --- a/.github/workflows/tests_emulator.yml +++ b/.github/workflows/tests_emulator.yml @@ -148,7 +148,7 @@ jobs: path: | ~/.android/avd/* ~/.android/adb* - key: avd-${{ matrix.api-level }}-${{ matrix.arch }}-${{matrix.target}}-v2-${{ github.event.inputs.clearCaches }} + key: avd-${{ matrix.api-level }}-${{ matrix.arch }}-${{matrix.target}}-v2- restore-keys: | avd-${{ matrix.api-level }}-${{ matrix.arch }}-${{matrix.target}}-v2 @@ -193,7 +193,7 @@ jobs: - 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 - if: "${{ github.event.inputs.clearCaches != '' || (steps.avd-cache.outputs.cache-hit != 'true' && github.ref == 'refs/heads/main') }}" + if: "${{ (steps.avd-cache.outputs.cache-hit != 'true' && github.ref == 'refs/heads/main') }}" uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 with: api-level: ${{ matrix.api-level }} @@ -215,7 +215,7 @@ jobs: # Only generate a snapshot for saving if we are on main branch with a cache miss # Switch the if statements via comment if generating snapshots for performance testing # if: matrix.first-boot-delay != '0' - if: "${{ github.event.inputs.clearCaches != '' || (steps.avd-cache.outputs.cache-hit != 'true' && github.ref == 'refs/heads/main') }}" + if: "${{ (steps.avd-cache.outputs.cache-hit != 'true' && github.ref == 'refs/heads/main') }}" env: FIRST_BOOT_DELAY: ${{ matrix.first-boot-delay }} uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 From da13c6732bc32316dcb68a292ddb6d7cd679dffd Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:44:02 +0100 Subject: [PATCH 19/50] ci(emulator): add a scheduled weekly run (03:17 UTC) Part of 21487 Assisted-by: GPT-6 --- .github/workflows/tests_emulator.yml | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/.github/workflows/tests_emulator.yml b/.github/workflows/tests_emulator.yml index f5a2ebdcfaef..b07b19207b47 100644 --- a/.github/workflows/tests_emulator.yml +++ b/.github/workflows/tests_emulator.yml @@ -1,6 +1,9 @@ name: 🛠️ Emulator Tests on: + schedule: + # Weekly emulator coverage, Monday at 03:17 UTC. + - cron: '17 3 * * 1' workflow_dispatch: inputs: iterations: @@ -21,7 +24,7 @@ permissions: contents: read concurrency: - group: ${{ github.workflow }}-${{ github.ref }} + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name == 'schedule' && 'scheduled' || 'ci' }} cancel-in-progress: true jobs: @@ -56,15 +59,6 @@ jobs: console.log('iterationArray is: ' + iterationArray) } - // If we are running on a schedule it's our periodic passive scan for flakes - // Goal is to run enough iterations on all systems that we have confidence there are no flakes - if (context.eventName === "schedule") { - const iterationCount = 15 - for (let i = 0; i < iterationCount; i++) { - iterationArray.push(i); - } - } - // TODO Refactor to make these dynamic with a minSdk/max bracket only on schedule, not push let apiLevel = [36]; From ba6a9c54379b62804da68a1d6bc5125a7fdd662c Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:44:02 +0100 Subject: [PATCH 20/50] ci(emulator): make 16 KB emulator scheduled-only This takes up a runner, 2.5GB of cache space, and has only once picked up a runner-specific bug, which was a fixed bug in the framework. We're getting more runner contention these days, so only test it weekly. Part of 21487 Assisted-by: GPT-6 --- .github/workflows/tests_emulator.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/tests_emulator.yml b/.github/workflows/tests_emulator.yml index b07b19207b47..2dad6d577124 100644 --- a/.github/workflows/tests_emulator.yml +++ b/.github/workflows/tests_emulator.yml @@ -66,6 +66,11 @@ jobs: // try different architectures, targets, delays etc let arch = ['x86_64']; let target = ['google_apis', 'google_apis_ps16k']; + // Keep the 16 KB emulator off the per-change CI path. + // Scheduled and manual runs exercise both targets. + if (context.eventName !== 'schedule' && context.eventName !== 'workflow_dispatch') { + target = ['google_apis']; + } let firstBootDelay = [600]; return { From 9305e99f7be81cae0dac4216b87fe742f746e012 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 3 Oct 2026 00:44:02 +0100 Subject: [PATCH 21/50] ci: skip caching the 16 KB emulator snapshot Frees up 2.5 GB of cache (1/4 of our total) Part of 21487 Assisted-by: GPT-6 --- .github/workflows/tests_emulator.yml | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/.github/workflows/tests_emulator.yml b/.github/workflows/tests_emulator.yml index 2dad6d577124..82d73dee38c1 100644 --- a/.github/workflows/tests_emulator.yml +++ b/.github/workflows/tests_emulator.yml @@ -137,10 +137,9 @@ jobs: # Comment this and the with: above out for performance testing on a branch cache-read-only: ${{ github.ref != 'refs/heads/main' }} - # This appears to be 'Cache Size: ~1230 MB (1290026823 B)' based on watching action logs - # Repo limit is 10GB; branch caches are independent; branches may read default branch cache. - # We don't want branches to evict main branch snapshot, so save on main, read-only all else + # Cache only the per-change emulator: the 16 KB snapshot costs about 2.5 GiB. - name: AVD cache + if: matrix.target == 'google_apis' uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 id: avd-cache with: @@ -190,9 +189,9 @@ jobs: "${ANDROID_HOME:?}/system-images/android-${SDK_API_LEVEL:?}/${SDK_TARGET:?}/${SDK_ARCH:?}/.installer" - name: AVD Boot and Snapshot Creation - # Only generate a snapshot for saving if we are on main branch with a cache miss + # Warm up the uncached 16 KB emulator on every run; otherwise only on main cache misses. # Comment the if out to generate snapshots on branch for performance testing - if: "${{ (steps.avd-cache.outputs.cache-hit != 'true' && github.ref == 'refs/heads/main') }}" + if: "${{ matrix.target == 'google_apis_ps16k' || (steps.avd-cache.outputs.cache-hit != 'true' && github.ref == 'refs/heads/main') }}" uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 with: api-level: ${{ matrix.api-level }} @@ -211,10 +210,10 @@ jobs: # This step is separate so pure install time may be calculated as a step - name: Emulator Snapshot After Firstboot Warmup - # Only generate a snapshot for saving if we are on main branch with a cache miss + # Always warm up the 16 KB emulator. The default emulator only needs it on main cache misses. # Switch the if statements via comment if generating snapshots for performance testing # if: matrix.first-boot-delay != '0' - if: "${{ (steps.avd-cache.outputs.cache-hit != 'true' && github.ref == 'refs/heads/main') }}" + if: "${{ matrix.target == 'google_apis_ps16k' || (steps.avd-cache.outputs.cache-hit != 'true' && github.ref == 'refs/heads/main') }}" env: FIRST_BOOT_DELAY: ${{ matrix.first-boot-delay }} uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 From afed0e1262ad80439801f664a55b23076284ca25 Mon Sep 17 00:00:00 2001 From: AnkiDroid Translations Date: Mon, 5 Oct 2026 05:33:43 +0000 Subject: [PATCH 22/50] Updated strings from Crowdin --- AnkiDroid/src/main/res/values-ckb/01-core.xml | 2 +- .../src/main/res/values-ja/02-strings.xml | 4 +- .../src/main/res/values-ja/10-preferences.xml | 20 ++-- AnkiDroid/src/main/res/values-tr/01-core.xml | 2 +- .../src/main/res/values-tr/02-strings.xml | 6 +- .../src/main/res/values-tr/03-dialogs.xml | 2 +- .../src/main/res/values-tr/10-preferences.xml | 96 +++++++++---------- 7 files changed, 66 insertions(+), 66 deletions(-) diff --git a/AnkiDroid/src/main/res/values-ckb/01-core.xml b/AnkiDroid/src/main/res/values-ckb/01-core.xml index 2bc00e799ab0..a4e686b82afd 100644 --- a/AnkiDroid/src/main/res/values-ckb/01-core.xml +++ b/AnkiDroid/src/main/res/values-ckb/01-core.xml @@ -125,7 +125,7 @@ The card type for the current card was deleted. Read and write to the AnkiDroid database - access existing notes, cards, note types, and decks, as well as create new ones + ڕێگەپێدان بە گەشتن بە تێبینییەکان ،کارتەکان ،جۆری تێبینییەکان ،و دەستەکان ،لەگەڵ دروستکردنی هی نوێ Card browser Anki card diff --git a/AnkiDroid/src/main/res/values-ja/02-strings.xml b/AnkiDroid/src/main/res/values-ja/02-strings.xml index 471be7134a5e..71be8b7e75e0 100644 --- a/AnkiDroid/src/main/res/values-ja/02-strings.xml +++ b/AnkiDroid/src/main/res/values-ja/02-strings.xml @@ -73,7 +73,7 @@ WebViewはシステムによって終了されました。これは通常、アプリケーションがメモリ不足時に発生します。多くの場合、データ量の多いフォントやメディアが原因です。 WebViewレンダラーがクラッシュしました。原因: %s Fatal error: WebView renderer crashed. Cause: %s - System WebView rendering failure + システムのWebViewのレンダリングエラー AndroidシステムのWebViewがカード \'%1$s\' のレンダリングに失敗しました。\n %2$s 左にスライドでキャンセル フラグ @@ -126,7 +126,7 @@ Markdown形式でコピー %1$d / %2$d 名前を変更 - チェック + 検査 タイピングして答えを入力: 不明なフィールド %s デッキを削除しています… AnkiDroidを評価 diff --git a/AnkiDroid/src/main/res/values-ja/10-preferences.xml b/AnkiDroid/src/main/res/values-ja/10-preferences.xml index bf04846c3b7f..dcda4fff235c 100644 --- a/AnkiDroid/src/main/res/values-ja/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-ja/10-preferences.xml @@ -224,15 +224,15 @@ 学習リマインダー コレクション全体 特定のデッキ - An error occurred while loading your review reminders, corrupted reminders have been deleted. Details: + 学習リマインダーの読み込み中にエラーが発生しました。破損したリマインダーは削除されました。詳細: Schedule reminders リマインダーを追加 学習のリマインダーがありません トラブルシューティング アプリの通知設定 - List of scheduled review reminders - Deck not found + スケジュール済みの学習リマインダーの一覧 + デッキが見つかりません リマインダーが機能していません 設定変更 新しい学習リマインダーを追加しました @@ -241,16 +241,16 @@ 学習リマインダーを追加 学習リマインダーを編集 - 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. + このリマインダーを削除しますか? + この操作は元に戻せません。 リマインダーが機能していません。 リマインダーが正常に動作しない可能性があります。 diff --git a/AnkiDroid/src/main/res/values-tr/01-core.xml b/AnkiDroid/src/main/res/values-tr/01-core.xml index c2bd9437ab8e..945ce6788249 100644 --- a/AnkiDroid/src/main/res/values-tr/01-core.xml +++ b/AnkiDroid/src/main/res/values-tr/01-core.xml @@ -128,7 +128,7 @@ varolan notlara, kartlara, not türlerine ve destelere erişmek, ve yenilerini oluşturmak Kart tarayıcısı - Anki card + Anki kartı Koleksiyonlar birleştirilemiyor.\nHangi koleksiyonu tutmak istersiniz? AnkiDroid diff --git a/AnkiDroid/src/main/res/values-tr/02-strings.xml b/AnkiDroid/src/main/res/values-tr/02-strings.xml index 9b8ab5358ddf..8c0628ea2dae 100644 --- a/AnkiDroid/src/main/res/values-tr/02-strings.xml +++ b/AnkiDroid/src/main/res/values-tr/02-strings.xml @@ -236,7 +236,7 @@ Araç çubuğu öğesini düzenle Seçili yazıdan önce ve sonra eklenecek HTML girin\n\nBir araç çubuğu öğesini değiştirmek ya da kaldırmak için basılı tutun Araç çubuğu öğesi kaldırılsın mı? - Button text is required + Düğme metni gerekiyor En az bir HTML alanı gereklidir. Bu resim fazla büyük, lütfen resmi elle ekleyin Bu video dosyası fazla büyük, lütfen videoyu elle ekleyin @@ -275,8 +275,8 @@ %1$s/s -/s - - Speed - Time left + Hız + Kalan süre İndirme devam ederken diğer uygulamaları kullanabilirsiniz Lütfen ağ bağlantını kontrol et İndirme iptal edildi, harici hafıza erişilebilir değil. diff --git a/AnkiDroid/src/main/res/values-tr/03-dialogs.xml b/AnkiDroid/src/main/res/values-tr/03-dialogs.xml index bf9f336f5dd1..f3fc00c5ed5c 100644 --- a/AnkiDroid/src/main/res/values-tr/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values-tr/03-dialogs.xml @@ -78,7 +78,7 @@ Üzerine Yaz Onar Değiştir - %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\nTarih ve zamanı düzeltin, veya otomatik tarih ve zamanı açın, sonra tekrar senkronize edin.\n\nCihazınızın güncel zamanı: %2$s Belirtilen konum geçerli bir dizin değildi. Verilen metin geçerli bir PEM-kodlanmış X509 sertifikasına çözümlenmiyor Sertifika çözümlemesinde hata diff --git a/AnkiDroid/src/main/res/values-tr/10-preferences.xml b/AnkiDroid/src/main/res/values-tr/10-preferences.xml index 5c67c9168d35..043485bb4a9f 100644 --- a/AnkiDroid/src/main/res/values-tr/10-preferences.xml +++ b/AnkiDroid/src/main/res/values-tr/10-preferences.xml @@ -222,75 +222,75 @@ ################################## Review reminders ################################### ####################################################################################### --> - Review reminders + Çalışma hatırlatıcıları Uygulama geneli - Deck-specific - An error occurred while loading your review reminders, corrupted reminders have been deleted. Details: + Desteye özel + Çalışma hatırlatıcılarınızı yüklerken bir hata oldu, bozuk hatırlatıcılar silindi. Detaylar: - Schedule reminders + Hatırlatıcı kur Hatırlatıcı ekle Çalışma hatırlatıcınız yok - Troubleshoot + Sorun gider Bildirim ayarları - List of scheduled review reminders + Kurulmuş çalışma hatırlatıcılarının listesi Deste bulunamadı - Reminders are unavailable - Fix - Successfully added new review reminder - Successfully deleted review reminder - Successfully edited review reminder + Hatırlatıcılar kullanılamıyor + Düzelt + Başarılı bir şekilde yeni çalışma hatırlatıcısı eklendi + Başarılı bir şekilde çalışma hatırlatıcısı silindi + Başarılı bir şekilde çalışma hatırlatıcısı düzenlendi Çalışma hatırlatıcısı ekle Çalışma hatırlatıcısını değiştir - Time: + Saat: Deste: Gelişmiş ayarlar - 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 + Kart eşiği: + Çalışılacak bundan az sayıda kart varsa bildirim gönderme + Sadece bugün hiç çalışmadıysam bildirim gönder + Lütfen tam bir kart sayısı girin + Eşik en az 0 olmalıdır Bu hatırlatıcı silinsin mi? Bu eylem geri alınamaz. - 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. + Hatırlatıcılar kullanılamıyor. + Hatırlatıcılar doğru çalışmayabilir. + Hatırlatıcılarınız beklendiği gibi çalışıyor olmalı. + Farklı cihazların farklı bildirim kısıtlama ayarları olur. Sistem ayarlarınızı kontrol edin veya sorunlar devam ederse internette araştırın. - Notification permission + Bildirim izni Bildirim kanalı - Do not disturb + Rahatsız etme Pil optimizasyonu Güç tasarruf modu - Alarms & reminders permission + Alarmlar ve hatırlatıcılar izni - Granted - Denied + Verilmiş + Reddedilmiş Açık Kapalı - Unrestricted - Optimized - Restricted + Kısıtsız + Optimize + Kısıtlı - 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 + Çalışma hatırlatıcısı bildirim kanalı açık olmalıdır + Rahatsız Etme hatırlatıcı bildirimlerini sessizleştirebilir + Pil optimizasyonu hatırlatıcıları geciktirebilir + Arka plan kullanımı kapalı. Hatırlatıcılar gönderilemeyebilir + Enerji tasarrufu modu hatırlatıcıların zamanında gelmesini önleyebilir + Kesin zamanlı hatırlatıcılar kurmak için gerekir - Grant permission - Enable notification channel - Disable battery optimization - Open battery settings + İzin ver + Bildirim kanalını aç + Pil optimizasyonunu kapat + Pil ayarlarını aç - It\'s time to study your cards - It\'s time to study \"%s\" - Snooze %s + Kartlarınızı çalışma zamanı + \"%s\" çalışma zamanı + %s ertele - %d card due - %d cards due + %d kartın sırası geldi + %d kartın sırası geldi Etiketleri Seçin @@ -310,7 +310,7 @@ Hakkında Katkıda Bulunanlar insanlar. Siz de AnkiDroid\'e programlayarak, çevirerek, bağış yaparak ve daha fazla yolla yardım edebilirsiniz!]]> - contributors. You can help AnkiDroid too by programming, translating and more!]]> + katkıda bulunan harika insanlar. Siz de AnkiDroid\'e programlama, çeviri ve daha fazlasını yaparak yardımcı olabilirsiniz!]]> Lisans GPL-3.0 ve AGPL-3.0 lisansları altında yayınlanmaktadır, kaynak kodu GitHub\'da mevcuttur]]> lisanslar altında üçüncü parti kütüphaneler ve simgeler de kullanır.]]> @@ -441,9 +441,9 @@ geri bildiriminizi veya bulduğunuz hataları bildirin.]]> Cevap geribildirimini göster Ekran - Also assign to ‘%1$s’? - Skip - Assign + \"%1$s\"e de atansın mı? + Geç + Ata Ayarları aç İlgili ayarlar From babc5135850dc9b712306054a31ab8beb6ed2774 Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Sat, 12 Sep 2026 02:29:45 +0530 Subject: [PATCH 23/50] build(deps): add ProcessPhoenix ProcessPhoenix will do the restart. It relaunches the app from a separate process once this one has been killed, so the relaunch is never handed to a process that is still dying. It is Apache 2.0 and has no dependencies of its own. --- AnkiDroid/build.gradle.kts | 1 + .../src/test/java/com/ichi2/anki/ActivityStartupMetaTest.kt | 1 + gradle/libs.versions.toml | 3 +++ 3 files changed, 5 insertions(+) diff --git a/AnkiDroid/build.gradle.kts b/AnkiDroid/build.gradle.kts index d7a70bc2b197..ad1907d475d7 100644 --- a/AnkiDroid/build.gradle.kts +++ b/AnkiDroid/build.gradle.kts @@ -791,6 +791,7 @@ dependencies { implementation(libs.nanohttpd) implementation(libs.kotlinx.serialization.json) implementation(libs.seismic) + implementation(libs.process.phoenix) debugImplementation(libs.androidx.fragment.testing.manifest) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/ActivityStartupMetaTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/ActivityStartupMetaTest.kt index 25362955c266..bfa3565d3349 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/ActivityStartupMetaTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/ActivityStartupMetaTest.kt @@ -38,6 +38,7 @@ class ActivityStartupMetaTest : RobolectricTest() { .filter { !it.startsWith("org.acra") } .filter { !it.startsWith("leakcanary.internal") } .filter { it != "com.canhub.cropper.CropImageActivity" } + .filter { it != "com.jakewharton.processphoenix.PhoenixActivity" } .toTypedArray() MatcherAssert.assertThat(testedActivityClassNames, Matchers.containsInAnyOrder(*manifestActivityNames)) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1a23d6f1bcd8..3224316d3e67 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -113,6 +113,8 @@ mockitoKotlin = "6.4.0" mockk = "1.14.11" nanohttpd = "2.3.1" okhttp = "5.5.0" +# https://github.com/JakeWharton/ProcessPhoenix/releases +processPhoenix = "3.0.0" # https://github.com/protocolbuffers/protobuf/releases protobufKotlinLite = "4.36.2" # ../AnkiDroid/robolectricDownload.gradle may need changes - read instructions in that file @@ -199,6 +201,7 @@ okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } android-lint-api = { module = "com.android.tools.lint:lint-api", version.ref = "lint" } android-lint = { module = "com.android.tools.lint:lint", version.ref = "lint" } android-lint-tests = { module = "com.android.tools.lint:lint-tests", version.ref = "lint" } +process-phoenix = { module = "com.jakewharton:process-phoenix", version.ref = "processPhoenix" } protobuf-kotlin-lite = { module = "com.google.protobuf:protobuf-kotlin-lite", version.ref = "protobufKotlinLite" } search-preference = { module = "com.github.ByteHamster:SearchPreference", version.ref = "searchpreference" } seismic = { module = "com.squareup:seismic", version.ref = "seismic" } From b6dea19e1eee09a005e00c60c9c1227f402dc2cb Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Sat, 12 Sep 2026 02:29:45 +0530 Subject: [PATCH 24/50] feat(profiles): skip app setup in the ProcessPhoenix process PhoenixActivity runs in its own :phoenix process, and Android creates the Application in every process of the app. AnkiDroidApp would start the backend and the rest of the app there, in a process that only lives long enough to kill the old one and launch the new one. It now returns straight away there. Assisted-by: Claude Opus 5 (multiple iterations over original code) --- .../main/java/com/ichi2/anki/AnkiDroidApp.kt | 5 +++++ .../com/ichi2/anki/multiprofile/AppRestart.kt | 15 +++++++++++++ .../ichi2/anki/multiprofile/AppRestartTest.kt | 22 +++++++++++++++++++ 3 files changed, 42 insertions(+) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/AppRestart.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/AppRestartTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt index ade3addae50e..e059c7f0c37b 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt @@ -53,6 +53,7 @@ import com.ichi2.anki.logging.logActivityCreation import com.ichi2.anki.model.FieldFilters.NoSuggestFilter import com.ichi2.anki.multimedia.MultimediaArgsStorage import com.ichi2.anki.multiprofile.ProfileManager +import com.ichi2.anki.multiprofile.isPhoenixProcess import com.ichi2.anki.navigation.initializeNavigator import com.ichi2.anki.observability.ChangeManager import com.ichi2.anki.preferences.SharedPreferencesProvider @@ -169,6 +170,10 @@ open class AnkiDroidApp : */ @KotlinCleanup("analytics can be moved to attachBaseContext()") override fun onCreate() { + if (isPhoenixProcess()) { + super.onCreate() + return + } initAnkiBackend(debugTraceSqlCalls = false) super.onCreate() if (!setupAnkiDroidApp()) { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/AppRestart.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/AppRestart.kt new file mode 100644 index 000000000000..ea1ed41b88bb --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/AppRestart.kt @@ -0,0 +1,15 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 Ashish Yadav + +package com.ichi2.anki.multiprofile + +import androidx.annotation.VisibleForTesting +import java.io.File + +/** True in the short-lived ProcessPhoenix process, which only relaunches the app. */ +fun isPhoenixProcess(): Boolean = isPhoenixProcessName(currentProcessName()) + +@VisibleForTesting +internal fun isPhoenixProcessName(name: String?): Boolean = name?.endsWith(":phoenix") == true + +private fun currentProcessName(): String? = runCatching { File("/proc/self/cmdline").readText().trim { it <= ' ' } }.getOrNull() diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/AppRestartTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/AppRestartTest.kt new file mode 100644 index 000000000000..14a8a418239d --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/AppRestartTest.kt @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 Ashish Yadav + +package com.ichi2.anki.multiprofile + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.RobolectricTest +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +@RunWith(AndroidJUnit4::class) +class AppRestartTest : RobolectricTest() { + @Test + fun `only the ProcessPhoenix process is recognised as it`() { + assertTrue(isPhoenixProcessName("com.ichi2.anki:phoenix")) + assertFalse(isPhoenixProcessName("com.ichi2.anki")) + assertFalse(isPhoenixProcessName("com.ichi2.anki:acra")) + assertFalse(isPhoenixProcessName(null)) + } +} From 2688fd202d99470188853f5f8e331a561943f0cb Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Sat, 12 Sep 2026 02:29:45 +0530 Subject: [PATCH 25/50] feat(profiles): hold a lock while background work runs A profile switch restarts the process, so it has to wait for work that outlives the collection queue, and keep new work from starting. A backup keeps writing after the backend releases the collection, and syncs run in workers. Each now holds a lock while it runs, for the restart to take. A worker that finds its lock taken drops its work rather than rescheduling it, since the work carries the AnkiWeb key of the profile being left. The backend cannot be asked to wait for a backup from elsewhere: it hands its running backup to the first caller that asks, and createBackup asks as soon as the backup starts. Assisted-by: Claude Opus 5 (multiple iterations over original code) --- .../java/com/ichi2/anki/BackendBackups.kt | 19 +++++- .../com/ichi2/anki/worker/SyncMediaWorker.kt | 10 ++++ .../java/com/ichi2/anki/worker/SyncWorker.kt | 11 ++++ .../anki/worker/WithLockUnlessSwitching.kt | 24 ++++++++ .../ichi2/anki/worker/SyncWorkerLockTest.kt | 59 +++++++++++++++++++ 5 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/worker/WithLockUnlessSwitching.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/worker/SyncWorkerLockTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/BackendBackups.kt b/AnkiDroid/src/main/java/com/ichi2/anki/BackendBackups.kt index 800b64a6351c..2cb302ab2314 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/BackendBackups.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/BackendBackups.kt @@ -21,6 +21,8 @@ import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.libanki.Collection import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import net.ankiweb.rsdroid.BackendException @@ -57,7 +59,22 @@ fun Activity.importColpkg(colpkgPath: String) where Activity : AnkiAc } } -private suspend fun createBackup(force: Boolean): Boolean { +/** + * Held for the whole of a background backup, including the stage the backend + * finishes after it has released the collection. + * + * Waiting on the backend directly does not work from elsewhere: it hands its + * running backup to whichever caller asks first, and [createBackup] asks as soon + * as the backup starts, so a second caller returns without waiting. + */ +val backupLock = Mutex() + +private suspend fun createBackup(force: Boolean) = + backupLock.withLock { + createBackupHoldingLock(force) + } + +private suspend fun createBackupHoldingLock(force: Boolean): Boolean { val created = withCol { // this two-step approach releases the backend lock after the initial copy diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncMediaWorker.kt b/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncMediaWorker.kt index 80813cf2d99d..f634050b8635 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncMediaWorker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncMediaWorker.kt @@ -37,9 +37,16 @@ import com.ichi2.anki.utils.ext.trySetForeground import com.ichi2.utils.TruncatedString import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay +import kotlinx.coroutines.sync.Mutex import net.ankiweb.rsdroid.Backend import timber.log.Timber +/** + * Held while a media sync runs. A profile switch holds it until the process dies, + * so a media sync cannot start part way through a restart. + */ +val mediaSyncLock = Mutex() + class SyncMediaWorker( context: Context, parameters: WorkerParameters, @@ -54,7 +61,10 @@ class SyncMediaWorker( override suspend fun doWork(): Result { Timber.v("SyncMediaWorker::doWork") + return mediaSyncLock.withLockUnlessSwitching { doWorkHoldingLock() } + } + private suspend fun doWorkHoldingLock(): Result { try { val auth = requireNotNull(inputData.toSyncAuth()) 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 bd0888b7d718..6a804b9b564f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncWorker.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/worker/SyncWorker.kt @@ -38,9 +38,16 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex import timber.log.Timber import kotlin.coroutines.cancellation.CancellationException +/** + * Held while a background collection sync runs. A profile switch holds it until + * the process dies, so a sync cannot start part way through a restart. + */ +val syncLock = Mutex() + /** * Syncs collection and media in the background. * @@ -69,6 +76,10 @@ class SyncWorker( override suspend fun doWork(): Result { Timber.v("SyncWorker::doWork") + return syncLock.withLockUnlessSwitching { doWorkHoldingLock() } + } + + private suspend fun doWorkHoldingLock(): Result { trySetForeground(getForegroundInfo()) val auth = inputData.toSyncAuth() ?: return Result.failure() diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/worker/WithLockUnlessSwitching.kt b/AnkiDroid/src/main/java/com/ichi2/anki/worker/WithLockUnlessSwitching.kt new file mode 100644 index 000000000000..f0cbf9796ca2 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/worker/WithLockUnlessSwitching.kt @@ -0,0 +1,24 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 Ashish Yadav + +package com.ichi2.anki.worker + +import androidx.work.ListenableWorker.Result +import kotlinx.coroutines.sync.Mutex +import timber.log.Timber + +/** + * Runs [block] holding this lock. If a profile switch holds it, the work is dropped + * rather than rescheduled, since it carries the AnkiWeb key of the profile being left. + */ +internal suspend fun Mutex.withLockUnlessSwitching(block: suspend () -> Result): Result { + if (!tryLock()) { + Timber.i("Background work dropped: a profile switch is restarting the app") + return Result.failure() + } + try { + return block() + } finally { + unlock() + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/worker/SyncWorkerLockTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/worker/SyncWorkerLockTest.kt new file mode 100644 index 000000000000..51c4cee35742 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/worker/SyncWorkerLockTest.kt @@ -0,0 +1,59 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 Ashish Yadav + +package com.ichi2.anki.worker + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.work.CoroutineWorker +import androidx.work.ListenableWorker +import androidx.work.testing.TestListenableWorkerBuilder +import com.ichi2.anki.RobolectricTest +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +/** + * A [RobolectricTest] because running a worker creates a backend through `TR`, and its + * teardown is what discards the backend. A leftover backend defeats tests that swap + * the backend factory, such as `BackendEmulatingOpenConflictTest`. + */ +@RunWith(AndroidJUnit4::class) +class SyncWorkerLockTest : RobolectricTest() { + private inline fun worker(): W = TestListenableWorkerBuilder(targetContext).build() + + private suspend fun assertDroppedWhileSwitching( + lock: Mutex, + run: suspend () -> ListenableWorker.Result, + ) = lock.withLock { + assertIs(run(), "rescheduled, it would run later with the AnkiWeb key of the profile being left") + assertTrue(lock.isLocked, "the worker must not release a lock the switch holds") + } + + private suspend fun assertReleasesAfterRunning( + lock: Mutex, + run: suspend () -> ListenableWorker.Result, + ) { + runCatching { run() } + assertFalse(lock.isLocked, "a finished sync must not leave switching blocked") + } + + @Test + fun `a sync is dropped while a profile switch holds its lock`() = + runTest { assertDroppedWhileSwitching(syncLock) { worker().doWork() } } + + @Test + fun `a media sync is dropped while a profile switch holds its lock`() = + runTest { assertDroppedWhileSwitching(mediaSyncLock) { worker().doWork() } } + + @Test + fun `a sync releases its lock when it finishes`() = runTest { assertReleasesAfterRunning(syncLock) { worker().doWork() } } + + @Test + fun `a media sync releases its lock when it finishes`() = + runTest { assertReleasesAfterRunning(mediaSyncLock) { worker().doWork() } } +} From 211227362fbd1ea118117de66527e224db71ab78 Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Sat, 12 Sep 2026 02:29:45 +0530 Subject: [PATCH 26/50] feat(profiles): restart the app after closing the collection A profile switch cannot be applied in place: the backend, WebView and SharedPreferences all cache paths from the profile that was active when the process started, so the process has to die and come back. The restart takes the sync lock first, waiting for a sync that is already running, since that sync queues a media sync before it lets go of its lock. Sync work is then cancelled, since it carries this profile's AnkiWeb key, which also stops a running media sync rather than waiting it out. The media sync and backup locks come next. All three are kept until ProcessPhoenix kills the process, so nothing can start in the meantime, and released if the handover fails. The collection is then closed: the backend holds it open, and killing the process mid-write risks corrupting it. ensureClosed runs on the collection queue, so it also waits for an operation that is already running. Assisted-by: Claude Opus 5 (multiple iterations over original code) --- .../com/ichi2/anki/multiprofile/AppRestart.kt | 48 +++++ .../ichi2/anki/multiprofile/AppRestartTest.kt | 178 ++++++++++++++++++ .../ichi2/anki/multiprofile/WaitingWork.kt | 41 ++++ 3 files changed, 267 insertions(+) create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/WaitingWork.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/AppRestart.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/AppRestart.kt index ea1ed41b88bb..3618819e56f2 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/AppRestart.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/AppRestart.kt @@ -3,9 +3,57 @@ package com.ichi2.anki.multiprofile +import android.content.Context import androidx.annotation.VisibleForTesting +import androidx.work.WorkManager +import androidx.work.await +import com.ichi2.anki.CollectionManager +import com.ichi2.anki.backupLock +import com.ichi2.anki.common.destinations.LauncherDestination +import com.ichi2.anki.toIntent +import com.ichi2.anki.worker.UniqueWorkNames +import com.ichi2.anki.worker.mediaSyncLock +import com.ichi2.anki.worker.syncLock +import com.jakewharton.processphoenix.ProcessPhoenix +import kotlinx.coroutines.sync.Mutex +import timber.log.Timber import java.io.File +/** + * Restarts the app once background work has finished and the collection is closed. + * + * Sync work is cancelled once the sync lock is held, since it carries the current + * profile's AnkiWeb key. The work locks stay held until ProcessPhoenix kills this + * process, so nothing can start and reopen the collection in the meantime. + */ +suspend fun safeRestartApp(context: Context) { + Timber.i("Restarting the app") + syncLock.holdUnlessThrows { + WorkManager.getInstance(context).run { + cancelUniqueWork(UniqueWorkNames.SYNC).await() + cancelUniqueWork(UniqueWorkNames.SYNC_MEDIA).await() + } + mediaSyncLock.holdUnlessThrows { + backupLock.holdUnlessThrows { + CollectionManager.ensureClosed() + ProcessPhoenix.triggerRebirth(context, LauncherDestination.toIntent(context)) + } + } + } +} + +/** Holds this lock while [block] runs, and keeps holding it unless [block] throws. */ +private suspend inline fun Mutex.holdUnlessThrows(block: () -> Unit) { + lock() + var returned = false + try { + block() + returned = true + } finally { + if (!returned) unlock() + } +} + /** True in the short-lived ProcessPhoenix process, which only relaunches the app. */ fun isPhoenixProcess(): Boolean = isPhoenixProcessName(currentProcessName()) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/AppRestartTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/AppRestartTest.kt index 14a8a418239d..5f9388cd7501 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/AppRestartTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/AppRestartTest.kt @@ -3,15 +3,193 @@ package com.ichi2.anki.multiprofile +import android.app.Application +import android.content.Context +import android.content.ContextWrapper +import android.content.Intent +import androidx.core.content.IntentCompat +import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.work.WorkInfo +import com.ichi2.anki.CollectionManager +import com.ichi2.anki.IntentHandler import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.backupLock +import com.ichi2.anki.worker.UniqueWorkNames +import com.ichi2.anki.worker.mediaSyncLock +import com.ichi2.anki.worker.syncLock +import com.jakewharton.processphoenix.PhoenixActivity +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After import org.junit.Test import org.junit.runner.RunWith +import org.robolectric.Shadows.shadowOf +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertNull import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) class AppRestartTest : RobolectricTest() { + private val context: Context get() = ApplicationProvider.getApplicationContext() + + private val backgroundWorkLocks get() = listOf(syncLock, mediaSyncLock, backupLock) + + @After + override fun tearDown() { + backgroundWorkLocks.filter { it.isLocked }.forEach { it.unlock() } + super.tearDown() + } + + private fun startedActivities() = shadowOf(context as Application) + + /** The intent ProcessPhoenix relaunches once it has killed this process. */ + private fun relaunchIntent(): Intent? { + val phoenix = startedActivities().nextStartedActivity ?: return null + assertEquals(PhoenixActivity::class.java.name, phoenix.component?.className) + return IntentCompat.getParcelableArrayListExtra(phoenix, "phoenix_restart_intents", Intent::class.java)?.single() + } + + @Test + fun `restart relaunches the launcher in a cleared task through ProcessPhoenix`() = + runTest { + safeRestartApp(context) + + val intent = assertNotNull(relaunchIntent(), "ProcessPhoenix must be handed the relaunch") + assertEquals(IntentHandler::class.java.name, intent.component?.className) + assertTrue(intent.categories.contains(Intent.CATEGORY_LAUNCHER)) + assertTrue(intent.flags and Intent.FLAG_ACTIVITY_CLEAR_TASK != 0, "the old profile's activities must not survive") + } + + @Test + fun `restart closes the collection before handing over to ProcessPhoenix`() = + runTest { + CollectionManager.ensureOpen() + + safeRestartApp(context) + + assertFalse(CollectionManager.isOpenUnsafe(), "the process is killed next, and a write in flight could corrupt it") + } + + @Test + fun `restart cancels sync work queued for the profile being left`() = + runTest { + context.enqueueWaiting(UniqueWorkNames.SYNC) + context.enqueueWaiting(UniqueWorkNames.SYNC_MEDIA) + + safeRestartApp(context) + + assertEquals(WorkInfo.State.CANCELLED, context.workState(UniqueWorkNames.SYNC), "it carries this profile's AnkiWeb key") + assertEquals(WorkInfo.State.CANCELLED, context.workState(UniqueWorkNames.SYNC_MEDIA), "it carries this profile's AnkiWeb key") + } + + @Test + fun `restart cancels a media sync enqueued by a sync that is still finishing`() = + runTest { + syncLock.lock() + val restart = launch { safeRestartApp(context) } + advanceUntilIdle() + + // SyncWorker enqueues the media sync before it releases syncLock + context.enqueueWaiting(UniqueWorkNames.SYNC_MEDIA) + + syncLock.unlock() + restart.join() + + assertEquals( + WorkInfo.State.CANCELLED, + context.workState(UniqueWorkNames.SYNC_MEDIA), + "it carries this profile's AnkiWeb key", + ) + } + + @Test + fun `restart cancels a media sync instead of waiting for it to finish`() = + runTest { + mediaSyncLock.lock() + context.enqueueWaiting(UniqueWorkNames.SYNC_MEDIA) + val restart = launch { safeRestartApp(context) } + advanceUntilIdle() + + assertEquals( + WorkInfo.State.CANCELLED, + context.workState(UniqueWorkNames.SYNC_MEDIA), + "otherwise the restart sits through a media sync it is about to cancel", + ) + + mediaSyncLock.unlock() + restart.join() + } + + @Test + fun `a cancelled restart releases only the locks it took`() = + runTest { + backupLock.lock() + val restart = launch { safeRestartApp(context) } + advanceUntilIdle() + + restart.cancelAndJoin() + + assertFalse(syncLock.isLocked, "a cancelled restart must not leave syncing blocked") + assertFalse(mediaSyncLock.isLocked, "a cancelled restart must not leave media syncing blocked") + assertTrue(backupLock.isLocked, "the backup that is still writing holds it") + } + + @Test + fun `restart keeps the locks until the process is killed`() = + runTest { + safeRestartApp(context) + + backgroundWorkLocks.forEach { assertTrue(it.isLocked, "work must not start before ProcessPhoenix kills the process") } + } + + @Test + fun `restart releases the locks if it cannot hand over to ProcessPhoenix`() = + runTest { + val refusesActivities = + object : ContextWrapper(context) { + override fun startActivity(intent: Intent) = throw IllegalStateException("cannot start activities") + } + + assertFailsWith { safeRestartApp(refusesActivities) } + + backgroundWorkLocks.forEach { assertFalse(it.isLocked, "a failed restart must not leave sync and backup blocked") } + } + + private suspend fun TestScope.assertRestartWaitsFor( + lock: Mutex, + why: String, + ) { + lock.lock() + val restart = launch { safeRestartApp(context) } + + advanceUntilIdle() + assertNull(startedActivities().peekNextStartedActivity(), why) + + lock.unlock() + restart.join() + assertNotNull(relaunchIntent(), "the restart must go ahead once the work has finished") + } + + @Test + fun `restart waits for a backup that is still writing`() = + runTest { assertRestartWaitsFor(backupLock, "killing the process mid-write can leave a truncated backup") } + + @Test + fun `restart waits for a sync that started after the checks passed`() = + runTest { assertRestartWaitsFor(syncLock, "a sync can start between the checks and the restart") } + + @Test + fun `restart waits for a media sync that started after the checks passed`() = + runTest { assertRestartWaitsFor(mediaSyncLock, "a media sync can start between the checks and the restart") } + @Test fun `only the ProcessPhoenix process is recognised as it`() { assertTrue(isPhoenixProcessName("com.ichi2.anki:phoenix")) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/WaitingWork.kt b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/WaitingWork.kt new file mode 100644 index 000000000000..ff376dc0ebbc --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/WaitingWork.kt @@ -0,0 +1,41 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 Ashish Yadav + +package com.ichi2.anki.multiprofile + +import android.content.Context +import androidx.work.Constraints +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkInfo +import androidx.work.WorkManager +import androidx.work.Worker +import androidx.work.WorkerParameters + +class NeverRuns( + context: Context, + params: WorkerParameters, +) : Worker(context, params) { + override fun doWork(): Result = Result.success() +} + +/** Queues work that waits on a constraint, as a sync does while offline. */ +fun Context.enqueueWaiting(uniqueWorkName: String) { + WorkManager + .getInstance(this) + .enqueueUniqueWork( + uniqueWorkName, + ExistingWorkPolicy.KEEP, + OneTimeWorkRequestBuilder() + .setConstraints(Constraints.Builder().setRequiresCharging(true).build()) + .build(), + ) +} + +fun Context.workState(uniqueWorkName: String): WorkInfo.State? = + WorkManager + .getInstance(this) + .getWorkInfosForUniqueWork(uniqueWorkName) + .get() + .singleOrNull() + ?.state From 54cd8d4cbc6764a946e72f15b5dda2fe495d692f Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Thu, 10 Sep 2026 19:34:32 +0530 Subject: [PATCH 27/50] fix(profiles): persist a profile switch synchronously A switch is followed by a process restart, and SharedPreferences.apply() writes to disk asynchronously. Android does not flush pending apply() writes when the process is killed, so the new profile id could be lost and the app would come back on the old profile. Commit the switch write. Startup keeps the asynchronous write, since nothing kills the process after it. --- .../ichi2/anki/multiprofile/ProfileManager.kt | 10 ++-- .../anki/multiprofile/ProfileManagerTest.kt | 46 +++++++++++++++++++ 2 files changed, 53 insertions(+), 3 deletions(-) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt index ba76d37ddebe..683525a8d8bc 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileManager.kt @@ -145,7 +145,8 @@ class ProfileManager private constructor( context(_: ProfileSwitchContext) fun switchActiveProfile(newProfileId: ProfileId) { Timber.i("Switching profile to ID: $newProfileId") - profileRegistry.setLastActiveProfileId(newProfileId) + // commit, not apply: the app restarts straight after a switch, and a pending apply() would be lost + profileRegistry.setLastActiveProfileId(newProfileId, commit = true) } private fun loadProfileData(profileId: ProfileId) { @@ -580,8 +581,11 @@ class ProfileManager private constructor( return id?.let { ProfileId(it) } } - fun setLastActiveProfileId(id: ProfileId) { - globalPrefs.edit { putString(KEY_LAST_ACTIVE_PROFILE_ID, id.value) } + fun setLastActiveProfileId( + id: ProfileId, + commit: Boolean = false, + ) { + globalPrefs.edit(commit = commit) { putString(KEY_LAST_ACTIVE_PROFILE_ID, id.value) } } fun getWebViewProfileId(): ProfileId? { diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileManagerTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileManagerTest.kt index 000364a20030..0bfb264d80c9 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileManagerTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileManagerTest.kt @@ -271,6 +271,27 @@ class ProfileManagerTest { ) } + @Test + fun `switching the profile persists synchronously so it survives the process kill`() { + val registry = RecordingPreferences(context.getSharedPreferences(PROFILE_REGISTRY_FILENAME, Context.MODE_PRIVATE)) + val wrapper = + object : ContextWrapper(context) { + override fun getApplicationContext(): Context? = null + + override fun getSharedPreferences( + name: String, + mode: Int, + ): SharedPreferences = if (name == PROFILE_REGISTRY_FILENAME) registry else super.getSharedPreferences(name, mode) + } + val manager = ProfileManager.create(wrapper) + registry.editors.clear() + + with(ProfileManager.ProfileSwitchContext) { manager.switchActiveProfile(ProfileId("p_other")) } + + assertEquals("apply() is async and is lost when the process is killed", 1, registry.editors.sumOf { it.commits }) + assertEquals(0, registry.editors.sumOf { it.applies }) + } + @Test fun `ProfileId DEFAULT must be strictly 'default' to preserve legacy compatibility`() { assertEquals("default", ProfileId.DEFAULT.value) @@ -645,6 +666,31 @@ class ProfileManagerTest { } /** Stubs [CookieManager.getInstance]. [completesRemoval] fires the cookie removal callback. */ + private class RecordingEditor( + private val delegate: SharedPreferences.Editor, + ) : SharedPreferences.Editor by delegate { + var commits = 0 + var applies = 0 + + override fun commit(): Boolean { + commits++ + return delegate.commit() + } + + override fun apply() { + applies++ + delegate.apply() + } + } + + private class RecordingPreferences( + private val delegate: SharedPreferences, + ) : SharedPreferences by delegate { + val editors = mutableListOf() + + override fun edit(): SharedPreferences.Editor = RecordingEditor(delegate.edit()).also { editors += it } + } + private fun mockCookieManager(completesRemoval: Boolean = true): CookieManager { mockkStatic(CookieManager::class) val cookieManager = mockk(relaxed = true) From 4baba72aacf25deae89ead8c2a931532c233372c Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Thu, 10 Sep 2026 21:35:18 +0530 Subject: [PATCH 28/50] feat(profiles): implement the profile switch safety checks --- .../anki/multiprofile/ProfileSwitchChecks.kt | 39 +++++++++ .../anki/multiprofile/ProfileSwitchGuard.kt | 4 + .../multiprofile/ProfileSwitchChecksTest.kt | 83 +++++++++++++++++++ 3 files changed, 126 insertions(+) create mode 100644 AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileSwitchChecks.kt create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileSwitchChecksTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileSwitchChecks.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileSwitchChecks.kt new file mode 100644 index 000000000000..d3dfee316827 --- /dev/null +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileSwitchChecks.kt @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 Ashish Yadav + +package com.ichi2.anki.multiprofile + +import com.ichi2.anki.backupLock +import com.ichi2.anki.multiprofile.ProfileSwitchGuard.BlockReason +import com.ichi2.anki.multiprofile.ProfileSwitchGuard.SafetyCheck +import com.ichi2.anki.worker.mediaSyncLock +import com.ichi2.anki.worker.syncLock +import kotlinx.coroutines.sync.Mutex + +/** + * Blocks a switch while the collection is syncing in the background. + * + * Only the background sync needs this: a foreground sync holds a modal progress + * dialog, so the user cannot reach the switch until it finishes. + */ +fun syncCheck() = lockCheck(syncLock, BlockReason.SYNC_IN_PROGRESS) + +/** Blocks a switch while media is syncing. */ +fun mediaSyncCheck() = lockCheck(mediaSyncLock, BlockReason.MEDIA_SYNC_IN_PROGRESS) + +/** Blocks a switch while a background backup is running. */ +fun backupCheck() = lockCheck(backupLock, BlockReason.BACKUP_IN_PROGRESS) + +/** + * Reads a lock that the work holds only while it runs, so work that is queued but + * waiting, such as a sync waiting for the network, does not block the switch: + * there is nothing to interrupt yet, and [safeRestartApp] holds the same lock, so the + * work cannot start part way through the restart. + * + * [safeRestartApp] waits for work that is still running, so a check exists to tell the + * user why the switch cannot happen yet rather than for safety. + */ +private fun lockCheck( + lock: Mutex, + reason: BlockReason, +) = SafetyCheck { reason.takeIf { lock.isLocked } } diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileSwitchGuard.kt b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileSwitchGuard.kt index 85adb854621a..f7ae4eee783f 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileSwitchGuard.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/multiprofile/ProfileSwitchGuard.kt @@ -43,8 +43,12 @@ class ProfileSwitchGuard( ) : Result() } + // TODO: COLLECTION_BUSY has no check yet: CollectionManager serialises on a + // private queue it does not expose. Closing the collection already waits for a + // running operation, so this is about warning the user rather than about safety. enum class BlockReason { BACKUP_IN_PROGRESS, + SYNC_IN_PROGRESS, MEDIA_SYNC_IN_PROGRESS, COLLECTION_BUSY, } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileSwitchChecksTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileSwitchChecksTest.kt new file mode 100644 index 000000000000..52f030dce063 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileSwitchChecksTest.kt @@ -0,0 +1,83 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// SPDX-FileCopyrightText: 2026 Ashish Yadav + +package com.ichi2.anki.multiprofile + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.backupLock +import com.ichi2.anki.multiprofile.ProfileSwitchGuard.BlockReason +import com.ichi2.anki.worker.UniqueWorkNames +import com.ichi2.anki.worker.mediaSyncLock +import com.ichi2.anki.worker.syncLock +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.test.assertEquals +import kotlin.test.assertNull + +@RunWith(AndroidJUnit4::class) +class ProfileSwitchChecksTest : RobolectricTest() { + private fun enqueueMediaSync() = targetContext.enqueueWaiting(UniqueWorkNames.SYNC_MEDIA) + + private fun enqueueSync() = targetContext.enqueueWaiting(UniqueWorkNames.SYNC) + + @Test + fun `no media sync means no reason to block`() = + runTest { + assertNull(mediaSyncCheck().verify()) + } + + @Test + fun `a media sync waiting for the network does not block the switch`() = + runTest { + enqueueMediaSync() + + assertNull(mediaSyncCheck().verify(), "queued work has nothing to interrupt") + } + + @Test + fun `no running backup means no reason to block`() = + runTest { + assertNull(backupCheck().verify()) + } + + @Test + fun `a running backup blocks the switch`() = + runTest { + backupLock.withLock { assertEquals(BlockReason.BACKUP_IN_PROGRESS, backupCheck().verify()) } + } + + @Test + fun `no background sync means no reason to block`() = + runTest { + assertNull(syncCheck().verify()) + } + + @Test + fun `a sync waiting for the network does not block the switch`() = + runTest { + enqueueSync() + + assertNull(syncCheck().verify(), "offline, a queued sync would block switching forever") + } + + @Test + fun `a running sync blocks the switch`() = + runTest { + syncLock.withLock { assertEquals(BlockReason.SYNC_IN_PROGRESS, syncCheck().verify()) } + } + + @Test + fun `a running media sync blocks the switch`() = + runTest { + mediaSyncLock.withLock { assertEquals(BlockReason.MEDIA_SYNC_IN_PROGRESS, mediaSyncCheck().verify()) } + } + + @Test + fun `a media sync does not count as a collection sync`() = + runTest { + mediaSyncLock.withLock { assertNull(syncCheck().verify(), "each check reports its own reason") } + } +} From 8b18055edf19910f0aee9932ebde6a428ac3636e Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:50:36 +0530 Subject: [PATCH 29/50] test: stop relying on instant background loads A few Card Browser, Previewer and Custom Study tests only passed because a leftover test dispatcher made background loads finish instantly. They now wait for the load, or run under runTest like the tests around them. Assisted-by: Claude Opus 5.5 --- .../java/com/ichi2/anki/CardBrowserTest.kt | 11 +- .../anki/dialogs/CustomStudyDialogTest.kt | 116 +++++++++--------- .../anki/previewer/PreviewerViewModelTest.kt | 4 + 3 files changed, 69 insertions(+), 62 deletions(-) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt index 5c2fb4767bd6..858506cc917c 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CardBrowserTest.kt @@ -43,6 +43,7 @@ import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.IntentHandler.Companion.grantedStoragePermissions import com.ichi2.anki.RobolectricTest.Companion.advanceRobolectricLooper +import com.ichi2.anki.RobolectricTest.Companion.advanceRobolectricLooperUntil import com.ichi2.anki.browser.BrowserColumnKey import com.ichi2.anki.browser.BrowserMultiColumnAdapter import com.ichi2.anki.browser.BrowserMultiColumnAdapter.Companion.LINES_VISIBLE_WHEN_COLLAPSED @@ -501,8 +502,8 @@ class CardBrowserTest : RobolectricTest() { } ActivityScenario.launch(deepLink).use { scenario -> - advanceRobolectricLooper() scenario.onActivity { browser -> + browser.waitForSearchResults() assertThat("the deep link's search is applied", browser.viewModel.searchTerms, equalTo("dog")) assertThat("only the matching note is shown", browser.viewModel.rowCount, equalTo(1)) } @@ -518,8 +519,8 @@ class CardBrowserTest : RobolectricTest() { fun CardBrowser.subtitle() = findViewById(R.id.subtitle).text.toString() ActivityScenario.launch(CardBrowser::class.java).use { scenario -> - advanceRobolectricLooper() scenario.onActivity { browser -> + browser.waitForSearchResults() assertThat("card count before recreation", browser.subtitle(), equalTo("2 cards shown")) } @@ -2271,11 +2272,11 @@ fun getBrowserWithNotes( test.addBasicNote(i.toString(), "back") } } - return test.startRegularActivity(Intent()).also { - advanceRobolectricLooper() // may be a fix for flaky tests - } + return test.startRegularActivity(Intent()).also { it.waitForSearchResults() } } +fun CardBrowser.waitForSearchResults() = advanceRobolectricLooperUntil { viewModel.searchJob?.isCompleted == true } + context(test: RobolectricTest) fun withCardBrowser( noteCount: Int, diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/CustomStudyDialogTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/CustomStudyDialogTest.kt index af0c6ffeb3ef..e5d245ec0356 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/CustomStudyDialogTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/dialogs/CustomStudyDialogTest.kt @@ -118,75 +118,77 @@ class CustomStudyDialogTest : RobolectricTest() { } @Test - fun `previous value for 'increase new card limit' is suggested`() { - // add cards to be sure we can extend successfully. Needs to be > 20 - repeat(23) { - addBasicNote() - } - val newExtendByValue = 1 + fun `previous value for 'increase new card limit' is suggested`() = + runTest { + // add cards to be sure we can extend successfully. Needs to be > 20 + repeat(23) { + addBasicNote() + } + val newExtendByValue = 1 - assertThat("'new' default value", defaultsOfDefaultDeck.extendNew.initialValue, equalTo(0)) + assertThat("'new' default value", defaultsOfDefaultDeck.extendNew.initialValue, equalTo(0)) - // extend limits with a value of '1' - withCustomStudyFragment( - args = argumentsDisplayingSubscreen(ContextMenuOption.EXTEND_NEW), - ) { dialogFragment: CustomStudyDialog -> + // extend limits with a value of '1' + withCustomStudyFragment( + args = argumentsDisplayingSubscreen(ContextMenuOption.EXTEND_NEW), + ) { dialogFragment: CustomStudyDialog -> - onSubscreenEditText() - .perform(replaceText(newExtendByValue.toString())) + onSubscreenEditText() + .perform(replaceText(newExtendByValue.toString())) - dialogFragment.submitSubscreenData() - } + dialogFragment.submitSubscreenData() + } - // ensure backend is updated - assertThat( - "'new' updated value", - defaultsOfDefaultDeck.extendNew.initialValue, - equalTo(newExtendByValue), - ) + // ensure backend is updated + assertThat( + "'new' updated value", + defaultsOfDefaultDeck.extendNew.initialValue, + equalTo(newExtendByValue), + ) - // ensure 'newExtendByValue' is used by our UI - withCustomStudyFragment( - args = argumentsDisplayingSubscreen(ContextMenuOption.EXTEND_NEW), - ) { - onSubscreenEditText() - .check(matches(withText(newExtendByValue.toString()))) + // ensure 'newExtendByValue' is used by our UI + withCustomStudyFragment( + args = argumentsDisplayingSubscreen(ContextMenuOption.EXTEND_NEW), + ) { + onSubscreenEditText() + .check(matches(withText(newExtendByValue.toString()))) + } } - } @Test - fun `previous value for 'increase review card limit' is suggested`() { - // Reduce review limit to 0, so we can successfully extend with just 1 review card. - updateDeckConfig(Consts.DEFAULT_DECK_ID) { rev.perDay = 0 } - addRevBasicNoteDueToday("Review", "Today") - - val reviewExtendByValue = 1 - assertThat("'review' default value", defaultsOfDefaultDeck.extendReview.initialValue, equalTo(0)) - - // Extend reviews by 'reviewExtendByValue'. - withCustomStudyFragment( - args = argumentsDisplayingSubscreen(ContextMenuOption.EXTEND_REV), - ) { dialogFragment: CustomStudyDialog -> - onSubscreenEditText() - .perform(replaceText(reviewExtendByValue.toString())) - dialogFragment.submitSubscreenData() - } + fun `previous value for 'increase review card limit' is suggested`() = + runTest { + // Reduce review limit to 0, so we can successfully extend with just 1 review card. + updateDeckConfig(Consts.DEFAULT_DECK_ID) { rev.perDay = 0 } + addRevBasicNoteDueToday("Review", "Today") - // Ensure backend is updated. - assertThat( - "'review' updated value", - defaultsOfDefaultDeck.extendReview.initialValue, - equalTo(reviewExtendByValue), - ) + val reviewExtendByValue = 1 + assertThat("'review' default value", defaultsOfDefaultDeck.extendReview.initialValue, equalTo(0)) - // Ensure 'reviewExtendByValue' is used in our UI. - withCustomStudyFragment( - args = argumentsDisplayingSubscreen(ContextMenuOption.EXTEND_REV), - ) { - onSubscreenEditText() - .check(matches(withText(reviewExtendByValue.toString()))) + // Extend reviews by 'reviewExtendByValue'. + withCustomStudyFragment( + args = argumentsDisplayingSubscreen(ContextMenuOption.EXTEND_REV), + ) { dialogFragment: CustomStudyDialog -> + onSubscreenEditText() + .perform(replaceText(reviewExtendByValue.toString())) + dialogFragment.submitSubscreenData() + } + + // Ensure backend is updated. + assertThat( + "'review' updated value", + defaultsOfDefaultDeck.extendReview.initialValue, + equalTo(reviewExtendByValue), + ) + + // Ensure 'reviewExtendByValue' is used in our UI. + withCustomStudyFragment( + args = argumentsDisplayingSubscreen(ContextMenuOption.EXTEND_REV), + ) { + onSubscreenEditText() + .check(matches(withText(reviewExtendByValue.toString()))) + } } - } @Test fun `creating a tags custom session uses selected card state`() = diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/previewer/PreviewerViewModelTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/previewer/PreviewerViewModelTest.kt index 783fa5fe9c9d..d0ed6575e627 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/previewer/PreviewerViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/previewer/PreviewerViewModelTest.kt @@ -13,8 +13,10 @@ import io.mockk.coEvery import io.mockk.spyk import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.withTimeout import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse @@ -22,6 +24,7 @@ import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith +import kotlin.time.Duration.Companion.seconds @RunWith(AndroidJUnit4::class) class PreviewerViewModelTest : JvmTest() { @@ -64,6 +67,7 @@ class PreviewerViewModelTest : JvmTest() { // the default implementation requires the Collection media directory, // which needs Robolectric with CollectionStorageMode.IN_MEMORY_WITH_MEDIA or ON_DISK coEvery { viewModel.prepareCardTextForDisplay(any()) } answers { firstArg() } + runBlocking { withTimeout(10.seconds) { viewModel.currentCard.await() } } } @After From 8f084393b2e9c5c569e1f188168de1df75105f4b Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:50:46 +0530 Subject: [PATCH 30/50] fix(tests): reset ioDispatcher after each test runTest swapped ioDispatcher for a test dispatcher and never put it back, so later tests in the same fork ran background work on the main thread. That hid races like 22271 until a test happened to run first. Reset it in tearDown, next to resetMain(). Assisted-by: Claude Opus 5.5 --- .../java/com/ichi2/anki/RobolectricTest.kt | 1 + .../ichi2/testutils/IoDispatcherResetTest.kt | 44 +++++++++++++++++++ .../test/java/com/ichi2/testutils/JvmTest.kt | 12 +++++ 3 files changed, 57 insertions(+) create mode 100644 AnkiDroid/src/test/java/com/ichi2/testutils/IoDispatcherResetTest.kt diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt index ff9bf5eed72f..9801cf68bb1b 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt @@ -229,6 +229,7 @@ open class RobolectricTest : } WorkManagerTestInitHelper.closeWorkDatabase() Dispatchers.resetMain() + ioDispatcher = Dispatchers.IO runBlocking { CollectionManager.discardBackend() } val pendingMethods = OnlyOnce.pendingMethods Assert.assertTrue( diff --git a/AnkiDroid/src/test/java/com/ichi2/testutils/IoDispatcherResetTest.kt b/AnkiDroid/src/test/java/com/ichi2/testutils/IoDispatcherResetTest.kt new file mode 100644 index 000000000000..c009a55cf814 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/testutils/IoDispatcherResetTest.kt @@ -0,0 +1,44 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.testutils + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.ioDispatcher +import kotlinx.coroutines.Dispatchers +import org.junit.Ignore +import org.junit.Test +import org.junit.runner.JUnitCore +import org.junit.runner.RunWith +import org.junit.runners.BlockJUnit4ClassRunner +import kotlin.test.assertEquals +import kotlin.test.assertSame + +@RunWith(AndroidJUnit4::class) +class IoDispatcherResetTest { + @Test + fun `RobolectricTest resets ioDispatcher after runTest`() = assertResetsIoDispatcher(RobolectricRunTest::class.java) + + @Test + fun `JvmTest resets ioDispatcher after runTest`() = assertResetsIoDispatcher(JvmRunTest::class.java) + + private fun assertResetsIoDispatcher(testClass: Class<*>) { + val result = JUnitCore().run(BlockJUnit4ClassRunner(testClass)) + + assertEquals(1, result.runCount) + assertEquals(0, result.failureCount, result.failures.toString()) + assertSame(Dispatchers.IO, ioDispatcher) + } + + @Ignore("Run explicitly by the enclosing test inside its Robolectric sandbox") + class RobolectricRunTest : RobolectricTest() { + @Test + fun `uses runTest`() = runTest { } + } + + @Ignore("Run explicitly by the enclosing test inside its Robolectric sandbox") + class JvmRunTest : JvmTest() { + @Test + fun `uses runTest`() = runTest { } + } +} diff --git a/AnkiDroid/src/test/java/com/ichi2/testutils/JvmTest.kt b/AnkiDroid/src/test/java/com/ichi2/testutils/JvmTest.kt index dc8429bde3dc..d5f0aef5eb2d 100644 --- a/AnkiDroid/src/test/java/com/ichi2/testutils/JvmTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/testutils/JvmTest.kt @@ -10,7 +10,9 @@ import com.ichi2.anki.libanki.testutils.InMemoryAnkiTest import com.ichi2.anki.observability.ChangeManager import com.ichi2.testutils.common.IgnoreFlakyTestsInCIRule import com.ichi2.testutils.rules.CollectionStorageRule +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.test.TestDispatcher +import org.junit.After import org.junit.Before import org.junit.Rule @@ -33,6 +35,16 @@ open class JvmTest : InMemoryAnkiTest() { ChangeManager.resetForTesting() } + @After + @CallSuper + override fun tearDown() { + try { + super.tearDown() + } finally { + ioDispatcher = Dispatchers.IO + } + } + override fun setupTestDispatcher(dispatcher: TestDispatcher) { super.setupTestDispatcher(dispatcher) ioDispatcher = dispatcher From 35e1439a1d54f7909db0b6c6a52ebb78a9fe0cfb Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 15 Aug 2026 13:19:57 +0100 Subject: [PATCH 31/50] fix: explain 'Anki already open' errors The backend string: 'Anki already open, or media currently syncing' doesn't explain this on Android - the other app likely launched on boot, (and upstream is much less likely to have parallel apps). In AnkiDroid, it is not possible for 'media syncing' to be the cause of this error. CollectionManager now rethrows `BackendDbLockedException` as `CollectionLockedException`, with better user-facing guidance. Also: * a locked collection no longer files a crash report * the login screen shows the guidance in a dialog rather than truncating it in a snackbar Fixes 21051 GPT-6 resolved the rebase conflict and updated the locked-collection message references to CommonString. GPT-6 also updated locked-collection exception assertions and registered the error dialog test activity for cleanup. Assisted-by: Claude Fable 5 Assisted-by: GPT-6 --- .../main/java/com/ichi2/anki/AnkiDroidApp.kt | 4 ++ .../java/com/ichi2/anki/CoroutineHelpers.kt | 5 +- .../java/com/ichi2/anki/InitialActivity.kt | 3 +- .../ichi2/anki/dialogs/DatabaseErrorDialog.kt | 2 +- AnkiDroid/src/main/res/values/03-dialogs.xml | 2 +- .../com/ichi2/anki/CollectionLeaseTest.kt | 4 +- .../ichi2/anki/CollectionManagerImportTest.kt | 4 +- .../anki/CollectionManagerStackTraceTest.kt | 5 +- .../ichi2/anki/CollectionStorageModeTest.kt | 6 +-- .../anki/CoroutineHelpersRobolectricTest.kt | 49 +++++++++++++++++++ .../java/com/ichi2/anki/RobolectricTest.kt | 2 +- .../anki/tags/ManageTagsViewModelTest.kt | 8 +-- .../BackendEmulatingOpenConflictTest.kt | 4 +- .../com/ichi2/anki/CollectionManager.kt | 26 ++++++---- .../exception/CollectionLockedException.kt | 37 ++++++++++++++ 15 files changed, 132 insertions(+), 29 deletions(-) create mode 100644 anki-common/src/main/kotlin/com/ichi2/anki/exception/CollectionLockedException.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt index e059c7f0c37b..c1581619b3d2 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/AnkiDroidApp.kt @@ -43,6 +43,7 @@ import com.ichi2.anki.common.utils.annotation.KotlinCleanup import com.ichi2.anki.compat.CompatHelper import com.ichi2.anki.contextmenu.AnkiCardContextMenu import com.ichi2.anki.contextmenu.CardBrowserContextMenu +import com.ichi2.anki.exception.CollectionLockedException import com.ichi2.anki.exception.StorageAccessException import com.ichi2.anki.exception.SystemStorageException import com.ichi2.anki.logging.FragmentLifecycleLogger @@ -187,6 +188,9 @@ open class AnkiDroidApp : initializeWidgetRepository() WidgetNotificationScheduler.register { scheduleNotification() } Animations.setPreferencesProvider { context -> PrefsRepository(context) } + CollectionLockedException.messageProvider = { + getString(CommonString.database_locked_summary_new, getString(CommonString.col_path)) + } val logType = LogType.value when (logType) { LogType.DEBUG -> Timber.plant(DebugTree()) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt b/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt index 058b76403b1d..def212ffbe8c 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/CoroutineHelpers.kt @@ -33,6 +33,7 @@ import com.ichi2.anki.common.crashreporting.CrashReportService import com.ichi2.anki.common.destinations.DeckOptionsDestination import com.ichi2.anki.dialogs.DatabaseErrorDialog import com.ichi2.anki.dialogs.DatabaseErrorDialog.DatabaseErrorDialogType +import com.ichi2.anki.exception.CollectionLockedException import com.ichi2.anki.exception.StorageAccessException import com.ichi2.anki.exception.StorageNotConfiguredException import com.ichi2.anki.pages.toIntent @@ -163,7 +164,9 @@ suspend fun FragmentActivity.runCatching( Timber.w(exc, errorMessage) if (!isFinishing) redirectToMainEntryPoint() } - is BackendNetworkException, is BackendSyncException, is StorageAccessException, is BackendCardTypeException -> { + is BackendNetworkException, is BackendSyncException, is StorageAccessException, is BackendCardTypeException, + is CollectionLockedException, + -> { // these exceptions do not generate worthwhile crash reports Timber.i("Showing error dialog but not sending a crash report.") showError(exc.localizedMessage!!, exc.toCrashReportData(this, reportException = false)) diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt b/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt index 73df41266d34..5cfd63d28f17 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/InitialActivity.kt @@ -24,6 +24,7 @@ import com.ichi2.anki.common.storage.StorageDecision import com.ichi2.anki.common.storage.isLegacyStorage import com.ichi2.anki.common.utils.android.SdCard import com.ichi2.anki.compat.CompatHelper.Companion.sdkVersion +import com.ichi2.anki.exception.CollectionLockedException import com.ichi2.anki.exception.StorageAccessException import com.ichi2.anki.servicelayer.PreferenceUpgradeService import com.ichi2.anki.servicelayer.PreferenceUpgradeService.setPreferencesUpToDate @@ -69,7 +70,7 @@ object InitialActivity { try { CollectionManager.getColUnsafe() return null - } catch (e: BackendException.BackendDbException.BackendDbLockedException) { + } catch (e: CollectionLockedException) { Timber.w(e) StartupFailure.DatabaseLocked } catch (e: BackendException.BackendDbException.BackendDbFileTooNewException) { diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DatabaseErrorDialog.kt b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DatabaseErrorDialog.kt index ce92f5375bfc..3c2aae249baf 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DatabaseErrorDialog.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/dialogs/DatabaseErrorDialog.kt @@ -585,7 +585,7 @@ class DatabaseErrorDialog : AsyncDialogFragment() { DIALOG_CONFIRM_DATABASE_CHECK -> res().getString(CommonString.check_db_warning) DIALOG_CONFIRM_RESTORE_BACKUP -> res().getString(CommonString.restore_backup) DIALOG_ONE_WAY_SYNC_FROM_SERVER -> res().getString(CommonString.backup_full_sync_from_server_question) - DIALOG_DB_LOCKED -> res().getString(CommonString.database_locked_summary) + DIALOG_DB_LOCKED -> res().getString(CommonString.database_locked_summary_new, res().getString(CommonString.col_path)) INCOMPATIBLE_DB_VERSION -> { var databaseVersion = -1 try { diff --git a/AnkiDroid/src/main/res/values/03-dialogs.xml b/AnkiDroid/src/main/res/values/03-dialogs.xml index d028635b97ae..a7b1a4124c4f 100644 --- a/AnkiDroid/src/main/res/values/03-dialogs.xml +++ b/AnkiDroid/src/main/res/values/03-dialogs.xml @@ -119,7 +119,7 @@ Database Locked - The AnkiDroid database is in use by another application. Please close the other application then reopen AnkiDroid. + Another AnkiDroid app is using this collection. It may conflict even when it is not visibly running.\n\nTo keep using both apps, change the ‘%s’ in one app\'s Advanced settings.\n\nTo use this app now, force-stop the conflicting app. Incompatible Database Version diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionLeaseTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionLeaseTest.kt index 8282cf475a3c..8c56fa4b4368 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionLeaseTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionLeaseTest.kt @@ -8,6 +8,7 @@ import com.ichi2.anki.CollectionManager.tryWithCol import com.ichi2.anki.CollectionManager.withCol import com.ichi2.anki.CollectionManager.withColExclusive import com.ichi2.anki.CollectionManager.withLeaseForTest +import com.ichi2.anki.exception.CollectionLockedException import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope @@ -19,7 +20,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import net.ankiweb.rsdroid.Backend -import net.ankiweb.rsdroid.BackendException import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.equalTo import org.hamcrest.Matchers.notNullValue @@ -395,7 +395,7 @@ class CollectionLeaseTest : RobolectricTest() { runTest { CollectionManager.emulatedOpenFailure = CollectionManager.CollectionOpenFailure.LOCKED try { - assertFailsWith { + assertFailsWith { withColExclusive(CollectionOperation.SYNC) { error("collection could not open") } } assertThat(collectionLease, nullValue()) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerImportTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerImportTest.kt index 16a6eef4aea0..a37280942234 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerImportTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerImportTest.kt @@ -5,8 +5,8 @@ package com.ichi2.anki import androidx.core.content.edit import androidx.test.ext.junit.runners.AndroidJUnit4 import com.ichi2.anki.common.storage.CollectionHelper +import com.ichi2.anki.exception.CollectionLockedException import net.ankiweb.rsdroid.BackendException -import net.ankiweb.rsdroid.BackendException.BackendDbException.BackendDbLockedException import net.ankiweb.rsdroid.BackendException.BackendImportException import net.ankiweb.rsdroid.exceptions.BackendIoException import net.ankiweb.rsdroid.exceptions.BackendSyncException @@ -66,7 +66,7 @@ class CollectionManagerImportTest : RobolectricTest() { val failure = assertFailsWith { CollectionManager.importColpkg(backup.path) } assertEquals(expectedMessage, failure.localizedMessage) - assertIs(failure.suppressed.single()) + assertIs(failure.suppressed.single()) } finally { CollectionManager.emulatedOpenFailure = null } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerStackTraceTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerStackTraceTest.kt index 1cf6d04952cd..7c3c407fa678 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerStackTraceTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionManagerStackTraceTest.kt @@ -3,12 +3,14 @@ package com.ichi2.anki import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.exception.CollectionLockedException import kotlinx.coroutines.test.StandardTestDispatcher import net.ankiweb.rsdroid.BackendException.BackendDbException.BackendDbLockedException import org.junit.Test import org.junit.runner.RunWith import kotlin.test.assertFailsWith import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertTrue @RunWith(AndroidJUnit4::class) @@ -22,8 +24,9 @@ class CollectionManagerStackTraceTest : RobolectricTest() { try { CollectionManager.emulatedOpenFailure = CollectionManager.CollectionOpenFailure.LOCKED - val failure = assertFailsWith { requestCollection() } + val failure = assertFailsWith { requestCollection() } + assertIs(failure.cause) assertFalse(failure.stackTrace.any { it.methodName == "requestCollection" }) val caller = failure.suppressed.single() assertTrue(caller.stackTrace.any { it.methodName == "requestCollection" }) diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionStorageModeTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionStorageModeTest.kt index 85e4729ed17d..83f638bfed51 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CollectionStorageModeTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CollectionStorageModeTest.kt @@ -3,9 +3,9 @@ package com.ichi2.anki import com.ichi2.anki.CollectionManager.withCol +import com.ichi2.anki.exception.CollectionLockedException import com.ichi2.anki.libanki.Collection import com.ichi2.anki.libanki.CollectionFiles -import net.ankiweb.rsdroid.BackendException.BackendDbException.BackendDbLockedException import org.hamcrest.MatcherAssert.assertThat import org.hamcrest.Matchers.equalTo import org.hamcrest.Matchers.instanceOf @@ -51,8 +51,8 @@ class CollectionStorageModeTest( fun `test access respects collection opening failures`() = runTest { withNullCollection { - assertThat(runCatching { col }.exceptionOrNull(), instanceOf(BackendDbLockedException::class.java)) - assertThat(runCatching { withCol { this } }.exceptionOrNull(), instanceOf(BackendDbLockedException::class.java)) + assertThat(runCatching { col }.exceptionOrNull(), instanceOf(CollectionLockedException::class.java)) + assertThat(runCatching { withCol { this } }.exceptionOrNull(), instanceOf(CollectionLockedException::class.java)) } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/CoroutineHelpersRobolectricTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/CoroutineHelpersRobolectricTest.kt index 49c7d798aacd..64adb0414879 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/CoroutineHelpersRobolectricTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/CoroutineHelpersRobolectricTest.kt @@ -4,7 +4,13 @@ package com.ichi2.anki import androidx.fragment.app.FragmentActivity import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.ichi2.anki.CollectionManager.withCol +import com.ichi2.anki.exception.CollectionLockedException import com.ichi2.anki.exception.StorageNotConfiguredException +import com.ichi2.testutils.BackendEmulatingOpenConflict +import kotlinx.coroutines.test.runTest +import org.hamcrest.CoreMatchers.containsString +import org.hamcrest.MatcherAssert.assertThat import org.junit.Test import org.junit.runner.RunWith import org.robolectric.Robolectric @@ -34,4 +40,47 @@ class CoroutineHelpersRobolectricTest : RobolectricTest() { assertEquals(IntentHandler::class.qualifiedName, redirect.component?.className) } } + + /** + * #21051: the collection lock is normally held by a second AnkiDroid install sharing the + * AnkiDroid folder. The backend's 'Anki already open' text doesn't explain this on Android, + * where the other app is invisible: [CollectionLockedException] carries guidance naming the + * likely cause instead. + */ + @Test + fun `launchCatchingTask explains a locked collection`() = + withLockedCollection { + throwOnShowError = false + val controller = Robolectric.buildActivity(FragmentActivity::class.java).also(::saveControllerForCleanup) + val activity = controller.create().get() + activity.setTheme(R.style.Theme_Light) + + activity.launchCatchingTask { withCol { } } + advanceRobolectricLooper() + + assertThat(getAlertDialogText(true), containsString("Advanced settings")) + } + + /** See `launchCatchingTask explains a locked collection`: the ViewModel error funnel */ + @Test + fun `launchCatching explains a locked collection`() = + withLockedCollection { + runTest { + var message: String? = null + + launchCatching(errorMessageHandler = { message = it }) { withCol { } }.join() + + assertThat(message, containsString("Advanced settings")) + } + } + + /** Emulates #21051: another AnkiDroid install holds the collection lock */ + private fun withLockedCollection(block: () -> Unit) { + BackendEmulatingOpenConflict.enable() + try { + block() + } finally { + BackendEmulatingOpenConflict.disable() + } + } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt index 9801cf68bb1b..9029e33af1de 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/RobolectricTest.kt @@ -386,7 +386,7 @@ open class RobolectricTest : } /** - * Emulates a null collection and a `BackendDbLockedException` while [block] runs, + * Emulates a null collection and a `CollectionLockedException` while [block] runs, * restoring normal collection behavior afterwards. * * @see enableNullCollection 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 bbcc1c86f0d9..3bc1d5ba277e 100644 --- a/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/anki/tags/ManageTagsViewModelTest.kt @@ -7,6 +7,7 @@ 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.exception.CollectionLockedException import com.ichi2.anki.observability.ensureOpsExecuted import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.cancelAndJoin @@ -14,7 +15,6 @@ 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 @@ -167,7 +167,7 @@ class ManageTagsViewModelTest : RobolectricTest() { assertThat(loadedState.visibleNodes, equalTo(previousTags)) assertThat(loadedState.isWorking, equalTo(false)) - assertThat(loadedState.error, instanceOf(BackendDbLockedException::class.java)) + assertThat(loadedState.error, instanceOf(CollectionLockedException::class.java)) withQueuedCollectionAccess { val retry = removeTag("science") @@ -188,7 +188,7 @@ class ManageTagsViewModelTest : RobolectricTest() { 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)) + assertThat((failure as ManageTagsState.Error).error, instanceOf(CollectionLockedException::class.java)) viewModel.filter("hist") assertThat(viewModel.searchQuery.value, equalTo("hist")) @@ -508,7 +508,7 @@ class ManageTagsViewModelTest : RobolectricTest() { withLockedCollection { refreshTags().join() } - assertThat(loadedState.error, instanceOf(BackendDbLockedException::class.java)) + assertThat(loadedState.error, instanceOf(CollectionLockedException::class.java)) assertThat(pendingMessages.value, equalTo(pending)) refreshTags().join() diff --git a/AnkiDroid/src/test/java/com/ichi2/testutils/BackendEmulatingOpenConflictTest.kt b/AnkiDroid/src/test/java/com/ichi2/testutils/BackendEmulatingOpenConflictTest.kt index 808090f8116a..b4c440d98e64 100644 --- a/AnkiDroid/src/test/java/com/ichi2/testutils/BackendEmulatingOpenConflictTest.kt +++ b/AnkiDroid/src/test/java/com/ichi2/testutils/BackendEmulatingOpenConflictTest.kt @@ -5,7 +5,7 @@ package com.ichi2.testutils import androidx.test.ext.junit.runners.AndroidJUnit4 import com.ichi2.anki.CollectionManager import com.ichi2.anki.RobolectricTest -import net.ankiweb.rsdroid.BackendException.BackendDbException.BackendDbLockedException +import com.ichi2.anki.exception.CollectionLockedException import org.junit.After import org.junit.Assert.assertThrows import org.junit.Before @@ -29,7 +29,7 @@ class BackendEmulatingOpenConflictTest : RobolectricTest() { @Test fun assumeMocksAreValid() { assertThrows( - BackendDbLockedException::class.java, + CollectionLockedException::class.java, ) { CollectionManager.getColUnsafe() } } } diff --git a/anki-common/src/main/kotlin/com/ichi2/anki/CollectionManager.kt b/anki-common/src/main/kotlin/com/ichi2/anki/CollectionManager.kt index 9e04b6bfd416..91e7d8a40913 100644 --- a/anki-common/src/main/kotlin/com/ichi2/anki/CollectionManager.kt +++ b/anki-common/src/main/kotlin/com/ichi2/anki/CollectionManager.kt @@ -27,6 +27,7 @@ import com.ichi2.anki.common.storage.StorageDecision import com.ichi2.anki.common.utils.android.Threads import com.ichi2.anki.common.utils.android.isRobolectric import com.ichi2.anki.common.utils.isRunningAsUnitTest +import com.ichi2.anki.exception.CollectionLockedException import com.ichi2.anki.exception.StorageNotConfiguredException import com.ichi2.anki.exception.SystemStorageException import com.ichi2.anki.libanki.Collection @@ -393,15 +394,20 @@ object CollectionManager { throw CollectionHelper.systemStorageFailure ?: StorageNotConfiguredException() } ensureBackendInner() - emulatedOpenFailure?.triggerFailure() - if (collection == null || collection!!.dbClosed) { - val collectionPath = collectionFilesTestOverride ?: collectionPathInValidFolder() - collection = - collection( - collectionFiles = collectionPath, - databaseBuilder = { backend -> createDatabaseUsingRustBackend(backend) }, - backend = backend, - ) + try { + emulatedOpenFailure?.triggerFailure() + if (collection == null || collection!!.dbClosed) { + val collectionPath = collectionFilesTestOverride ?: collectionPathInValidFolder() + collection = + collection( + collectionFiles = collectionPath, + databaseBuilder = { backend -> createDatabaseUsingRustBackend(backend) }, + backend = backend, + ) + } + } catch (e: BackendException.BackendDbException.BackendDbLockedException) { + // All consumers should better explain the issue + throw CollectionLockedException(e) } } @@ -599,7 +605,7 @@ object CollectionManager { } enum class CollectionOpenFailure { - /** Raises [BackendException.BackendDbException.BackendDbLockedException] */ + /** Raises [BackendException.BackendDbException.BackendDbLockedException], surfaced as [CollectionLockedException] */ LOCKED, /** Raises [BackendException.BackendFatalError] */ diff --git a/anki-common/src/main/kotlin/com/ichi2/anki/exception/CollectionLockedException.kt b/anki-common/src/main/kotlin/com/ichi2/anki/exception/CollectionLockedException.kt new file mode 100644 index 000000000000..f48ce9abd7e1 --- /dev/null +++ b/anki-common/src/main/kotlin/com/ichi2/anki/exception/CollectionLockedException.kt @@ -0,0 +1,37 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.exception + +import net.ankiweb.rsdroid.BackendException +import net.ankiweb.rsdroid.BackendException.BackendDbException.BackendDbLockedException + +/** + * The collection database is locked by another process: normally a parallel AnkiDroid install. + * + * Thrown in place of [BackendDbLockedException] for a better user-facing error: + * + * * Inform the user two apps using the same folder are not supported. + * * Inform them they need to change the folder (via 'AnkiDroid directory') + * * Inform them that they should use 'Force close' to temporarily solve it. + * * Inform the user the app may not be running. + * + * Note: "Anki already open, or media currently syncing." is currently hardcoded as an error. + * `rslib/src/error/db.rs` + */ +class CollectionLockedException( + cause: BackendDbLockedException, +) : BackendException(messageProvider?.invoke() ?: cause.localizedMessage) { + init { + initCause(cause) + } + + companion object { + /** + * Localized, user-facing guidance shown wherever this exception is displayed. + * + * Set at app startup: this module cannot access the app's string resources. + * TODO: after #21500, use the string directly + */ + var messageProvider: (() -> String)? = null + } +} From ccf39a89a17b991987f907556659b8732daa98d8 Mon Sep 17 00:00:00 2001 From: David Allison <62114487+david-allison@users.noreply.github.com> Date: Sat, 15 Aug 2026 13:20:18 +0100 Subject: [PATCH 32/50] fix(login): locked-collection error in a dialog The `CollectionLockedException` guidance is truncated by the login screen's snackbar, so show it in a dialog. Part of 21051 Assisted-by: Claude Fable 5 --- .../com/ichi2/anki/account/LoginFragment.kt | 13 ++++- .../ichi2/anki/account/LoginFragmentTest.kt | 50 +++++++++++++++++++ 2 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 AnkiDroid/src/test/java/com/ichi2/anki/account/LoginFragmentTest.kt diff --git a/AnkiDroid/src/main/java/com/ichi2/anki/account/LoginFragment.kt b/AnkiDroid/src/main/java/com/ichi2/anki/account/LoginFragment.kt index 1a6dd2b8711a..3965a07e2e32 100644 --- a/AnkiDroid/src/main/java/com/ichi2/anki/account/LoginFragment.kt +++ b/AnkiDroid/src/main/java/com/ichi2/anki/account/LoginFragment.kt @@ -30,11 +30,14 @@ import com.google.android.material.textfield.TextInputEditText import com.google.android.material.textfield.TextInputLayout import com.ichi2.anki.CollectionManager.TR import com.ichi2.anki.CommonString +import com.ichi2.anki.CrashReportData.Companion.toCrashReportData import com.ichi2.anki.DeckPicker import com.ichi2.anki.R import com.ichi2.anki.account.AccountActivity.Companion.START_FROM_DECKPICKER import com.ichi2.anki.dialogs.help.HelpDialog +import com.ichi2.anki.exception.CollectionLockedException import com.ichi2.anki.getEndpoint +import com.ichi2.anki.showError import com.ichi2.anki.snackbar.showSnackbar import com.ichi2.anki.ui.internationalization.sentenceCase import com.ichi2.anki.utils.bottomCornerClearance @@ -244,7 +247,15 @@ class LoginFragment : Fragment(R.layout.fragment_my_account) { showLoginSuccessDialog() } is LoginState.Error -> { - showSnackbar(text = state.exception.message.toString()) + when (val exception = state.exception) { + // the locked-collection guidance is too long for a snackbar + is CollectionLockedException -> + requireContext().showError( + exception.localizedMessage!!, + exception.toCrashReportData(requireContext(), reportException = false), + ) + else -> showSnackbar(text = exception.message.toString()) + } } is LoginState.Idle -> { /* Not needed */ } } diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/account/LoginFragmentTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/account/LoginFragmentTest.kt new file mode 100644 index 000000000000..c527d3310765 --- /dev/null +++ b/AnkiDroid/src/test/java/com/ichi2/anki/account/LoginFragmentTest.kt @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package com.ichi2.anki.account + +import android.widget.Button +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.android.material.textfield.TextInputEditText +import com.ichi2.anki.R +import com.ichi2.anki.RobolectricTest +import com.ichi2.anki.throwOnShowError +import com.ichi2.testutils.BackendEmulatingOpenConflict +import com.ichi2.ui.TextInputEditField +import org.hamcrest.CoreMatchers.containsString +import org.hamcrest.MatcherAssert.assertThat +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LoginFragmentTest : RobolectricTest() { + @Before + override fun setUp() { + super.setUp() + BackendEmulatingOpenConflict.enable() + } + + @After + override fun tearDown() { + super.tearDown() + BackendEmulatingOpenConflict.disable() + } + + /** + * #21051: the locked-collection guidance is multi-sentence, so it is shown in a dialog + * rather than truncated in the login screen's snackbar + */ + @Test + fun `login with a locked collection shows guidance`() { + throwOnShowError = false + val activity = startRegularActivity(AccountActivity.getIntent(targetContext)) + activity.findViewById(R.id.username).setText("user@example.com") + activity.findViewById(R.id.password).setText("hunter2") + + activity.findViewById