diff --git a/app/src/androidTest/kotlin/org/libremail/data/local/DatabaseProvisionerInstrumentedTest.kt b/app/src/androidTest/kotlin/org/libremail/data/local/DatabaseProvisionerInstrumentedTest.kt index 58f53dc..ee49502 100644 --- a/app/src/androidTest/kotlin/org/libremail/data/local/DatabaseProvisionerInstrumentedTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/data/local/DatabaseProvisionerInstrumentedTest.kt @@ -142,6 +142,34 @@ class DatabaseProvisionerInstrumentedTest { } } + /** + * Issue #359: with `encryptCache` on and NO cache yet (a fresh install enabling encryption), the + * provisioner must load SQLCipher's native library, report [CacheOpenMode.Encrypted], and a real keyed + * open must then create and read the encrypted cache — i.e. `libsqlcipher.so` actually loads and runs. + * + * On a 16 KB memory-page device/image (Android 15+, and the CI API-37 preview `google_apis_ps16k` + * E2E image) an `.so` not aligned for 16 KB pages fails exactly here with `UnsatisfiedLinkError` at + * `SQLiteConnection.nativeOpen`. Running this on that image makes the 16 KB native-lib load a tested + * invariant, so a dependency bump that regressed alignment is caught in CI rather than on-device. + */ + @Test + fun freshEncryptOnStartLoadsThe16KbNativeLibAndOpensKeyedWithoutCrashing() = runBlocking { + every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = true, appLock = false)) + assertFalse("precondition: no cache file exists yet", dbFile.exists()) + + val mode = provisioner().prepareCache() + + assertEquals(CacheOpenMode.Encrypted(passphrase), mode) + // The keyed open must actually succeed on real SQLCipher — loading and using libsqlcipher.so on + // whatever ABI / page size this device or emulator image uses. + openEncrypted().apply { + messageDao().insertNew(listOf(message("acct:1"))) + assertEquals(listOf("acct:1"), messageDao().observeSummaries().first().map { it.id }) + close() + } + assertTrue("the fresh cache was created in SQLCipher (encrypted) form", DatabaseEncryption.isEncrypted(dbFile)) + } + @Test fun encryptionTurnedOffDecryptsAnEncryptedCacheToPlaintext() = runBlocking { every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = false, appLock = false)) 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 24ec574..490c51f 100644 --- a/app/src/main/kotlin/org/libremail/data/local/DatabaseProvisioner.kt +++ b/app/src/main/kotlin/org/libremail/data/local/DatabaseProvisioner.kt @@ -10,7 +10,10 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import org.libremail.data.security.DatabaseKeyStore +import org.libremail.data.settings.AppSettings import org.libremail.data.settings.SettingsRepository +import org.libremail.reporting.AppLog +import java.io.File import javax.inject.Inject import javax.inject.Singleton @@ -115,6 +118,24 @@ class DatabaseProvisioner internal constructor( // resolvePassphrase waits on PassphraseSession until the user authenticates — which is why this // must never run on the main thread while the cache is locked (issue #93). val settings = settingsRepository.settings.first() + return try { + resolveOpenMode(settings, dbFile) + } catch (nativeLoadFailure: LinkageError) { + // SQLCipher's native library could not be loaded/linked — most likely an `.so` not aligned + // for the 16 KB memory pages Android 15+ / SDK 37 devices use, which surfaces as an + // UnsatisfiedLinkError at SQLiteConnection.nativeOpen (issue #359). Rather than crash-loop the + // app on every cold start, degrade to an unencrypted cache. + degradeToUnencryptedCache(dbFile, nativeLoadFailure) + } + } + + /** + * The encryption gate (step 3 of [runStartupSequence]): convert the on-disk cache to the form the + * `encryptCache` setting asks for and report how Room must open it. Split out so a native-library + * load failure on either the encrypt or the decrypt-on-disable path (both need SQLCipher's `.so`) is + * caught in one place — see [runStartupSequence]'s handler and [degradeToUnencryptedCache]. + */ + private suspend fun resolveOpenMode(settings: AppSettings, dbFile: File): CacheOpenMode { val appLock = settings.appLock return when { settings.encryptCache -> { @@ -140,4 +161,26 @@ class DatabaseProvisioner internal constructor( else -> CacheOpenMode.Plaintext } } + + /** + * Fallback for issue #359 when SQLCipher's native library will not load on this device: encryption + * cannot apply, so open the cache unencrypted instead of throwing. Turns `encryptCache` off so the + * next start does not re-attempt (and re-wipe) the same failing conversion, and if the on-disk cache + * is currently ciphertext — which the plaintext framework opener cannot parse — clears it and resets + * its now-useless seals. The cache is a re-syncable copy of server mail, so clearing it loses nothing + * that cannot be re-fetched. + */ + private suspend fun degradeToUnencryptedCache(dbFile: File, cause: LinkageError): CacheOpenMode { + AppLog.w(TAG, "SQLCipher native library failed to load; opening the cache unencrypted", cause) + settingsRepository.setEncryptCache(false) + if (DatabaseEncryption.isEncrypted(dbFile)) { + DatabaseFiles.clear(context) + keyStore.resetSealedPassphrase() + } + return CacheOpenMode.Plaintext + } + + private companion object { + const val TAG = "DatabaseProvisioner" + } } 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 6265d05..942522e 100644 --- a/app/src/test/kotlin/org/libremail/data/local/DatabaseProvisionerTest.kt +++ b/app/src/test/kotlin/org/libremail/data/local/DatabaseProvisionerTest.kt @@ -10,6 +10,7 @@ import io.mockk.every import io.mockk.just import io.mockk.mockk import io.mockk.mockkObject +import io.mockk.mockkStatic import io.mockk.unmockkAll import io.mockk.verify import kotlinx.coroutines.CompletableDeferred @@ -56,6 +57,11 @@ class DatabaseProvisionerTest { .asCoroutineDispatcher() mockkObject(DatabaseEncryption) mockkObject(DatabaseFiles) + // `android.util.Log` is a no-op stub under plain JVM unit tests; the #359 degrade path breadcrumbs + // through AppLog.w, so statically mock Log (fully-qualified — a raw android.util.Log import is + // detekt-forbidden, epic #324) so it does not throw "not mocked". + mockkStatic(android.util.Log::class) + every { android.util.Log.w(any(), any(), any()) } returns 0 every { context.getDatabasePath(any()) } returns File("libremail.db") every { DatabaseFiles.clear(any()) } just Runs @@ -98,6 +104,42 @@ class DatabaseProvisionerTest { verify(exactly = 1) { DatabaseEncryption.ensureNativeLibraryLoaded() } } + @Test + fun `a native-library load failure degrades an encrypted cache to a plaintext open`() = runTest { + every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = true, appLock = false)) + coEvery { settingsRepository.setEncryptCache(any()) } just Runs + // Issue #359: on a 16 KB-page device the SQLCipher `.so` fails to load, throwing UnsatisfiedLinkError + // (a LinkageError) at the keyed open. The provisioner must degrade, not propagate the crash. + val nativeLoadFailure = UnsatisfiedLinkError("dlopen failed: libsqlcipher.so is not 16 KB aligned") + every { DatabaseEncryption.ensureNativeLibraryLoaded() } throws nativeLoadFailure + + val mode = provisioner().prepareCache() + + // Degrades to a working plaintext open and turns the setting off so the next start does not + // re-attempt the same failing conversion (matching the crash-report forensics: encryptCache=false). + assertEquals(CacheOpenMode.Plaintext, mode) + coVerify(exactly = 1) { settingsRepository.setEncryptCache(false) } + // The on-disk cache is plaintext here (isEncrypted stubbed false), so there is nothing to wipe. + verify(exactly = 0) { DatabaseFiles.clear(any()) } + } + + @Test + fun `a native-library load failure wipes an already-encrypted cache and resets its seals`() = runTest { + every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = true, appLock = false)) + coEvery { settingsRepository.setEncryptCache(any()) } just Runs + every { DatabaseEncryption.isEncrypted(any()) } returns true + val nativeLoadFailure = UnsatisfiedLinkError("dlopen failed: libsqlcipher.so is not 16 KB aligned") + every { DatabaseEncryption.ensureEncrypted(any(), any()) } throws nativeLoadFailure + + val mode = provisioner().prepareCache() + + assertEquals(CacheOpenMode.Plaintext, mode) + coVerify(exactly = 1) { settingsRepository.setEncryptCache(false) } + // Ciphertext the plaintext framework opener cannot parse is cleared, and its stale seal reset. + verify(exactly = 1) { DatabaseFiles.clear(any()) } + coVerify(exactly = 1) { keyStore.resetSealedPassphrase() } + } + @Test fun `prepareCache suspends on the auth-bound passphrase until it resolves`() = runTest { every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = true, appLock = true))