Skip to content

fix(profiles): keep MetaDB per profile - #22873

Open
criticalAY wants to merge 1 commit into
ankidroid:mainfrom
criticalAY:profiles/metadb-per-profile
Open

criticalAY wants to merge 1 commit into
ankidroid:mainfrom
criticalAY:profiles/metadb-per-profile

Conversation

@criticalAY

Copy link
Copy Markdown
Contributor

Note

Assisted-by: Opus 5.5

Purpose / Description

MetaDB opened its database in a way that ignored the profile's database folder, so every profile shared one MetaDB. Its data is keyed by deck ID, so settings like the whiteboard state for the Default deck carried over between profiles. Deleting a database through a profile also removed the Default profile's file. Opening and deleting databases now go through the active profile.

Fixes

Approach

check commit message

How Has This Been Tested?

added tests

Learning (optional, can help others)

Android's openOrCreateDatabase and deleteDatabase call their own getDatabasePath, so overriding getDatabasePath in a ContextWrapper is not enough.

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

@criticalAY criticalAY added the Multi Profile Anything that is related to AnkiDroid multiple profiles label Oct 30, 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

Thanks for the fix — keeping MetaDB on the active profile path is the right call for whiteboard isolation.

Locally I rebased the branch onto current main (no push; tip already matched the remote). Lint passed (lintAll, ktlintCheck, lint-rules:test), unit tests passed (jacocoUnitTestReport), and the release emulator suite passed on emulator-5556 / API35_GAPI with TEST_RELEASE_BUILD=true.

AnkiDroidApp.openOrCreateDatabase (3-arg and 4-arg) and deleteDatabase delegate to profileManager.activeProfileContext when set, otherwise super. ProfileContextWrapper for non-default profiles routes openOrCreateDatabase through SQLiteDatabase.openDatabase(getDatabasePath(name).path, …) with the same WAL/collator flags as ContextImpl; default profile still uses super. deleteDatabase uses SQLiteDatabase.deleteDatabase(getDatabasePath(name)). MetaDB is unchanged — first openDBIfClosed wins until closeDB. New tests cover per-profile whiteboard state and the wrapper/application delegation paths.

some acceptable findings, no action required, just leaving for posterity: nit: MetaDBTest never reads p_work back; asserting the writer profile stays true would distinguish a failed store from isolation.

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

This comment was marked as resolved.

@david-allison david-allison added the Needs Author Reply Waiting for a reply from the original author label Nov 2, 2026
@criticalAY
criticalAY force-pushed the profiles/metadb-per-profile branch from 314709b to 9895640 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
david-allison self-requested a review November 2, 2026 19:57
david-allison

This comment was marked as resolved.

@david-allison david-allison added the Needs Author Reply Waiting for a reply from the original author label Nov 2, 2026
@criticalAY
criticalAY force-pushed the profiles/metadb-per-profile branch from 9895640 to 46cfba1 Compare November 2, 2026 21:09
@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 the Needs Author Reply Waiting for a reply from the original author label Nov 2, 2026
@criticalAY
criticalAY force-pushed the profiles/metadb-per-profile branch from 46cfba1 to 1d68a67 Compare November 2, 2026 21: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.

@criticalAY criticalAY added the Needs Author Reply Waiting for a reply from the original author label Nov 2, 2026
MetaDB opened its database through the framework, which ignores the
profile's database folder, so every profile shared one MetaDB and deck
settings like the whiteboard carried over. Opening and deleting a
database now goes through the active profile.

Absolute database paths such as WorkManager's were also put inside the
profile's database folder. They're now left where they are, and a
database already stored there is moved back.

Co-authored-by: David Allison <62114487+david-allison@users.noreply.github.com>
Assisted-by: Claude Opus 5.5
@criticalAY
criticalAY force-pushed the profiles/metadb-per-profile branch from 1d68a67 to 6a375eb Compare November 3, 2026 04:48
@criticalAY criticalAY removed the Needs Author Reply Waiting for a reply from the original author label Nov 3, 2026
david-allison

