fix(di): defer database provisioning off the Hilt inject path #137
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user