fix(cache): load SQLCipher native lib before every keyed open

The opt-in encrypted cache crash-looped on launch (UnsatisfiedLinkError:
No implementation found for SQLiteConnection.nativeOpen) on any cold start
after encryption was enabled — reported after an app upgrade.

System.loadLibrary("sqlcipher") was only invoked as a side effect of an
actual plaintext<->encrypted conversion (DatabaseEncryption.migrate) or the
one-time #111 account migration. On a steady-state start the cache is
already encrypted and the account migration is already done, so both no-op
and nothing loads the native library before Room opens the keyed database
via SupportOpenHelperFactory -> nativeOpen. The previous process survived
only because an earlier conversion had loaded the .so in-memory; the next
cold start (e.g. an upgrade) crashes.

Load the library explicitly in DatabaseProvisioner whenever it commits to an
encrypted open (idempotent; no-ops when already loaded). Add regression
assertions: the encrypted path must load it, the plaintext path must not.

Verified on a Pixel 10 Pro XL — an in-place update preserving the already-
encrypted cache now launches to the mailbox instead of crash-looping.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-03 09:22:03 -05:00
co-authored by Claude Opus 4.8
parent 54c941d771
commit 592a797dd0
2 changed files with 16 additions and 0 deletions
@@ -120,6 +120,13 @@ class DatabaseProvisioner internal constructor(
settings.encryptCache -> {
val passphrase = keyStore.resolvePassphrase(appLock)
DatabaseEncryption.ensureEncrypted(dbFile, passphrase)
// Room is about to open the cache with SQLCipher (SupportOpenHelperFactory), so its
// native library must already be loaded. ensureEncrypted() above loads it only as a
// side effect of an actual plaintext -> encrypted conversion; on a steady-state start
// (cache already encrypted, nothing to convert) that no-ops, so without this explicit
// load the keyed open reaches SQLiteConnection.nativeOpen with no library loaded and
// crashes with UnsatisfiedLinkError on every cold start once encryption is enabled.
DatabaseEncryption.ensureNativeLibraryLoaded()
CacheOpenMode.Encrypted(passphrase)
}
@@ -61,6 +61,7 @@ class DatabaseProvisionerTest {
every { DatabaseEncryption.isEncrypted(any()) } returns false
every { DatabaseEncryption.ensureEncrypted(any(), any()) } just Runs
every { DatabaseEncryption.ensurePlaintext(any(), any()) } just Runs
every { DatabaseEncryption.ensureNativeLibraryLoaded() } just Runs
every { settingsRepository.settings } returns flowOf(AppSettings())
coEvery { keyStore.isClearPending() } returns false
@@ -89,6 +90,11 @@ class DatabaseProvisionerTest {
coVerify(exactly = 1) { keyStore.resolvePassphrase(false) }
verify(exactly = 1) { DatabaseEncryption.ensureEncrypted(any(), PASSPHRASE) }
verify(exactly = 0) { DatabaseEncryption.ensurePlaintext(any(), any()) }
// Regression (crash-on-launch after upgrade): the encrypted open path MUST load SQLCipher's
// native library itself. ensureEncrypted no-ops when the cache is already encrypted, so if the
// load only rode on that conversion, Room's keyed open would hit nativeOpen with no .so loaded
// and throw UnsatisfiedLinkError on every cold start.
verify(exactly = 1) { DatabaseEncryption.ensureNativeLibraryLoaded() }
}
@Test
@@ -143,6 +149,9 @@ class DatabaseProvisionerTest {
coVerify(exactly = 0) { keyStore.resolvePassphrase(any()) }
verify(exactly = 0) { DatabaseEncryption.ensureEncrypted(any(), any()) }
verify(exactly = 0) { DatabaseEncryption.ensurePlaintext(any(), any()) }
// A plaintext cache opens with the framework helper, never SQLCipher, so it must not touch the
// native library — the counterpart to the encrypted path's mandatory load above.
verify(exactly = 0) { DatabaseEncryption.ensureNativeLibraryLoaded() }
}
@Test