This comment was marked as off-topic.

@david-allison david-allison added the Needs Author Reply Waiting for a reply from the original author label Nov 3, 2026
@criticalAY

Copy link
Copy Markdown
Contributor Author

@david-allison imo the review should be in(correct me if I am wrong):

@criticalAY criticalAY removed the Needs Author Reply Waiting for a reply from the original author label Nov 3, 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.

Assisted-by: GPT-6.1-Sol xhigh & GPT-6-Astra xhigh

Failed WAL recovery hides committed transactions

Subject: Failed WAL recovery hides committed transactions

An interrupted migration opens without its committed WAL when relocating the WAL fails.

Pre-PR status could not be established.

---
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileDatabaseWalMoveFailureRegressionTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileDatabaseWalMoveFailureRegressionTest.kt
new file mode 100644
index 0000000000..e219e8cd59
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileDatabaseWalMoveFailureRegressionTest.kt
@@ -0,0 +1,73 @@
+// 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 android.database.sqlite.SQLiteException
+import androidx.test.core.app.ApplicationProvider
+import com.ichi2.testutils.common.OS
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+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 ProfileDatabaseWalMoveFailureRegressionTest {
+    @Test
+    fun `failed WAL recovery keeps committed data`() {
+        assumeFalse("Windows paths cannot hold the nested copy", OS.WINDOWS.isRunning())
+        val base = ApplicationProvider.getApplicationContext<Context>()
+        val root = File(base.filesDir.parentFile, "p_wal_failure")
+        val profile = ProfileContextWrapper.create(base, ProfileId("p_wal_failure"), root)
+        val destination = File(profile.noBackupFilesDir, "androidx.work.workdb")
+        val nested = File(File(root, "databases"), destination.absolutePath)
+        nested.parentFile!!.mkdirs()
+        val seed = File(base.cacheDir, "wal-move-seed.db")
+        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(wal.length() > 32)
+            seed.copyTo(destination)
+            wal.copyTo(File(nested.path + "-wal"))
+        }
+        val journalParent = nested.parentFile!!
+        assertTrue(journalParent.setWritable(false, false))
+        try {
+            assertFalse("The recovery WAL must be unable to move", journalParent.canWrite())
+            val opened =
+                try {
+                    profile.openOrCreateDatabase(destination.path, Context.MODE_PRIVATE, null)
+                } catch (expected: IOException) {
+                    // Refusing to open until the WAL can be recovered is safe.
+                    return
+                } catch (expected: SQLiteException) {
+                    // SQLite may also refuse to open while journal recovery is blocked.
+                    return
+                }
+            opened.use { db ->
+                db.rawQuery("SELECT value FROM marker", null).use { cursor ->
+                    assertTrue("The committed transaction must survive WAL recovery", cursor.moveToFirst())
+                    assertEquals(73, cursor.getInt(0))
+                }
+            }
+        } finally {
+            journalParent.setWritable(true, true)
+        }
+    }
+}

Deleted databases reappear from legacy copies

Subject: Deleted databases reappear from legacy copies

Deleting a database when a legacy copy remains restores that copy on the next open.

