Skip to content

fix(fields): keep confirmations working after recreation - #22876

Open
criticalAY wants to merge 2 commits into
ankidroid:mainfrom
criticalAY:fix/confirmation-dialog-recreation
Open

criticalAY wants to merge 2 commits into
ankidroid:mainfrom
criticalAY:fix/confirmation-dialog-recreation

Conversation

@criticalAY

Copy link
Copy Markdown
Contributor

Note

Assisted-by: Opus 5.5

Purpose / Description

After a night mode switch, confirming a change in the note type field editor did nothing. An open field menu also acted on the first field, so Delete could remove the wrong one.

Fixes

Approach

The editor saves the selected field, and ConfirmationDialog now returns a fragment result that the editor listens for in onCreate.

How Has This Been Tested?

test (fails on main).

Learning (optional, can help others)

No activity handles uiMode, so a night mode switch recreates the screen while rotation doesn't.

Checklist

Please, go through these checks before submitting the PR.

  • You have a descriptive commit message with a short title (first line, max 50 chars).
  • You have commented your code, particularly in hard-to-understand areas
  • You have performed a self-review of your own code
  • UI changes: include screenshots of all affected screens (in particular showing any new or changed strings)
  • UI Changes: You have tested your change using the Google Accessibility Scanner

@mikehardy mikehardy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assisted-by: Grok 4.7

Thank you — splitting “which field” from “the confirm click still does the edit” makes the failure mode easy to follow, and the Robolectric tests recreate the activity before the click.

Rebased two commits onto current main locally (no push). Ran lint (lintAll, ktlintCheck, lint-rules:test), unit (jacocoUnitTestReport), and release emulator tests on emulator-5556 / API35_GAPI; all passed.

Item click shows NoteTypeFieldEditorContextMenu with the field label, then sets currentPos. After recreation the restored menu’s item click uses the new activity. Delete, the field-delete warning, and the full-sync delete confirm all call deleteField(), which removes noteFields[currentPos]. The delete listener also calls modSchema(check = false) and popBackStackImmediate of the whole back stack, which is the old confirm body.

Add’s name dialog is still an AlertDialog. On ConfirmModSchemaException it shows a ConfirmationDialog whose result bundle holds field_name. The listener calls addField(name, false), which skips the schema check and calls modSchema(check = false). Reposition stores newPosition - 1 as field_index and, on confirm, modSchema(check = false) then moveFieldLegacy of noteFields[currentPos] to that index. Sort does not put an index in the bundle; on confirm it modSchema(check = false) and changeSortField(notetype, currentPos).

NoteTypeFieldEditorRecreationTest launches the Basic note type, opens the menu on Back (position 1), and uses ActivityController.configurationChange() after +night, asserting a new activity. One test recreates before choosing Delete. The others recreate with the confirm already showing, including the synced-collection full-sync path (ls = scm) for delete, add (Extra), reposition (input 1 → Back, Front), and sort (sortf == 1).

some acceptable findings, no action required, just leaving for posterity: rename’s full-sync ConfirmationDialog in renameFieldDialog still uses setConfirm; ConfirmationDialog’s class KDoc still says to call setArgs and setConfirm only though newInstance is what survives recreation.

@mikehardy mikehardy added Needs Second Approval Has one approval, one more approval to merge and removed Needs Review labels Nov 1, 2026

@david-allison david-allison left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thanks so much!!!

