Repository navigation
fix(profiles): keep MetaDB per profile - #22873
criticalAY wants to merge 1 commit into
Conversation
mikehardy
left a comment
There was a problem hiding this comment.
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.
314709b to
9895640
Compare
9895640 to
46cfba1
Compare
46cfba1 to
1d68a67
Compare
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
1d68a67 to
6a375eb
Compare
|
@david-allison imo the review should be in(correct me if I am wrong): |
david-allison
left a comment
There was a problem hiding this comment.
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.
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
openOrCreateDatabaseanddeleteDatabasecall their owngetDatabasePath, so overridinggetDatabasePathin a ContextWrapper is not enough.Checklist
Please, go through these checks before submitting the PR.