---
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileDatabaseDeleteConflictRegressionTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileDatabaseDeleteConflictRegressionTest.kt
new file mode 100644
index 0000000000..fdc48ec2b4
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileDatabaseDeleteConflictRegressionTest.kt
@@ -0,0 +1,51 @@
+// 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.assertFalse
+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 ProfileDatabaseDeleteConflictRegressionTest {
+    @Test
+    fun `deleted database stays deleted`() {
+        assumeFalse("Windows paths cannot hold the nested copy", OS.WINDOWS.isRunning())
+        val base = ApplicationProvider.getApplicationContext<Context>()
+        val root = File(base.filesDir.parentFile, "p_delete")
+        val profile = ProfileContextWrapper.create(base, ProfileId("p_delete"), root)
+        val destination = File(profile.noBackupFilesDir, "androidx.work.workdb")
+        val nested = File(File(root, "databases"), destination.absolutePath)
+        nested.parentFile!!.mkdirs()
+        for (file in listOf(destination, nested)) {
+            SQLiteDatabase.openOrCreateDatabase(file, null).use { db ->
+                db.execSQL("CREATE TABLE stale_work (value INTEGER)")
+                db.execSQL("INSERT INTO stale_work VALUES (73)")
+            }
+        }
+
+        assertTrue(profile.deleteDatabase(destination.path))
+        assertFalse(destination.exists())
+
+        profile.openOrCreateDatabase(destination.path, Context.MODE_PRIVATE, null).use { db ->
+            db.rawQuery("SELECT name FROM sqlite_master WHERE name = 'stale_work'", null).use { cursor ->
+                assertFalse("A successful delete must not resurrect the legacy database", cursor.moveToFirst())
+            }
+        }
+    }
+}

Android upgrades can restore stale work data

Subject: Android upgrades can restore stale work data

After upgrading from Android 24 to 27, database migration reopens stale work data and abandons the newer active database.

Fix unresolved: no compatible temporary fix verified. Conflicting complete databases have no recorded provenance.

---
diff --git a/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileDatabaseUpgradeConflictRegressionTest.kt b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileDatabaseUpgradeConflictRegressionTest.kt
new file mode 100644
index 0000000000..914e015c81
--- /dev/null
+++ b/AnkiDroid/src/test/java/com/ichi2/anki/multiprofile/ProfileDatabaseUpgradeConflictRegressionTest.kt
@@ -0,0 +1,80 @@
+// 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.sqlite.db.SupportSQLiteDatabase
+import androidx.sqlite.db.SupportSQLiteOpenHelper
+import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
+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 ProfileDatabaseUpgradeConflictRegressionTest {
+    @Test
+    fun `OS upgrade keeps current work`() {
+        assumeFalse("Windows paths cannot hold the nested copy", OS.WINDOWS.isRunning())
+        val base = ApplicationProvider.getApplicationContext<Context>()
+        val root = File(base.filesDir.parentFile, "p_upgrade")
+        val profile = ProfileContextWrapper.create(base, ProfileId("p_upgrade"), root)
+        val name = "androidx.work.workdb"
+        val destination = File(profile.noBackupFilesDir, name)
+        val nested = File(File(root, "databases"), destination.absolutePath)
+
+        // Before this change, API 24-26 used Context.openOrCreateDatabase and wrote here.
+        seed(destination, 24)
+        // After upgrading Android, API 27's SQLiteOpenHelper used the nested getDatabasePath.
+        // This is the profile's currently used database, with work committed after the upgrade.
+        seed(nested, 27)
+
+        val config =
+            SupportSQLiteOpenHelper.Configuration
+                .builder(profile)
+                .name(name)
+                .noBackupDirectory(true)
+                .callback(
+                    object : SupportSQLiteOpenHelper.Callback(1) {
+                        override fun onCreate(db: SupportSQLiteDatabase) = Unit
+
+                        override fun onUpgrade(
+                            db: SupportSQLiteDatabase,
+                            oldVersion: Int,
+                            newVersion: Int,
+                        ) = Unit
+                    },
+                ).build()
+        FrameworkSQLiteOpenHelperFactory().create(config).use { helper ->
+            helper.writableDatabase.query("SELECT value FROM marker").use { cursor ->
+                assertTrue(cursor.moveToFirst())
+                assertEquals("The app upgrade must keep the database used before it", 27, 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)")
+            db.version = 1
+        }
+    }
+}

A compatible upgrade-conflict fix still needs a way to identify the active database history. No device or emulator upgrade was exercised.

@david-allison david-allison added the Needs Author Reply Waiting for a reply from the original author label Nov 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Multi Profile Anything that is related to AnkiDroid multiple profiles 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