fix(di): defer database provisioning off the Hilt inject path #137

Merged
JMR-dev merged 4 commits from fix-providedatabase-anr into main 2026-07-02 16:23:15 +00:00
6 changed files with 557 additions and 70 deletions
@@ -0,0 +1,136 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import org.libremail.data.security.DatabaseKeyStore
import org.libremail.data.settings.SettingsRepository
import javax.inject.Inject
import javax.inject.Singleton
/** How the cache database ([LibreMailDatabase]) must be opened, decided by [DatabaseProvisioner]. */
sealed interface CacheOpenMode {
/** Open with SQLCipher, keyed by [passphrase] — the opt-in encrypted cache. */
data class Encrypted(val passphrase: String) : CacheOpenMode
/** Open with the default framework helper — the cache is plaintext on disk. */
data object Plaintext : CacheOpenMode
}
/**
* Runs the one-time, blocking startup sequence that must complete BEFORE Room opens either database —
* exactly once, memoized, and OFF the Hilt injection path (issue #93).
*
* `DatabaseModule.provideDatabase` used to do this work inline, with `runBlocking`, while Hilt
* constructed the singleton [LibreMailDatabase]: a DataStore read, a Keystore op, a possible SQLCipher
* re-key conversion, and (since #111) the cross-database [AccountDataMigrator]. All of it ran
* synchronously on whichever thread first injected the database — which can be the main thread — so the
* first DB access could jank or ANR (worst with the encrypted cache on). This class moves that work
* behind [prepareCache]; the Hilt providers wire it into a [DeferredOpenHelperFactory] so it runs
* lazily, on Room's background open, never at inject time.
*
* The sequence, its ordering, and its crash-safety are unchanged from the old `provideDatabase` — only
* WHERE and WHEN it runs moved:
* 1. If a screen-lock change flagged the encrypted cache for wiping, wipe it and reset its seals
* (before Room opens the file, so no open connection is deleted underneath it).
* 2. Run [AccountDataMigrator] — the one-time move of accounts/credentials/settings/signatures into
* the non-auth [AccountDatabase] (issue #111). MUST precede opening the cache (whose
* [MIGRATION_15_16] drops the moved tables) AND opening [AccountDatabase] (which reads the copied
* rows). Both databases' open paths gate on [prepareCache], so the migrate-before-open guarantee
* holds regardless of which database Room opens first.
* 3. Resolve the encryption gate: convert the on-disk cache to the form the `encryptCache` setting
* asks for, and report how the cache must be opened.
*
* [prepareCache] is memoized on success and guarded by a [Mutex], so the first database to open runs
* the sequence and any concurrent or later opener awaits the same result. A failure is NOT memoized, so
* it retries on the next open — preserving the migrator's "crash-loop rather than lose data" contract
* (a throw here means the cache never opens, so [MIGRATION_15_16] never drops the not-yet-copied rows).
*/
@Singleton
class DatabaseProvisioner internal constructor(
private val context: Context,
private val keyStore: DatabaseKeyStore,
private val settingsRepository: SettingsRepository,
private val accountDataMigrator: AccountDataMigrator,
private val ioDispatcher: CoroutineDispatcher,
) {
@Inject
constructor(
@ApplicationContext context: Context,
keyStore: DatabaseKeyStore,
settingsRepository: SettingsRepository,
accountDataMigrator: AccountDataMigrator,
) : this(context, keyStore, settingsRepository, accountDataMigrator, Dispatchers.IO)
private val mutex = Mutex()
@Volatile
private var prepared: CacheOpenMode? = null
/**
* Runs the startup sequence exactly once (on [ioDispatcher]) and returns how the cache must be
* opened. Idempotent and safe to call concurrently from both databases' open paths; the blocking
* work runs on [ioDispatcher], never on the caller's thread past the suspension point.
*/
suspend fun prepareCache(): CacheOpenMode {
prepared?.let { return it }
return mutex.withLock {
prepared ?: withContext(ioDispatcher) { runStartupSequence() }.also { prepared = it }
}
}
private suspend fun runStartupSequence(): CacheOpenMode {
val dbFile = context.getDatabasePath(DatabaseFiles.NAME)
// A screen-lock change (biometric re-enrollment / lock removal) can invalidate the auth-bound
// key so the encrypted cache is no longer decryptable. AppLockViewModel records that and
// restarts the app; we wipe the cache HERE — before Room opens it — so the file is never
// deleted from under an open connection. Crash-safe order: wipe + reset the seals, and only THEN
// clear the flag, so a kill mid-wipe just repeats the idempotent wipe next start. Only
// libremail.db is wiped: accounts/credentials live in AccountDatabase (a separate file), so the
// user stays signed in across the wipe (issue #111).
if (keyStore.isClearPending()) {
DatabaseFiles.clear(context)
keyStore.resetSealedPassphrase()
keyStore.clearClearPending()
}
// One-time move of accounts/credentials/settings/signatures into the non-auth AccountDatabase
// (issue #111). MUST run before the cache opens: opening it applies MIGRATION_15_16, which drops
// the moved tables. Runs AFTER the wipe above so an unrecoverable-key cache is gone first
// (nothing left to move) and we never block waiting on a passphrase we can't get.
accountDataMigrator.migrateIfNeeded()
// Opt-in at-rest encryption of the local cache (off by default). The conversion runs here —
// before the database is opened — so it never races an open connection; toggling the setting
// therefore takes effect on the next app start. The passphrase source is resolved from which
// seal actually exists (DatabaseKeyStore.resolvePassphrase), NOT from the app-lock setting (a
// separate DataStore that can disagree). When app-lock is ON the sealing key is auth-bound, so
// resolvePassphrase waits on PassphraseSession until the user authenticates — which is why this
// must never run on the main thread while the cache is locked (issue #93).
val settings = settingsRepository.settings.first()
val appLock = settings.appLock
return when {
settings.encryptCache -> {
val passphrase = keyStore.resolvePassphrase(appLock)
DatabaseEncryption.ensureEncrypted(dbFile, passphrase)
CacheOpenMode.Encrypted(passphrase)
}
DatabaseEncryption.isEncrypted(dbFile) -> {
// Encryption was turned back off — decrypt so the default (unkeyed) open succeeds.
val passphrase = keyStore.resolvePassphrase(appLock)
DatabaseEncryption.ensurePlaintext(dbFile, passphrase)
CacheOpenMode.Plaintext
}
else -> CacheOpenMode.Plaintext
}
}
}
@@ -0,0 +1,75 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local
import androidx.sqlite.db.SupportSQLiteDatabase
import androidx.sqlite.db.SupportSQLiteOpenHelper
/**
* A [SupportSQLiteOpenHelper.Factory] that defers building the REAL open helper — and any blocking work
* that choosing and creating it entails — from Room's build/inject path to the FIRST actual database
* open (issue #93).
*
* Room calls [create] and [SupportSQLiteOpenHelper.setWriteAheadLoggingEnabled] while it builds the
* database, on whichever thread injected it (possibly the main thread); neither may block. This factory
* hands back a thin handle whose delegate is materialised only when the database is first opened
* (`writableDatabase` / `readableDatabase`), which Room performs on its background query executor. The
* [buildDelegate] lambda is where the caller runs the startup gate (see [DatabaseProvisioner]) and
* picks the concrete factory — so all of that runs off the injection path and off the main thread.
*/
internal class DeferredOpenHelperFactory(
private val buildDelegate: (SupportSQLiteOpenHelper.Configuration) -> SupportSQLiteOpenHelper,
) : SupportSQLiteOpenHelper.Factory {
override fun create(configuration: SupportSQLiteOpenHelper.Configuration): SupportSQLiteOpenHelper =
DeferredOpenHelper(configuration, buildDelegate)
}
/**
* The lazy handle returned by [DeferredOpenHelperFactory]. Everything Room touches before the first
* open is cheap; [buildDelegate] (which does the blocking work) runs only when [writableDatabase] or
* [readableDatabase] is first read.
*/
private class DeferredOpenHelper(
private val configuration: SupportSQLiteOpenHelper.Configuration,
private val buildDelegate: (SupportSQLiteOpenHelper.Configuration) -> SupportSQLiteOpenHelper,
) : SupportSQLiteOpenHelper {
private val lock = Any()
/** Guarded by [lock]. Null until the database is first opened — `create()` must stay non-blocking. */
private var delegate: SupportSQLiteOpenHelper? = null
/**
* Guarded by [lock]. Room may set WAL before the first open; we remember the value and apply it when
* the delegate is built, rather than building the delegate early (which would run the gate at inject
* time). Null means "Room never asked", so the delegate keeps the real factory's own default.
*/
private var writeAheadLoggingEnabled: Boolean? = null
override val databaseName: String?
get() = configuration.name
override fun setWriteAheadLoggingEnabled(enabled: Boolean) {
synchronized(lock) {
writeAheadLoggingEnabled = enabled
delegate?.setWriteAheadLoggingEnabled(enabled)
}
}
override val writableDatabase: SupportSQLiteDatabase
get() = delegate().writableDatabase
override val readableDatabase: SupportSQLiteDatabase
get() = delegate().readableDatabase
override fun close() {
// Never opened means nothing to close; do NOT build the delegate just to close it.
synchronized(lock) { delegate?.close() }
}
private fun delegate(): SupportSQLiteOpenHelper = synchronized(lock) {
delegate ?: buildDelegate(configuration).also { built ->
writeAheadLoggingEnabled?.let(built::setWriteAheadLoggingEnabled)
delegate = built
}
}
}
@@ -3,14 +3,17 @@ package org.libremail.di
import android.content.Context
import androidx.room.Room
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.runBlocking
import org.libremail.data.local.AccountDatabase
import org.libremail.data.local.DatabaseFiles.ACCOUNTS_NAME
import org.libremail.data.local.LibreMailDatabase
import org.libremail.data.local.DatabaseProvisioner
import org.libremail.data.local.DeferredOpenHelperFactory
import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.dao.AccountSettingsDao
import org.libremail.data.local.dao.CredentialDao
@@ -27,17 +30,26 @@ import javax.inject.Singleton
object AccountDatabaseModule {
/**
* The plaintext account store. Depends on [LibreMailDatabase] purely for construction ordering:
* building the cache runs the one-time [org.libremail.data.local.AccountDataMigrator] (which
* populates this file on a dedicated connection) and then drops the moved tables, so by the time
* Room opens this file the data is already present and no other connection is touching it.
* The plaintext account store. Its OPEN is gated on [DatabaseProvisioner.prepareCache] so the
* one-time [org.libremail.data.local.AccountDataMigrator] (which populates this file on a dedicated
* connection, then drops the moved tables from the cache) has finished before Room opens this file —
* the migrate-before-open ordering the old construction-time dependency on `LibreMailDatabase`
* enforced, now moved OFF the injection path (issue #93). This store always opens unkeyed, so it
* ignores the returned cache open-mode and only awaits the shared sequence.
*/
@Provides
@Singleton
fun provideAccountDatabase(
@ApplicationContext context: Context,
@Suppress("UNUSED_PARAMETER") cacheDatabase: LibreMailDatabase,
): AccountDatabase = Room.databaseBuilder(context, AccountDatabase::class.java, ACCOUNTS_NAME).build()
provisioner: DatabaseProvisioner,
): AccountDatabase = Room.databaseBuilder(context, AccountDatabase::class.java, ACCOUNTS_NAME)
.openHelperFactory(
DeferredOpenHelperFactory { configuration ->
runBlocking { provisioner.prepareCache() }
FrameworkSQLiteOpenHelperFactory().create(configuration)
},
)
.build()
@Provides
fun provideAccountDao(database: AccountDatabase): AccountDao = database.accountDao()
@@ -3,17 +3,18 @@ package org.libremail.di
import android.content.Context
import androidx.room.Room
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import net.zetetic.database.sqlcipher.SupportOpenHelperFactory
import org.libremail.data.local.AccountDataMigrator
import org.libremail.data.local.DatabaseEncryption
import org.libremail.data.local.CacheOpenMode
import org.libremail.data.local.DatabaseFiles
import org.libremail.data.local.DatabaseProvisioner
import org.libremail.data.local.DeferredOpenHelperFactory
import org.libremail.data.local.LibreMailDatabase
import org.libremail.data.local.MIGRATION_10_11
import org.libremail.data.local.MIGRATION_11_12
@@ -36,8 +37,6 @@ import org.libremail.data.local.dao.DraftDao
import org.libremail.data.local.dao.FolderDao
import org.libremail.data.local.dao.MessageDao
import org.libremail.data.local.dao.OutboxDao
import org.libremail.data.security.DatabaseKeyStore
import org.libremail.data.settings.SettingsRepository
import javax.inject.Singleton
@Module
@@ -46,13 +45,8 @@ object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(
@ApplicationContext context: Context,
keyStore: DatabaseKeyStore,
settingsRepository: SettingsRepository,
accountDataMigrator: AccountDataMigrator,
): LibreMailDatabase {
val builder = Room.databaseBuilder(context, LibreMailDatabase::class.java, DB_NAME)
fun provideDatabase(@ApplicationContext context: Context, provisioner: DatabaseProvisioner): LibreMailDatabase =
Room.databaseBuilder(context, LibreMailDatabase::class.java, DB_NAME)
.addMigrations(
MIGRATION_1_2,
MIGRATION_2_3,
@@ -70,59 +64,29 @@ object DatabaseModule {
MIGRATION_14_15,
MIGRATION_15_16,
)
// No destructive fallback: the migration chain is complete, and silently dropping the
// mail/message tables would lose cached data. A missing migration should fail loudly in
// testing instead.
// No destructive fallback: the migration chain is complete, and silently dropping the
// mail/message tables would lose cached data. A missing migration should fail loudly in
// testing instead.
//
// All blocking startup work — the issue-#111 AccountDataMigrator, the encrypted-cache
// conversion, and the Keystore passphrase resolution — is deferred OFF this injection path
// (issue #93). The factory below runs DatabaseProvisioner.prepareCache() lazily, when Room
// first OPENS the cache on its background query executor, never on the (possibly main)
// thread that injects this singleton. prepareCache() still performs that sequence before the
// file opens and in the same order, so the migrate-before-open guarantee and the encryption
// gate are unchanged — only where/when they run moved.
.openHelperFactory(
DeferredOpenHelperFactory { configuration ->
val realFactory = when (val mode = runBlocking { provisioner.prepareCache() }) {
is CacheOpenMode.Encrypted ->
SupportOpenHelperFactory(mode.passphrase.toByteArray(Charsets.US_ASCII), null, false)
// Opt-in at-rest encryption of the local cache (off by default). The conversion runs here —
// before the database is opened — so it never races an open connection; toggling the setting
// therefore takes effect on the next app start. The passphrase is sealed by the Keystore.
//
// The passphrase source is resolved from which seal actually exists
// ([DatabaseKeyStore.resolvePassphrase]), NOT from the app-lock setting (a separate DataStore
// that can disagree). When app-lock is ON the sealing key is auth-bound, so resolvePassphrase
// waits on PassphraseSession until the user authenticates. This provider must therefore never
// be constructed on the main thread while the cache is locked — LibreMailApplication injects
// AccountRepository lazily and the sync/push workers fail fast when locked, and the gate
// composes no DB-backed screen until Unlocked.
val dbFile = context.getDatabasePath(DB_NAME)
// A screen-lock change (biometric re-enrollment / lock removal) can invalidate the auth-bound
// key so the encrypted cache is no longer decryptable. AppLockViewModel records that and
// restarts the app; we wipe the cache HERE — at cold start, before Room opens — so the file is
// never deleted from under an open connection. Crash-safe order: wipe + reset the seals, and
// only THEN clear the flag, so a kill mid-wipe just repeats the idempotent wipe next start.
// Only libremail.db is wiped: accounts/credentials live in AccountDatabase (a separate file),
// so the user stays signed in across the wipe (issue #111).
if (runBlocking { keyStore.isClearPending() }) {
DatabaseFiles.clear(context)
runBlocking {
keyStore.resetSealedPassphrase()
keyStore.clearClearPending()
}
}
// One-time move of accounts/credentials/settings/signatures into the non-auth AccountDatabase
// (issue #111). MUST run before builder.build() below: opening the cache applies MIGRATION_15_16,
// which drops the moved tables. It runs AFTER the wipe above so an unrecoverable-key cache is
// gone first (nothing left to move) and we never block waiting on a passphrase we can't get.
runBlocking { accountDataMigrator.migrateIfNeeded() }
val settings = runBlocking { settingsRepository.settings.first() }
val appLock = settings.appLock
if (settings.encryptCache) {
val passphrase = runBlocking { keyStore.resolvePassphrase(appLock) }
DatabaseEncryption.ensureEncrypted(dbFile, passphrase)
builder.openHelperFactory(
SupportOpenHelperFactory(passphrase.toByteArray(Charsets.US_ASCII), null, false),
CacheOpenMode.Plaintext -> FrameworkSQLiteOpenHelperFactory()
}
realFactory.create(configuration)
},
)
} else if (DatabaseEncryption.isEncrypted(dbFile)) {
// Encryption was turned back off — decrypt so the default (unkeyed) open succeeds.
val passphrase = runBlocking { keyStore.resolvePassphrase(appLock) }
DatabaseEncryption.ensurePlaintext(dbFile, passphrase)
}
return builder.build()
}
.build()
@Provides
fun provideMessageDao(database: LibreMailDatabase): MessageDao = database.messageDao()
@@ -0,0 +1,193 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local
import android.content.Context
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.coVerifyOrder
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.ExecutorCoroutineDispatcher
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.libremail.data.security.DatabaseKeyStore
import org.libremail.data.settings.AppSettings
import org.libremail.data.settings.SettingsRepository
import java.io.File
import java.util.concurrent.Executors
import kotlin.test.assertEquals
/**
* [DatabaseProvisioner] holds the one-time startup sequence that `DatabaseModule.provideDatabase` used
* to run inline while Hilt constructed the database (a DataStore read, a Keystore op, a possible
* SQLCipher re-key conversion, and the issue-#111 account migrator) — synchronously on whichever thread
* injected it, possibly the main thread. These tests pin down what moving that work behind
* [DatabaseProvisioner.prepareCache] must preserve (issue #93): the same ordering (wipe -> migrate ->
* encryption gate), the same branch behaviour, single-run memoization, and that the blocking work runs
* on the injected IO dispatcher rather than the caller's thread.
*
* The native/file collaborators ([DatabaseEncryption], [DatabaseFiles]) and the suspend collaborators
* are all mocked, so this exercises the orchestration without a device.
*/
class DatabaseProvisionerTest {
private val context = mockk<Context>()
private val keyStore = mockk<DatabaseKeyStore>()
private val settingsRepository = mockk<SettingsRepository>()
private val accountDataMigrator = mockk<AccountDataMigrator>()
private lateinit var ioDispatcher: ExecutorCoroutineDispatcher
@Before
fun setUp() {
ioDispatcher = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, IO_THREAD_NAME) }
.asCoroutineDispatcher()
mockkObject(DatabaseEncryption)
mockkObject(DatabaseFiles)
every { context.getDatabasePath(any()) } returns File("libremail.db")
every { DatabaseFiles.clear(any()) } just Runs
every { DatabaseEncryption.isEncrypted(any()) } returns false
every { DatabaseEncryption.ensureEncrypted(any(), any()) } just Runs
every { DatabaseEncryption.ensurePlaintext(any(), any()) } just Runs
every { settingsRepository.settings } returns flowOf(AppSettings())
coEvery { keyStore.isClearPending() } returns false
coEvery { keyStore.resetSealedPassphrase() } just Runs
coEvery { keyStore.clearClearPending() } just Runs
coEvery { keyStore.resolvePassphrase(any()) } returns PASSPHRASE
coEvery { accountDataMigrator.migrateIfNeeded() } just Runs
}
@After
fun tearDown() {
ioDispatcher.close()
unmockkAll()
}
private fun provisioner() =
DatabaseProvisioner(context, keyStore, settingsRepository, accountDataMigrator, ioDispatcher)
@Test
fun `an encrypted cache is converted and reports the SQLCipher passphrase`() = runTest {
every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = true, appLock = false))
val mode = provisioner().prepareCache()
assertEquals(CacheOpenMode.Encrypted(PASSPHRASE), mode)
coVerify(exactly = 1) { keyStore.resolvePassphrase(false) }
verify(exactly = 1) { DatabaseEncryption.ensureEncrypted(any(), PASSPHRASE) }
verify(exactly = 0) { DatabaseEncryption.ensurePlaintext(any(), any()) }
}
@Test
fun `a pending clear wipes and resets the seals before the migrator runs`() = runTest {
coEvery { keyStore.isClearPending() } returns true
provisioner().prepareCache()
// Crash-safe order preserved from the old provideDatabase: wipe + reset the seals, THEN clear the
// pending flag, THEN migrate — never touching the cache file after an open connection exists.
coVerifyOrder {
keyStore.isClearPending()
DatabaseFiles.clear(any())
keyStore.resetSealedPassphrase()
keyStore.clearClearPending()
accountDataMigrator.migrateIfNeeded()
}
}
@Test
fun `the account migrator runs before the cache encryption gate`() = runTest {
every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = true))
provisioner().prepareCache()
// The #111 migrate-before-open guarantee: the account tables are copied out BEFORE the cache is
// touched (here, before its passphrase is resolved and it is re-keyed).
coVerifyOrder {
accountDataMigrator.migrateIfNeeded()
keyStore.resolvePassphrase(any())
DatabaseEncryption.ensureEncrypted(any(), any())
}
}
@Test
fun `an encrypted file with encryption turned off is decrypted to plaintext`() = runTest {
every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = false))
every { DatabaseEncryption.isEncrypted(any()) } returns true
val mode = provisioner().prepareCache()
assertEquals(CacheOpenMode.Plaintext, mode)
verify(exactly = 1) { DatabaseEncryption.ensurePlaintext(any(), PASSPHRASE) }
verify(exactly = 0) { DatabaseEncryption.ensureEncrypted(any(), any()) }
}
@Test
fun `a plaintext cache with encryption off touches neither the passphrase nor a conversion`() = runTest {
val mode = provisioner().prepareCache() // defaults: encryptCache = false, file not encrypted
assertEquals(CacheOpenMode.Plaintext, mode)
coVerify(exactly = 0) { keyStore.resolvePassphrase(any()) }
verify(exactly = 0) { DatabaseEncryption.ensureEncrypted(any(), any()) }
verify(exactly = 0) { DatabaseEncryption.ensurePlaintext(any(), any()) }
}
@Test
fun `the startup sequence runs once and is memoized across calls`() = runTest {
val provisioner = provisioner()
repeat(3) { provisioner.prepareCache() }
coVerify(exactly = 1) { keyStore.isClearPending() }
coVerify(exactly = 1) { accountDataMigrator.migrateIfNeeded() }
verify(exactly = 1) { settingsRepository.settings }
}
@Test
fun `concurrent first opens collapse to a single run`() = runTest {
val provisioner = provisioner()
// Both databases opening at once each gate on prepareCache; the mutex must collapse them to one
// run of the migrator (opening the cache twice would be a correctness bug).
val first = async { provisioner.prepareCache() }
val second = async { provisioner.prepareCache() }
awaitAll(first, second)
coVerify(exactly = 1) { accountDataMigrator.migrateIfNeeded() }
}
@Test
fun `the blocking sequence runs on the injected io dispatcher, not the caller`() = runTest {
val migratorThread = CompletableDeferred<String>()
coEvery { accountDataMigrator.migrateIfNeeded() } coAnswers {
migratorThread.complete(Thread.currentThread().name)
}
provisioner().prepareCache()
assertEquals(
IO_THREAD_NAME,
migratorThread.await(),
"the startup work must run on the injected IO dispatcher, off the calling thread",
)
}
private companion object {
// 64 hex chars == a 32-byte SQLCipher passphrase, matching DatabaseKeyStore's format.
const val PASSPHRASE = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
const val IO_THREAD_NAME = "test-db-io-dispatcher"
}
}
@@ -0,0 +1,107 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local
import android.content.Context
import androidx.sqlite.db.SupportSQLiteDatabase
import androidx.sqlite.db.SupportSQLiteOpenHelper
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.Test
import java.util.concurrent.atomic.AtomicInteger
import kotlin.test.assertEquals
import kotlin.test.assertSame
/**
* [DeferredOpenHelperFactory] is the seam that keeps the blocking startup gate off Room's build/inject
* path (issue #93): the operations Room performs while BUILDING the database — `create()` and
* `setWriteAheadLoggingEnabled()` — must not touch the real delegate, and therefore must not run the
* gate. The delegate materialises only when the database is first OPENED (`writableDatabase` /
* `readableDatabase`), which Room does on its background query executor.
*/
class DeferredOpenHelperFactoryTest {
private fun configuration(name: String? = "test.db"): SupportSQLiteOpenHelper.Configuration {
val callback = object : SupportSQLiteOpenHelper.Callback(1) {
override fun onCreate(db: SupportSQLiteDatabase) = Unit
override fun onUpgrade(db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int) = Unit
}
return SupportSQLiteOpenHelper.Configuration.builder(mockk<Context>(relaxed = true))
.name(name)
.callback(callback)
.build()
}
@Test
fun `create and the build-time configuration calls never run the deferred gate`() {
val builds = AtomicInteger(0)
val factory = DeferredOpenHelperFactory {
builds.incrementAndGet()
mockk<SupportSQLiteOpenHelper>(relaxed = true)
}
val helper = factory.create(configuration(name = "libremail.db"))
// Everything Room touches while building the database must stay cheap.
assertEquals("libremail.db", helper.databaseName)
helper.setWriteAheadLoggingEnabled(true)
helper.setWriteAheadLoggingEnabled(false)
assertEquals(0, builds.get(), "building the database must not run the deferred startup gate")
}
@Test
fun `the delegate is built only on first open and then reused`() {
val builds = AtomicInteger(0)
val delegate = mockk<SupportSQLiteOpenHelper>(relaxed = true)
val factory = DeferredOpenHelperFactory {
builds.incrementAndGet()
delegate
}
val helper = factory.create(configuration())
assertEquals(0, builds.get())
// The first open materialises the delegate (and runs the gate exactly once)...
helper.writableDatabase
assertEquals(1, builds.get())
// ...and every later access reuses it, never re-running the gate.
helper.writableDatabase
helper.readableDatabase
assertEquals(1, builds.get())
}
@Test
fun `a WAL setting made before the first open is applied when the delegate is built`() {
val delegate = mockk<SupportSQLiteOpenHelper>(relaxed = true)
val factory = DeferredOpenHelperFactory { delegate }
val helper = factory.create(configuration())
helper.setWriteAheadLoggingEnabled(true) // recorded, not forwarded — there is no delegate yet
verify(exactly = 0) { delegate.setWriteAheadLoggingEnabled(any()) }
helper.writableDatabase // builds the delegate
verify(exactly = 1) { delegate.setWriteAheadLoggingEnabled(true) }
}
@Test
fun `close before any open is a no-op that never builds the delegate`() {
val builds = AtomicInteger(0)
val factory = DeferredOpenHelperFactory {
builds.incrementAndGet()
mockk<SupportSQLiteOpenHelper>(relaxed = true)
}
factory.create(configuration()).close()
assertEquals(0, builds.get(), "closing a never-opened helper must not build the delegate")
}
@Test
fun `writableDatabase delegates to the built helper`() {
val db = mockk<SupportSQLiteDatabase>(relaxed = true)
val delegate = mockk<SupportSQLiteOpenHelper>(relaxed = true)
every { delegate.writableDatabase } returns db
val factory = DeferredOpenHelperFactory { delegate }
assertSame(db, factory.create(configuration()).writableDatabase)
}
}