From 592a797dd027bda547dab172261798f4a3f8106c Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Fri, 3 Jul 2026 09:22:03 -0500 Subject: [PATCH] fix(cache): load SQLCipher native lib before every keyed open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../org/libremail/data/local/DatabaseProvisioner.kt | 7 +++++++ .../org/libremail/data/local/DatabaseProvisionerTest.kt | 9 +++++++++ 2 files changed, 16 insertions(+) diff --git a/app/src/main/kotlin/org/libremail/data/local/DatabaseProvisioner.kt b/app/src/main/kotlin/org/libremail/data/local/DatabaseProvisioner.kt index 9015ce2..24ec574 100644 --- a/app/src/main/kotlin/org/libremail/data/local/DatabaseProvisioner.kt +++ b/app/src/main/kotlin/org/libremail/data/local/DatabaseProvisioner.kt @@ -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) } diff --git a/app/src/test/kotlin/org/libremail/data/local/DatabaseProvisionerTest.kt b/app/src/test/kotlin/org/libremail/data/local/DatabaseProvisionerTest.kt index 7d2fa26..d1563e4 100644 --- a/app/src/test/kotlin/org/libremail/data/local/DatabaseProvisionerTest.kt +++ b/app/src/test/kotlin/org/libremail/data/local/DatabaseProvisionerTest.kt @@ -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