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
JMR-dev commented 2026-07-02 15:02:21 +00:00 (Migrated from github.com)

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

## 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)
Sign in to join this conversation.