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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user