#367 made the encrypted-cache open fail closed, but its try { … } catch (LinkageError) in DatabaseProvisioner.runStartupSequence wrapped onlyresolveOpenMode. Three keyed-open paths reach SQLiteConnection.nativeOpenoutside that handler, so an UnsatisfiedLinkError there still crash-loops on a device whose SQLCipher .so loads but won't link:
Migrator — AccountDataMigrator.migrateIfNeeded() (called before the handler) does a keyed openOrCreateDatabase + ATTACH … KEYeven when encryption is OFF (empty key), so every pre-#111 upgrader hit it.
Room's deferred open — the real keyed nativeOpen fires in DatabaseModule's DeferredOpenHelperFactoryafterprepareCache() returns — outside any handler.
Headless entry points — WorkManager workers + IdleService inject the cache with no UI gate (CacheEncryptionGate wraps only MainActivity).
Fix
Gaps 1–2: widen the fail-closed try to cover migrateIfNeeded(), and eagerly probe the real keyed open (DatabaseEncryption.probeKeyedOpen, a throwaway sibling file that never touches the real cache) inside the handler, so the deferred-open LinkageError becomes a CacheEncryptionUnavailableExceptionbefore Room can crash on it. The catch stays LinkageError-only on purpose — the migrator throws a non-linkage error on unexpected copy failure ("crash-loop rather than lose data") that must still propagate uncaught.
Gap 3: headless tolerance via Throwable.isCacheEncryptionUnavailable() (walks the cause chain — coroutine recovery re-wraps) + a retryIfEncryptedCacheUnavailable worker guard (soft Result.retry()) and an IdleService catch that logs PII-free and stopSelf()s. A later launch re-probes and recovers automatically.
Fail-closed semantics preserved: never opens plaintext, never wipes ciphertext, never writes encryptCache; the throw is not memoized, so recovery is automatic once the library links.
Tests
Unit: provisioner fail-closed paths, the isCacheEncryptionUnavailable cause-chain walk, and all four worker/service guards.
Local JVM gate green (assembleDebug + testDebugUnitTest + compileDebugAndroidTestKotlin). E2E runs via CI (local emulator host is being provisioned separately).
Closes #359.
## Problem
#367 made the encrypted-cache open fail closed, but its `try { … } catch (LinkageError)` in `DatabaseProvisioner.runStartupSequence` wrapped **only** `resolveOpenMode`. Three keyed-open paths reach `SQLiteConnection.nativeOpen` **outside** that handler, so an `UnsatisfiedLinkError` there still crash-loops on a device whose SQLCipher `.so` loads but won't link:
1. **Migrator** — `AccountDataMigrator.migrateIfNeeded()` (called before the handler) does a keyed `openOrCreateDatabase` + `ATTACH … KEY` **even when encryption is OFF** (empty key), so every pre-#111 upgrader hit it.
2. **Room's deferred open** — the real keyed `nativeOpen` fires in `DatabaseModule`'s `DeferredOpenHelperFactory` **after** `prepareCache()` returns — outside any handler.
3. **Headless entry points** — WorkManager workers + `IdleService` inject the cache with no UI gate (`CacheEncryptionGate` wraps only `MainActivity`).
## Fix
- **Gaps 1–2:** widen the fail-closed `try` to cover `migrateIfNeeded()`, and eagerly **probe the real keyed open** (`DatabaseEncryption.probeKeyedOpen`, a throwaway sibling file that never touches the real cache) inside the handler, so the deferred-open `LinkageError` becomes a `CacheEncryptionUnavailableException` **before** Room can crash on it. The catch stays **`LinkageError`-only** on purpose — the migrator throws a *non*-linkage error on unexpected copy failure ("crash-loop rather than lose data") that must still propagate uncaught.
- **Gap 3:** headless tolerance via `Throwable.isCacheEncryptionUnavailable()` (walks the cause chain — coroutine recovery re-wraps) + a `retryIfEncryptedCacheUnavailable` worker guard (soft `Result.retry()`) and an `IdleService` catch that logs PII-free and `stopSelf()`s. A later launch re-probes and recovers automatically.
Fail-closed semantics preserved: never opens plaintext, never wipes ciphertext, never writes `encryptCache`; the throw is not memoized, so recovery is automatic once the library links.
## Tests
- **Unit:** provisioner fail-closed paths, the `isCacheEncryptionUnavailable` cause-chain walk, and all four worker/service guards.
- **Instrumented:** `DatabaseEncryptionProbeInstrumentedTest` exercises `probeKeyedOpen` on-device.
- Local JVM gate green (`assembleDebug` + `testDebugUnitTest` + `compileDebugAndroidTestKotlin`). **E2E runs via CI** (local emulator host is being provisioned separately).
## Merge
Security-sensitive (fail-closed crypto path) — **not** arming auto-merge; requesting maintainer review.
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.
Closes #359.
Problem
#367 made the encrypted-cache open fail closed, but its
try { … } catch (LinkageError)inDatabaseProvisioner.runStartupSequencewrapped onlyresolveOpenMode. Three keyed-open paths reachSQLiteConnection.nativeOpenoutside that handler, so anUnsatisfiedLinkErrorthere still crash-loops on a device whose SQLCipher.soloads but won't link:AccountDataMigrator.migrateIfNeeded()(called before the handler) does a keyedopenOrCreateDatabase+ATTACH … KEYeven when encryption is OFF (empty key), so every pre-#111 upgrader hit it.nativeOpenfires inDatabaseModule'sDeferredOpenHelperFactoryafterprepareCache()returns — outside any handler.IdleServiceinject the cache with no UI gate (CacheEncryptionGatewraps onlyMainActivity).Fix
tryto covermigrateIfNeeded(), and eagerly probe the real keyed open (DatabaseEncryption.probeKeyedOpen, a throwaway sibling file that never touches the real cache) inside the handler, so the deferred-openLinkageErrorbecomes aCacheEncryptionUnavailableExceptionbefore Room can crash on it. The catch staysLinkageError-only on purpose — the migrator throws a non-linkage error on unexpected copy failure ("crash-loop rather than lose data") that must still propagate uncaught.Throwable.isCacheEncryptionUnavailable()(walks the cause chain — coroutine recovery re-wraps) + aretryIfEncryptedCacheUnavailableworker guard (softResult.retry()) and anIdleServicecatch that logs PII-free andstopSelf()s. A later launch re-probes and recovers automatically.Fail-closed semantics preserved: never opens plaintext, never wipes ciphertext, never writes
encryptCache; the throw is not memoized, so recovery is automatic once the library links.Tests
isCacheEncryptionUnavailablecause-chain walk, and all four worker/service guards.DatabaseEncryptionProbeInstrumentedTestexercisesprobeKeyedOpenon-device.assembleDebug+testDebugUnitTest+compileDebugAndroidTestKotlin). E2E runs via CI (local emulator host is being provisioned separately).Merge
Security-sensitive (fail-closed crypto path) — not arming auto-merge; requesting maintainer review.
Merge Queue Status
2026-07-08 03:06 UTC· Rule:default· triggered by merge protections2026-07-08 03:06 UTC· at8cacff6b4a1b3adcb3e07f4ae6979a668bdee27e· mergeThis pull request spent 8 seconds in the queue, including 1 second running CI.
Required conditions to merge
-conflict-draftbase = maincheck-success = CI passedgithub-review-approved[🛡 GitHub repository ruleset rulemain]label != brokencheck-success = Debug buildcheck-neutral = Debug buildcheck-skipped = Debug buildcheck-success = Unit testscheck-neutral = Unit testscheck-skipped = Unit testscheck-success = CI passedcheck-neutral = CI passedcheck-skipped = CI passedmain]:check-success = @github-actions/CI passedcheck-neutral = @github-actions/CI passedcheck-skipped = @github-actions/CI passed