Comment on lines +81 to +83
requireArguments().getString(ARG_REQUEST_KEY)?.let { requestKey ->
setFragmentResult(requestKey, requireArguments().getBundle(ARG_RESULT) ?: Bundle())
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit/style: I like these as extension methods

Comment on lines +137 to +166
setFragmentResultListener(REQUEST_ADD_FIELD) { _, result ->
try {
addField(result.getString(RESULT_FIELD_NAME), false)
} catch (e: ConfirmModSchemaException) {
e.log()
// This should never be thrown
}
}
setFragmentResultListener(REQUEST_DELETE_FIELD) { _, _ ->
getColUnsafe.modSchema(check = false)
deleteField()

// This ensures that the context menu closes after the field has been deleted
supportFragmentManager.popBackStackImmediate(
null,
FragmentManager.POP_BACK_STACK_INCLUSIVE,
)
}
setFragmentResultListener(REQUEST_REPOSITION_FIELD) { _, result ->
try {
getColUnsafe.modSchema(check = false)
repositionField(result.getInt(RESULT_FIELD_INDEX))
} catch (e: JSONException) {
throw RuntimeException(e)
}
}
setFragmentResultListener(REQUEST_SORT_FIELD) { _, _ ->
getColUnsafe.modSchema(check = false)
launchCatchingTask { changeSortField(notetype, currentPos) }
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit/style: I like these as extension methods

Comment on lines +329 to +334
showDialogFragment(
ConfirmationDialog.newInstance(
requestKey = REQUEST_DELETE_FIELD,
message = resources.getString(CommonString.full_sync_confirmation),
),
)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit/style: could do with a method on ConfirmationDialog

@david-allison david-allison added Needs Author Reply Waiting for a reply from the original author Pending Merge Things with approval that are waiting future merge (e.g. targets a future release, CI wait, etc) and removed Needs Second Approval Has one approval, one more approval to merge labels Nov 2, 2026
@criticalAY
criticalAY force-pushed the fix/confirmation-dialog-recreation branch from 8406d66 to 9f0a68c Compare November 2, 2026 16:13
@criticalAY criticalAY removed the Needs Author Reply Waiting for a reply from the original author label Nov 2, 2026
Comment on lines +131 to +136
private fun FragmentManager.setConfirmationResult(
requestKey: String,
result: Bundle,
) {
setFragmentResult(requestKey, result)
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would expect this on ConfirmationDialog and handling ARG_REQUEST_KEY inside the method

david-allison

This comment was marked as resolved.

@david-allison david-allison added Needs Author Reply Waiting for a reply from the original author Needs Second Approval Has one approval, one more approval to merge labels Nov 2, 2026
@criticalAY
criticalAY force-pushed the fix/confirmation-dialog-recreation branch from 9f0a68c to a972730 Compare November 2, 2026 19:38
@criticalAY criticalAY removed the Needs Author Reply Waiting for a reply from the original author label Nov 2, 2026
david-allison

This comment was marked as resolved.

@david-allison david-allison added Needs Author Reply Waiting for a reply from the original author and removed Pending Merge Things with approval that are waiting future merge (e.g. targets a future release, CI wait, etc) labels Nov 2, 2026
@criticalAY
criticalAY force-pushed the fix/confirmation-dialog-recreation branch from a972730 to 63db170 Compare November 2, 2026 21:12
@david-allison david-allison removed the Needs Author Reply Waiting for a reply from the original author label Nov 2, 2026
@david-allison
david-allison self-requested a review November 2, 2026 21:17
david-allison

This comment was marked as resolved.

@criticalAY criticalAY added the Needs Author Reply Waiting for a reply from the original author label Nov 2, 2026
The field editor forgot which field was tapped when it was recreated,
for example by a night mode switch. An open field menu then acted on
the first field, so Delete could remove the wrong one. The field is
now remembered by name, so it is found again even if the fields were
reordered meanwhile.

Part of 22714

Assisted-by: Claude Opus 5.5
ConfirmationDialog kept its action in memory, so after a night mode
switch tapping Delete did nothing and the field menu came back. It can
now send a fragment result, which the field editor uses to add, delete,
reposition and sort fields. A result that arrives before the note type
has loaded is kept until it has, and nothing changes if the chosen
field is gone by then.

Part of 22714

Assisted-by: Claude Opus 5.5
@criticalAY
criticalAY force-pushed the fix/confirmation-dialog-recreation branch from 63db170 to cb4eee5 Compare November 3, 2026 04:52
@criticalAY criticalAY removed the Needs Author Reply Waiting for a reply from the original author label Nov 3, 2026

@mikehardy mikehardy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Assisted-by: Grok 4.7

Thank you — saving the field name and pending action through recreation fixes the wrong-field deletes and the startup race David flagged.

Locally I rebased onto main without force-pushing (prep stayed at the PR tip). I ran lint (lintAll, ktlintCheck, lint-rules:test), unit tests (jacocoUnitTestReport), and release emulator tests (TEST_RELEASE_BUILD=true via run_emulator_tests.py on emulator-5556 / API35_GAPI); all passed.

Reviewed ConfirmationDialog result bundles for delete/sort/reposition/add, selectedFieldName lookup after restore, and tests that block collection load until confirm plus schema reorder before restore.

some acceptable findings, no action required, just leaving for posterity: rename full-sync confirm still uses in-memory setConfirm; KDoc still mentions setConfirm only.

@mikehardy mikehardy added Pending Merge Things with approval that are waiting future merge (e.g. targets a future release, CI wait, etc) and removed Needs Second Approval Has one approval, one more approval to merge labels Nov 3, 2026
@mikehardy
mikehardy enabled auto-merge November 3, 2026 05:31

@david-allison david-allison left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

legacy database resurrection after deletion

diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileDatabaseDeletionRegressionTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileDatabaseDeletionRegressionTest.kt
new file mode 100644
index 0000000000..e581bbe02c
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileDatabaseDeletionRegressionTest.kt
@@ -0,0 +1,64 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+@file:Suppress("DEPRECATION")
+
+package com.ichi2.anki.multiprofile
+
+import android.app.Application
+import android.content.Context
+import android.database.sqlite.SQLiteDatabase
+import androidx.test.core.app.ApplicationProvider
+import com.ichi2.testutils.common.OS
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Assume.assumeFalse
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.SQLiteMode
+import java.io.File
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [27], application = Application::class)
+@SQLiteMode(SQLiteMode.Mode.NATIVE)
+class ProfileDatabaseDeletionRegressionTest {
+    @Test
+    fun `deleting a database does not restore a conflicting legacy copy`() {
+        assumeFalse("Windows paths cannot hold the nested copy", OS.WINDOWS.isRunning())
+        val base = ApplicationProvider.getApplicationContext<Context>()
+        val root = File(base.filesDir.parentFile, "p_delete_regression")
+        val profile = ProfileContextWrapper.create(base, ProfileId("p_delete_regression"), root)
+        val database = File(profile.noBackupFilesDir, "androidx.work.workdb")
+        val legacy = File(File(root, "databases"), database.absolutePath)
+        seed(database, 99)
+        seed(legacy, 73)
+
+        // The existing destination wins when both locations contain a database.
+        profile.openOrCreateDatabase(database.path, Context.MODE_PRIVATE, null).use { db ->
+            db.rawQuery("SELECT value FROM marker", null).use { cursor ->
+                assertTrue(cursor.moveToFirst())
+                assertEquals(99, cursor.getInt(0))
+            }
+        }
+        assertTrue(profile.deleteDatabase(database.path))
+
+        profile.openOrCreateDatabase(database.path, Context.MODE_PRIVATE, null).use { db ->
+            db.rawQuery("SELECT count(*) FROM sqlite_master WHERE type='table' AND name='marker'", null).use { cursor ->
+                assertTrue(cursor.moveToFirst())
+                assertEquals("Deletion must not resurrect the legacy database", 0, cursor.getInt(0))
+            }
+        }
+    }
+
+    private fun seed(
+        file: File,
+        value: Int,
+    ) {
+        file.parentFile!!.mkdirs()
+        SQLiteDatabase.openOrCreateDatabase(file, null).use { db ->
+            db.execSQL("CREATE TABLE marker (value INTEGER)")
+            db.execSQL("INSERT INTO marker VALUES ($value)")
+        }
+    }
+}

Data loss after a failed WAL move

diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileDatabaseJournalMoveFailureRegressionTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileDatabaseJournalMoveFailureRegressionTest.kt
new file mode 100644
index 0000000000..f8db6e08de
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileDatabaseJournalMoveFailureRegressionTest.kt
@@ -0,0 +1,71 @@
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+@file:Suppress("DEPRECATION")
+
+package com.ichi2.anki.multiprofile
+
+import android.app.Application
+import android.content.Context
+import android.database.sqlite.SQLiteDatabase
+import androidx.test.core.app.ApplicationProvider
+import com.ichi2.testutils.common.OS
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Assume.assumeFalse
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import org.robolectric.annotation.SQLiteMode
+import java.io.File
+import java.io.IOException
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [27], application = Application::class)
+@SQLiteMode(SQLiteMode.Mode.NATIVE)
+class ProfileDatabaseJournalMoveFailureRegressionTest {
+    @Test
+    fun `failed journal move does not hide a committed transaction`() {
+        assumeFalse("Windows paths cannot hold the nested copy", OS.WINDOWS.isRunning())
+        val base = ApplicationProvider.getApplicationContext<Context>()
+        val root = File(base.filesDir.parentFile, "p_journal_move_failure")
+        val profile = ProfileContextWrapper.create(base, ProfileId("p_journal_move_failure"), root)
+        val destination = File(profile.noBackupFilesDir, "androidx.work.workdb")
+        val nested = File(File(root, "databases"), destination.absolutePath)
+        val seed = File(base.cacheDir, "journal-move-seed.db")
+        nested.parentFile!!.mkdirs()
+        SQLiteDatabase.openOrCreateDatabase(seed, null).use { db ->
+            db.execSQL("CREATE TABLE marker (value INTEGER)")
+            assertTrue(db.enableWriteAheadLogging())
+            db.rawQuery("PRAGMA wal_autocheckpoint=0", null).close()
+            db.execSQL("INSERT INTO marker VALUES (73)")
+            val wal = File(seed.path + "-wal")
+            assertTrue("The committed insert must still be in WAL", wal.length() > 32)
+            seed.copyTo(nested)
+            wal.copyTo(File(nested.path + "-wal"))
+        }
+        // Resume a previous migration interrupted after moving only the main database.
+        assertTrue(nested.renameTo(destination))
+        val sourceParent = nested.parentFile!!
+        assertTrue(sourceParent.setWritable(false, false))
+        try {
+            try {
+                profile.openOrCreateDatabase(destination.path, Context.MODE_PRIVATE, null).use(::assertMarker)
+            } catch (expected: IOException) {
+                // Refusing to open is safe while the committed journal cannot be moved.
+                assertTrue(File(nested.path + "-wal").length() > 32)
+            }
+        } finally {
+            sourceParent.setWritable(true, true)
+        }
+        // An explicit refusal must still leave the committed data recoverable.
+        profile.openOrCreateDatabase(destination.path, Context.MODE_PRIVATE, null).use(::assertMarker)
+    }
+
+    private fun assertMarker(database: SQLiteDatabase) {
+        database.rawQuery("SELECT value FROM marker", null).use { cursor ->
+            assertTrue("Opening without the stranded WAL hides committed data", cursor.moveToFirst())
+            assertEquals(73, cursor.getInt(0))
+        }
+    }
+}

Unresolved candidate and validation limits

// SPDX-License-Identifier: GPL-3.0-or-later

@file:Suppress("DEPRECATION")

package com.ichi2.anki.multiprofile

import android.app.Application
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import com.ichi2.testutils.common.OS
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Assume.assumeFalse
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
import org.robolectric.annotation.SQLiteMode
import java.io.File

@RunWith(RobolectricTestRunner::class)
@Config(sdk = [27], application = Application::class)
@SQLiteMode(SQLiteMode.Mode.NATIVE)
class ProfileDatabaseOrphanWalRegressionTest {
    @Test
    fun `an orphan WAL cannot replace a newly created database`() {
        assumeFalse("Windows paths cannot hold the nested copy", OS.WINDOWS.isRunning())
        val base = ApplicationProvider.getApplicationContext<Context>()
        val root = File(base.filesDir.parentFile, "p_orphan_wal_regression")
        val profile = ProfileContextWrapper.create(base, ProfileId("p_orphan_wal_regression"), root)
        val destination = File(profile.noBackupFilesDir, "androidx.work.workdb")
        val nested = File(File(root, "databases"), destination.absolutePath)
        val seed = File(base.cacheDir, "orphan-wal-seed.db")
        seed(seed, 10)
        SQLiteDatabase.openOrCreateDatabase(seed, null).use { db ->
            assertTrue(db.enableWriteAheadLogging())
            db.execSQL("UPDATE marker SET value=73")
            nested.parentFile!!.mkdirs()
            File(seed.path + "-wal").copyTo(File(nested.path + "-wal"))
        }
        // The old database is absent; a helper now creates a fresh destination.
        assertEquals(destination, profile.getDatabasePath(destination.path))
        seed(destination, 99)
        SQLiteDatabase.openOrCreateDatabase(destination, null).use { db ->
            assertTrue(db.enableWriteAheadLogging())
        }
        profile.openOrCreateDatabase(destination.path, Context.MODE_PRIVATE, null).use { db ->
            db.rawQuery("SELECT value FROM marker", null).use { cursor ->
                assertTrue(cursor.moveToFirst())
                assertEquals("A leftover WAL belongs to the old database", 99, cursor.getInt(0))
            }
        }
    }

    private fun seed(
        file: File,
        value: Int,
    ) {
        file.parentFile!!.mkdirs()
        SQLiteDatabase.openOrCreateDatabase(file, null).use { db ->
            db.execSQL("CREATE TABLE marker (value INTEGER)")
            db.execSQL("INSERT INTO marker VALUES ($value)")
        }
    }
}

@david-allison david-allison added Needs Author Reply Waiting for a reply from the original author Needs Second Approval Has one approval, one more approval to merge and removed Pending Merge Things with approval that are waiting future merge (e.g. targets a future release, CI wait, etc) labels Nov 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Needs Author Reply Waiting for a reply from the original author Needs Second Approval Has one approval, one more approval to merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants