diff --git a/app/src/main/kotlin/org/libremail/data/local/DatabaseProvisioner.kt b/app/src/main/kotlin/org/libremail/data/local/DatabaseProvisioner.kt new file mode 100644 index 0000000..9015ce2 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/local/DatabaseProvisioner.kt @@ -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 + } + } +} diff --git a/app/src/main/kotlin/org/libremail/data/local/DeferredOpenHelperFactory.kt b/app/src/main/kotlin/org/libremail/data/local/DeferredOpenHelperFactory.kt new file mode 100644 index 0000000..f4bed86 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/data/local/DeferredOpenHelperFactory.kt @@ -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 + } + } +} diff --git a/app/src/main/kotlin/org/libremail/di/AccountDatabaseModule.kt b/app/src/main/kotlin/org/libremail/di/AccountDatabaseModule.kt index 435f929..60bac01 100644 --- a/app/src/main/kotlin/org/libremail/di/AccountDatabaseModule.kt +++ b/app/src/main/kotlin/org/libremail/di/AccountDatabaseModule.kt @@ -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() diff --git a/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt b/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt index d247ad1..8c0b43b 100644 --- a/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt +++ b/app/src/main/kotlin/org/libremail/di/DatabaseModule.kt @@ -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() diff --git a/app/src/test/kotlin/org/libremail/data/local/DatabaseProvisionerTest.kt b/app/src/test/kotlin/org/libremail/data/local/DatabaseProvisionerTest.kt new file mode 100644 index 0000000..7d2fa26 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/data/local/DatabaseProvisionerTest.kt @@ -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() + private val keyStore = mockk() + private val settingsRepository = mockk() + private val accountDataMigrator = mockk() + 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() + 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" + } +} diff --git a/app/src/test/kotlin/org/libremail/data/local/DeferredOpenHelperFactoryTest.kt b/app/src/test/kotlin/org/libremail/data/local/DeferredOpenHelperFactoryTest.kt new file mode 100644 index 0000000..28fb505 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/data/local/DeferredOpenHelperFactoryTest.kt @@ -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(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(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(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(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(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(relaxed = true) + val delegate = mockk(relaxed = true) + every { delegate.writableDatabase } returns db + val factory = DeferredOpenHelperFactory { delegate } + + assertSame(db, factory.create(configuration()).writableDatabase) + } +}