DatabaseModule.provideDatabase ran the entire startup sequence with runBlockingwhile Hilt constructed the singleton database — a DataStore read, a Keystore op, a possible SQLCipher re-key conversion, and (since #111) the cross-database AccountDataMigrator. All of it executed synchronously on whichever thread first injected the database, which can be the main thread → jank / ANR, worst with the encrypted cache on.
This moves that work off the injection path while preserving its ordering, encryption gate, and crash-safety.
How
DatabaseProvisioner.prepareCache() (new @Singleton) holds the one-time sequence — clear-pending wipe → AccountDataMigrator.migrateIfNeeded() → encryption gate — memoized on success, Mutex-guarded, run on the injected IO dispatcher. A failure is not memoized, so it retries (preserving the migrator's "crash-loop rather than lose data" contract: a throw means the cache never opens, so MIGRATION_15_16 never drops the not-yet-copied rows).
DeferredOpenHelperFactory (new) is a SupportSQLiteOpenHelper.Factory whose real delegate — and therefore the gate — is materialised only when Room first opens the database (writableDatabase/readableDatabase), which Room does on its background query executor. Everything Room touches at build/inject time (create(), setWriteAheadLoggingEnabled(), databaseName) stays cheap.
DatabaseModule / AccountDatabaseModule now build through that factory. provideDatabase picks the SQLCipher-vs-framework helper from prepareCache()'s result; provideAccountDatabase gates its open on the same prepareCache() — so the #111 migrate-before-open ordering (previously enforced by AccountDatabase's construction-time dependency on LibreMailDatabase) still holds regardless of which database Room opens first. The @Suppress("UNUSED_PARAMETER") cacheDatabase dependency is gone.
Net: injecting a DAO/database no longer blocks; the sequence runs once, on a background thread, before either file opens — same order, same effects.
Inject-time blocking found (the whole surface, incl. #118's migrator)
runBlocking { accountDataMigrator.migrateIfNeeded() } — the #111 cross-DB copy (DataStore read + SQLCipher ATTACH/copy), the extra startup work #118 added
All four now run inside prepareCache(), at open time, on the background executor.
Tests (JVM unit)
DatabaseProvisionerTest (8): wipe→migrate→encryption-gate ordering; migrate-before-encryption-gate (the #111 guarantee at the prepare level); encrypted / decrypt-when-turned-off / plaintext branches; single-run memoization; concurrent first-opens collapse to one migrator run; and that the blocking work runs on the injected IO dispatcher, not the caller's thread.
DeferredOpenHelperFactoryTest (5): create() and the build-time configuration calls never run the gate; the delegate is built only on first open and then reused; a pre-open WAL setting is applied when the delegate is built; close() before any open is a no-op.
No app-startup initializer was needed: the existing LibreMailApplication startup already triggers the first DB access off-main (its combine collector on Dispatchers.Default observes accounts), so prepareCache() is warmed early on a background thread. The gate guarantees correctness even if some other path opens first.
Left intentionally untouched (stale doc-comment references to provideDatabase doing the work, now in DatabaseProvisioner): AccountDataMigrator (its logic must not change — #111), and the app-lock/security files EncryptedCacheGuard / PassphraseSession / DatabaseKeyStore / AppLockViewModel (#99). The behaviour they describe still holds — opening a locked cache still parks a thread waiting on auth, just at background open-time instead of inject-time, which is strictly better. Worth a follow-up doc sweep.
## What
`DatabaseModule.provideDatabase` ran the entire startup sequence with `runBlocking` **while Hilt constructed the singleton database** — a DataStore read, a Keystore op, a possible SQLCipher re-key conversion, and (since #111) the cross-database `AccountDataMigrator`. All of it executed synchronously on whichever thread first injected the database, which can be the **main thread** → jank / ANR, worst with the encrypted cache on.
This moves that work **off the injection path** while preserving its ordering, encryption gate, and crash-safety.
## How
- **`DatabaseProvisioner.prepareCache()`** (new `@Singleton`) holds the one-time sequence — clear-pending wipe → `AccountDataMigrator.migrateIfNeeded()` → encryption gate — memoized on success, `Mutex`-guarded, run on the injected IO dispatcher. A failure is **not** memoized, so it retries (preserving the migrator's "crash-loop rather than lose data" contract: a throw means the cache never opens, so `MIGRATION_15_16` never drops the not-yet-copied rows).
- **`DeferredOpenHelperFactory`** (new) is a `SupportSQLiteOpenHelper.Factory` whose real delegate — and therefore the gate — is materialised only when Room first **opens** the database (`writableDatabase`/`readableDatabase`), which Room does on its **background query executor**. Everything Room touches at build/inject time (`create()`, `setWriteAheadLoggingEnabled()`, `databaseName`) stays cheap.
- **`DatabaseModule` / `AccountDatabaseModule`** now build through that factory. `provideDatabase` picks the SQLCipher-vs-framework helper from `prepareCache()`'s result; `provideAccountDatabase` gates its open on the same `prepareCache()` — so the **#111 migrate-before-open ordering** (previously enforced by AccountDatabase's construction-time dependency on `LibreMailDatabase`) still holds regardless of which database Room opens first. The `@Suppress("UNUSED_PARAMETER") cacheDatabase` dependency is gone.
Net: injecting a DAO/database no longer blocks; the sequence runs once, on a background thread, before either file opens — same order, same effects.
## Inject-time blocking found (the whole surface, incl. #118's migrator)
1. `runBlocking { keyStore.isClearPending() }` + the wipe (`DatabaseFiles.clear` + `resetSealedPassphrase` + `clearClearPending`)
2. `runBlocking { accountDataMigrator.migrateIfNeeded() }` — the #111 cross-DB copy (DataStore read + SQLCipher `ATTACH`/copy), the extra startup work #118 added
3. `runBlocking { settingsRepository.settings.first() }` — DataStore read
4. `runBlocking { keyStore.resolvePassphrase(appLock) }` + `DatabaseEncryption.ensureEncrypted`/`ensurePlaintext` — Keystore op + SQLCipher re-key conversion
All four now run inside `prepareCache()`, at open time, on the background executor.
## Tests (JVM unit)
- **`DatabaseProvisionerTest`** (8): wipe→migrate→encryption-gate ordering; migrate-before-encryption-gate (the #111 guarantee at the prepare level); encrypted / decrypt-when-turned-off / plaintext branches; single-run memoization; concurrent first-opens collapse to one migrator run; and that the blocking work runs on the injected IO dispatcher, **not** the caller's thread.
- **`DeferredOpenHelperFactoryTest`** (5): `create()` and the build-time configuration calls never run the gate; the delegate is built only on first open and then reused; a pre-open WAL setting is applied when the delegate is built; `close()` before any open is a no-op.
## Preflight (JDK 21)
`assembleDebug` + `testDebugUnitTest` + `lintDebug` + `ktlintCheck` + `detekt` + `compileDebugAndroidTestKotlin` all green.
## Notes for the maintainer
- No app-startup initializer was needed: the existing `LibreMailApplication` startup already triggers the first DB access off-main (its `combine` collector on `Dispatchers.Default` observes accounts), so `prepareCache()` is warmed early on a background thread. The gate guarantees correctness even if some other path opens first.
- Left intentionally untouched (stale doc-comment references to `provideDatabase` doing the work, now in `DatabaseProvisioner`): `AccountDataMigrator` (its logic must not change — #111), and the app-lock/security files `EncryptedCacheGuard` / `PassphraseSession` / `DatabaseKeyStore` / `AppLockViewModel` (#99). The behaviour they describe still holds — opening a locked cache still parks a thread waiting on auth, just at background open-time instead of inject-time, which is strictly better. Worth a follow-up doc sweep.
Closes #93
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
What
DatabaseModule.provideDatabaseran the entire startup sequence withrunBlockingwhile Hilt constructed the singleton database — a DataStore read, a Keystore op, a possible SQLCipher re-key conversion, and (since #111) the cross-databaseAccountDataMigrator. All of it executed synchronously on whichever thread first injected the database, which can be the main thread → jank / ANR, worst with the encrypted cache on.This moves that work off the injection path while preserving its ordering, encryption gate, and crash-safety.
How
DatabaseProvisioner.prepareCache()(new@Singleton) holds the one-time sequence — clear-pending wipe →AccountDataMigrator.migrateIfNeeded()→ encryption gate — memoized on success,Mutex-guarded, run on the injected IO dispatcher. A failure is not memoized, so it retries (preserving the migrator's "crash-loop rather than lose data" contract: a throw means the cache never opens, soMIGRATION_15_16never drops the not-yet-copied rows).DeferredOpenHelperFactory(new) is aSupportSQLiteOpenHelper.Factorywhose real delegate — and therefore the gate — is materialised only when Room first opens the database (writableDatabase/readableDatabase), which Room does on its background query executor. Everything Room touches at build/inject time (create(),setWriteAheadLoggingEnabled(),databaseName) stays cheap.DatabaseModule/AccountDatabaseModulenow build through that factory.provideDatabasepicks the SQLCipher-vs-framework helper fromprepareCache()'s result;provideAccountDatabasegates its open on the sameprepareCache()— so the #111 migrate-before-open ordering (previously enforced by AccountDatabase's construction-time dependency onLibreMailDatabase) still holds regardless of which database Room opens first. The@Suppress("UNUSED_PARAMETER") cacheDatabasedependency is gone.Net: injecting a DAO/database no longer blocks; the sequence runs once, on a background thread, before either file opens — same order, same effects.
Inject-time blocking found (the whole surface, incl. #118's migrator)
runBlocking { keyStore.isClearPending() }+ the wipe (DatabaseFiles.clear+resetSealedPassphrase+clearClearPending)runBlocking { accountDataMigrator.migrateIfNeeded() }— the #111 cross-DB copy (DataStore read + SQLCipherATTACH/copy), the extra startup work #118 addedrunBlocking { settingsRepository.settings.first() }— DataStore readrunBlocking { keyStore.resolvePassphrase(appLock) }+DatabaseEncryption.ensureEncrypted/ensurePlaintext— Keystore op + SQLCipher re-key conversionAll four now run inside
prepareCache(), at open time, on the background executor.Tests (JVM unit)
DatabaseProvisionerTest(8): wipe→migrate→encryption-gate ordering; migrate-before-encryption-gate (the #111 guarantee at the prepare level); encrypted / decrypt-when-turned-off / plaintext branches; single-run memoization; concurrent first-opens collapse to one migrator run; and that the blocking work runs on the injected IO dispatcher, not the caller's thread.DeferredOpenHelperFactoryTest(5):create()and the build-time configuration calls never run the gate; the delegate is built only on first open and then reused; a pre-open WAL setting is applied when the delegate is built;close()before any open is a no-op.Preflight (JDK 21)
assembleDebug+testDebugUnitTest+lintDebug+ktlintCheck+detekt+compileDebugAndroidTestKotlinall green.Notes for the maintainer
LibreMailApplicationstartup already triggers the first DB access off-main (itscombinecollector onDispatchers.Defaultobserves accounts), soprepareCache()is warmed early on a background thread. The gate guarantees correctness even if some other path opens first.provideDatabasedoing the work, now inDatabaseProvisioner):AccountDataMigrator(its logic must not change — #111), and the app-lock/security filesEncryptedCacheGuard/PassphraseSession/DatabaseKeyStore/AppLockViewModel(#99). The behaviour they describe still holds — opening a locked cache still parks a thread waiting on auth, just at background open-time instead of inject-time, which is strictly better. Worth a follow-up doc sweep.Closes #93
🤖 Generated with Claude Code