Merge branch 'main' into test-376-robolectric-richtext

This commit is contained in:
Jason Ross
2026-07-06 19:54:03 -05:00
committed by GitHub
17 changed files with 970 additions and 16 deletions
+9
View File
@@ -337,6 +337,15 @@ val jacocoNonJvmTestableSurface = listOf(
"**/SettingsComponents*",
"**/SignatureEditScreen*",
"**/SignaturesScreen*",
// CacheEncryptionGate.kt (issue #359/#367 fail-closed encryption gate) is pure render: the gate
// composable, its blank cover, the error screen, and the ephemeral report-review screen — no plain
// top-level logic. Spelled out to "...GateKt*" (the file's compiled facade class), NOT the bare
// "**/CacheEncryptionGate*" this list otherwise uses, because unlike every Screen/ViewModel pair
// above, CacheEncryptionGateViewModel's name literally starts with "CacheEncryptionGate" — a bare
// wildcard would also swallow the (94%-covered, dedicated-tested) ViewModel and its sealed
// CacheEncryptionGateState. CacheEncryptionGateViewModel and CacheEncryptionUnavailableException
// stay in scope (both have JVM tests: CacheEncryptionGateViewModelTest, DatabaseProvisionerTest).
"**/CacheEncryptionGateKt*",
// --- Android framework entry points (OS-instantiated). NB: Workers are intentionally NOT here
// --- (they are unit-tested — see the KEPT IN SCOPE note above).
"**/*Activity*",
@@ -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<Unit> {
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<Unit> {
every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = false, appLock = false))
@@ -0,0 +1,109 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.di
import android.content.Context
import android.content.ContextWrapper
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.mockk.Runs
import io.mockk.coEvery
import io.mockk.every
import io.mockk.just
import io.mockk.mockk
import io.mockk.mockkObject
import io.mockk.unmockkAll
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.data.local.AccountDataMigrator
import org.libremail.data.local.DatabaseEncryption
import org.libremail.data.local.DatabaseFiles
import org.libremail.data.local.DatabaseProvisioner
import org.libremail.data.security.DatabaseKeyStore
import org.libremail.data.settings.AppSettings
import org.libremail.data.settings.SettingsRepository
import java.io.File
/**
* Pins the fail-closed contract's ONE resilience exception (issue #359): the plaintext account store is
* never encrypted and never uses SQLCipher, so a cache-encryption native-load failure — which the
* provisioner surfaces as `CacheEncryptionUnavailableException` — must NOT brick it. If it did, the app
* couldn't read accounts to render the encryption error gate or assemble the PII-free problem report.
*
* Mirrors [DatabaseModuleInstrumentedTest]'s style: MockK collaborators, a real [ContextWrapper] (never
* `mockk<Context>()`, which trips an ART parameter-annotation mismatch on API 31/32), and a real
* [DatabaseProvisioner] whose encryption gate is forced to fail via a spied [DatabaseEncryption].
*/
@RunWith(AndroidJUnit4::class)
class AccountDatabaseModuleInstrumentedTest {
private val appContext = ApplicationProvider.getApplicationContext<Context>()
private val cacheDbName = "accountmodule_cache_test.db"
private val accountsDbName = "accountmodule_accounts_test.db"
private val cacheFile: File get() = appContext.getDatabasePath(cacheDbName)
private val accountsFile: File get() = appContext.getDatabasePath(accountsDbName)
// 64 hex chars == a 32-byte SQLCipher passphrase.
private val passphrase = "0123456789abcdef".repeat(4)
private val keyStore = mockk<DatabaseKeyStore>()
private val settingsRepository = mockk<SettingsRepository>()
private val migrator = mockk<AccountDataMigrator>()
// Route the provisioner's cache lookup and Room's account-store lookup to this test's private files,
// never the app's real databases.
private val context: Context = object : ContextWrapper(appContext) {
override fun getDatabasePath(name: String): File = when (name) {
DatabaseFiles.NAME -> cacheFile
DatabaseFiles.ACCOUNTS_NAME -> accountsFile
else -> super.getDatabasePath(name)
}
}
@Before
fun setUp() {
clean()
coEvery { keyStore.isClearPending() } returns false
coEvery { keyStore.resolvePassphrase(any()) } returns passphrase
coEvery { migrator.migrateIfNeeded() } just Runs
}
@After
fun tearDown() {
unmockkAll()
clean()
}
private fun clean() {
listOf(cacheDbName, accountsDbName).forEach { name ->
appContext.deleteDatabase(name)
appContext.getDatabasePath(name).parentFile?.listFiles { f -> f.name.startsWith(name) }
?.forEach { it.delete() }
}
}
private fun provisioner() = DatabaseProvisioner(context, keyStore, settingsRepository, migrator, Dispatchers.IO)
@Test
fun accountStoreStillOpensWhenTheCacheEncryptionLibraryFailsToLoad() = runBlocking<Unit> {
every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = true, appLock = false))
mockkObject(DatabaseEncryption) // spy: real impls run except the forced failure below
// Fault injection: the encrypted-cache gate can't load SQLCipher, so prepareCache() fails closed
// with CacheEncryptionUnavailableException — the exact condition provideAccountDatabase tolerates.
every { DatabaseEncryption.ensureNativeLibraryLoaded() } throws
UnsatisfiedLinkError("dlopen failed: libsqlcipher.so is not loadable")
val database = AccountDatabaseModule.provideAccountDatabase(context, provisioner())
try {
// The plaintext account store opens and a query succeeds despite the cache-encryption failure.
assertEquals(emptyList<Any>(), database.accountDao().getAll())
} finally {
database.close()
}
}
}
@@ -0,0 +1,55 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.security
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import org.libremail.R
import org.libremail.ui.theme.LibreMailTheme
/**
* UI coverage for the fail-closed encrypted-cache error screen (issue #359). [CacheEncryptionErrorScreen]
* is presentational (its report action is wired by the caller), so it is exercised in isolation: the
* verbatim error message shows, and tapping "Report a problem" reports back.
*/
@RunWith(AndroidJUnit4::class)
class CacheEncryptionErrorScreenTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
private fun string(resId: Int) = composeTestRule.activity.getString(resId)
private fun setContent(onReportProblem: () -> Unit = {}) {
composeTestRule.setContent {
LibreMailTheme(darkTheme = false, dynamicColor = false) {
CacheEncryptionErrorScreen(onReportProblem = onReportProblem)
}
}
}
@Test
fun showsTheVerbatimErrorMessageAndReportAction() {
setContent()
// The exact maintainer-specified message must render, unchanged.
composeTestRule.onNodeWithText(string(R.string.cache_encryption_error_message)).assertIsDisplayed()
composeTestRule.onNodeWithText(string(R.string.cache_encryption_report_action)).assertIsDisplayed()
}
@Test
fun tappingReportProblem_invokesCallback() {
var reported = false
setContent(onReportProblem = { reported = true })
composeTestRule.onNodeWithText(string(R.string.cache_encryption_report_action)).performClick()
composeTestRule.waitUntil(5_000) { reported }
}
}
@@ -21,6 +21,7 @@ import org.libremail.ui.LibreMailApp
import org.libremail.ui.compose.ComposePrefill
import org.libremail.ui.compose.IntentComposeParser
import org.libremail.ui.lock.AppLockGateHost
import org.libremail.ui.security.CacheEncryptionGate
import org.libremail.ui.theme.LibreMailTheme
import javax.inject.Inject
@@ -73,12 +74,18 @@ class MainActivity : FragmentActivity() {
// Gate the whole app behind the screen-lock when app-lock is enabled. When it is off
// the gate resolves straight to the content, so this is a no-op for most users.
AppLockGateHost {
LibreMailApp(
pendingCompose = pendingCompose.value,
onComposeHandled = { pendingCompose.value = null },
pendingOpenMessageId = pendingOpenMessageId.value,
onOpenMessageHandled = { pendingOpenMessageId.value = null },
)
// Inside the app-lock gate (so the auth-bound passphrase is already unlocked): fail
// closed if the encrypted cache's SQLCipher library won't load (#359), showing the
// error gate instead of ever opening the cache unencrypted. Resolves straight to the
// content when the cache is openable, so it is a no-op for most users.
CacheEncryptionGate {
LibreMailApp(
pendingCompose = pendingCompose.value,
onComposeHandled = { pendingCompose.value = null },
pendingOpenMessageId = pendingOpenMessageId.value,
onOpenMessageHandled = { pendingOpenMessageId.value = null },
)
}
}
}
}
@@ -0,0 +1,24 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local
/**
* Raised by [DatabaseProvisioner] when the opt-in encrypted cache cannot be opened because SQLCipher's
* native library failed to load or link on this device (issue #359 — e.g. an `.so` the platform
* rejects, surfacing as an `UnsatisfiedLinkError`/`LinkageError` at `SQLiteConnection.nativeOpen` or
* from [DatabaseEncryption.ensureNativeLibraryLoaded]).
*
* The app must **fail closed**: it must NOT fall back to an unencrypted cache (that would silently
* defeat the user's opt-in encryption), NOT wipe the on-disk ciphertext, and NOT touch the
* `encryptCache` setting. Instead this distinct, expected signal is surfaced so the startup UI
* (`CacheEncryptionGate`) can show the encryption error gate — not the mailbox, and not a crash.
*
* Deliberately a dedicated type (not a bare [LinkageError]) so only this precise condition is treated
* as "encryption unavailable"; any other failure still propagates. The provisioner never memoizes it,
* so a later launch — where the library may load, e.g. after an app update — re-attempts and recovers
* automatically.
*/
class CacheEncryptionUnavailableException(cause: Throwable) :
Exception(
"Encrypted cache unavailable: the SQLCipher native library failed to load on this device",
cause,
)
@@ -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,35 @@ 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) {
// FAIL CLOSED (issue #359, security rework of #367). SQLCipher's native library could not be
// loaded/linked (e.g. UnsatisfiedLinkError at SQLiteConnection.nativeOpen or from
// ensureNativeLibraryLoaded), so the encrypted cache cannot be opened OR converted. We must
// NOT silently degrade to an unencrypted cache (that would defeat the user's opt-in
// encryption), so we deliberately do NOT: open plaintext, wipe the on-disk ciphertext, or
// write the encryptCache setting. Instead raise a distinct signal the startup UI catches to
// show the encryption error gate. This throw is NOT memoized (it skips prepareCache's
// `.also { prepared = it }`), so the next launch re-attempts and recovers automatically if
// the library later loads.
AppLog.w(
TAG,
"SQLCipher native library failed to load; failing closed (encrypted cache unavailable)",
nativeLoadFailure,
)
throw CacheEncryptionUnavailableException(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, which fails closed by raising
* [CacheEncryptionUnavailableException] rather than degrading to an unencrypted cache.
*/
private suspend fun resolveOpenMode(settings: AppSettings, dbFile: File): CacheOpenMode {
val appLock = settings.appLock
return when {
settings.encryptCache -> {
@@ -140,4 +172,8 @@ class DatabaseProvisioner internal constructor(
else -> CacheOpenMode.Plaintext
}
}
private companion object {
const val TAG = "DatabaseProvisioner"
}
}
@@ -1,9 +1,12 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.security
import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.security.keystore.StrongBoxUnavailableException
import android.util.Base64
import org.libremail.reporting.AppLog
import java.security.GeneralSecurityException
import java.security.KeyStore
import javax.crypto.AEADBadTagException
@@ -115,23 +118,52 @@ abstract class AesGcmKeystoreCipher(private val alias: String, private val gener
// Synchronized so two concurrent first-run encrypts can't both generate a key under the same
// alias — the second would overwrite the first, leaving the first secret undecryptable.
protected open fun getOrCreateKey(): SecretKey = synchronized(keyLock) {
existingKey()?.let { return it }
existingKey() ?: generateKeyWithStrongBoxFallback()
}
/**
* Mint the key, preferring the hardware **StrongBox** secure element (a dedicated tamper-resistant
* chip) so the non-exportable key is bound to the strongest keystore available. Devices without
* StrongBox report [StrongBoxUnavailableException] at generation time; we then regenerate a
* TEE-backed key so key creation still succeeds on every device. Both the master ([KeystoreCrypto])
* and auth-bound ([DatabaseKeyCipher]) keys inherit this through the shared base.
*/
private fun generateKeyWithStrongBoxFallback(): SecretKey = try {
generateKey(strongBox = true)
} catch (e: StrongBoxUnavailableException) {
// Expected on devices with no StrongBox — not an error. PII-free (a device-capability fact).
AppLog.i(TAG, "StrongBox unavailable for Keystore alias '$alias'; using a TEE-backed key: ${e.message}")
generateKey(strongBox = false)
}
/**
* Test seam over the raw Android Keystore key generation for a given [strongBox] preference. The
* real [KeyGenerator] is device-only, so JVM unit tests override this to exercise the StrongBox
* fallback in [generateKeyWithStrongBoxFallback] without a Keystore.
*/
protected open fun generateKey(strongBox: Boolean): SecretKey {
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, ANDROID_KEYSTORE)
generator.init(keySpec())
generator.generateKey()
generator.init(keySpec(strongBox))
return generator.generateKey()
}
/** The alias-bound [KeyGenParameterSpec] for this key; subclasses extend [keySpecBuilder]. */
protected abstract fun keySpec(): KeyGenParameterSpec
protected abstract fun keySpec(strongBox: Boolean): KeyGenParameterSpec
/** The common AES-256-GCM builder (encrypt + decrypt, GCM, no padding, 256-bit) to extend. */
protected fun keySpecBuilder(): KeyGenParameterSpec.Builder = KeyGenParameterSpec.Builder(
protected fun keySpecBuilder(strongBox: Boolean): KeyGenParameterSpec.Builder = KeyGenParameterSpec.Builder(
alias,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.setKeySize(AES_KEY_SIZE_BITS)
.apply {
// Bind the key to the StrongBox secure element when requested and supported (API 28+; minSdk
// is 29, so the guard is defensive). If the device has no StrongBox, generateKey() catches
// StrongBoxUnavailableException and retries with strongBox = false for a TEE-backed key.
if (strongBox && Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) setIsStrongBoxBacked(true)
}
private companion object {
const val ANDROID_KEYSTORE = "AndroidKeyStore"
@@ -139,5 +171,6 @@ abstract class AesGcmKeystoreCipher(private val alias: String, private val gener
const val IV_LENGTH = 12
const val TAG_BITS = 128
const val AES_KEY_SIZE_BITS = 256
const val TAG = "AesGcmKeystoreCipher"
}
}
@@ -82,8 +82,8 @@ class DatabaseKeyCipher @Inject constructor() :
/** A missing auth-bound key means it was invalidated; surface that instead of regenerating. */
override fun onMissingDecryptionKey(): Nothing = error("auth-bound database key is missing")
override fun keySpec(): KeyGenParameterSpec {
val builder = keySpecBuilder()
override fun keySpec(strongBox: Boolean): KeyGenParameterSpec {
val builder = keySpecBuilder(strongBox)
.setUserAuthenticationRequired(true)
.setInvalidatedByBiometricEnrollment(true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
@@ -18,7 +18,7 @@ import javax.inject.Singleton
@Singleton
class KeystoreCrypto @Inject constructor() : AesGcmKeystoreCipher(alias = KEY_ALIAS, generateKeyOnDecrypt = true) {
override fun keySpec(): KeyGenParameterSpec = keySpecBuilder().build()
override fun keySpec(strongBox: Boolean): KeyGenParameterSpec = keySpecBuilder(strongBox).build()
private companion object {
const val KEY_ALIAS = "libremail.master.key"
@@ -12,6 +12,7 @@ import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.runBlocking
import org.libremail.data.local.ACCOUNT_MIGRATION_1_2
import org.libremail.data.local.AccountDatabase
import org.libremail.data.local.CacheEncryptionUnavailableException
import org.libremail.data.local.DatabaseFiles.ACCOUNTS_NAME
import org.libremail.data.local.DatabaseProvisioner
import org.libremail.data.local.DeferredOpenHelperFactory
@@ -19,6 +20,7 @@ import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.dao.AccountSettingsDao
import org.libremail.data.local.dao.CredentialDao
import org.libremail.data.local.dao.SignatureDao
import org.libremail.reporting.AppLog
import javax.inject.Singleton
/**
@@ -37,6 +39,13 @@ object AccountDatabaseModule {
* the migrate-before-open ordering the old construction-time dependency on `LibreMailDatabase`
* enforced, now moved OFF the injection path (issue #93). This store always opens unkeyed, so it
* ignores the returned cache open-mode and only awaits the shared sequence.
*
* This store is plaintext and never uses SQLCipher, so a cache-encryption native-load failure
* (issue #359, surfaced as [CacheEncryptionUnavailableException]) must NOT brick it: the app still
* needs accounts/credentials to render the encryption error gate and let the user file a PII-free
* problem report. The wipe + migrate steps run BEFORE the encryption gate that can throw, so the
* migrate-before-open ordering still holds when we tolerate that one specific failure here; any
* other failure still propagates.
*/
@Provides
@Singleton
@@ -47,7 +56,11 @@ object AccountDatabaseModule {
.addMigrations(ACCOUNT_MIGRATION_1_2)
.openHelperFactory(
DeferredOpenHelperFactory { configuration ->
runBlocking { provisioner.prepareCache() }
try {
runBlocking { provisioner.prepareCache() }
} catch (e: CacheEncryptionUnavailableException) {
AppLog.w(TAG, "cache encryption unavailable; opening the plaintext account store anyway", e)
}
FrameworkSQLiteOpenHelperFactory().create(configuration)
},
)
@@ -64,4 +77,6 @@ object AccountDatabaseModule {
@Provides
fun provideSignatureDao(database: AccountDatabase): SignatureDao = database.signatureDao()
private const val TAG = "AccountDatabaseModule"
}
@@ -0,0 +1,265 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.security
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Lock
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.libremail.R
import org.libremail.ui.reporting.copyReportPayloadToClipboard
/**
* Fail-closed gate for the opt-in encrypted cache (issue #359). Wraps the whole app: while
* [CacheEncryptionGateViewModel] resolves whether the encrypted cache can be opened it shows a blank
* cover, and [content] — the real app — composes only once the gate reports
* [CacheEncryptionGateState.Ready]. If SQLCipher's native library will not load, the gate shows
* [CacheEncryptionErrorScreen] instead of ever opening the cache unencrypted or reaching the mailbox.
*
* Hosted INSIDE the app-lock gate (`AppLockGateHost`), so when app-lock is on the passphrase is already
* unlocked before the probe runs.
*/
@Composable
fun CacheEncryptionGate(viewModel: CacheEncryptionGateViewModel = hiltViewModel(), content: @Composable () -> Unit) {
val state by viewModel.state.collectAsStateWithLifecycle()
when (state) {
CacheEncryptionGateState.Checking -> GateCover()
CacheEncryptionGateState.Ready -> content()
CacheEncryptionGateState.Unavailable -> CacheEncryptionErrorFlow(viewModel)
}
}
/** Opaque cover shown while the gate resolves, so no DB-backed screen is visible before the decision. */
@Composable
private fun GateCover() {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {}
}
/** Error screen ↔ ephemeral report review, kept off the app's NavHost (the app never composed here). */
@Composable
private fun CacheEncryptionErrorFlow(viewModel: CacheEncryptionGateViewModel) {
var reviewing by rememberSaveable { mutableStateOf(false) }
if (reviewing) {
val payload by viewModel.reportPayload.collectAsStateWithLifecycle()
EphemeralReportReviewScreen(
payload = payload,
onBack = {
viewModel.dismissReport()
reviewing = false
},
)
} else {
CacheEncryptionErrorScreen(
onReportProblem = {
viewModel.prepareReport()
reviewing = true
},
)
}
}
/**
* Full-screen fail-closed notice shown when the encrypted cache cannot be opened. Presentational: the
* verbatim error message, a reassurance that nothing was lost, and a "Report a problem" action wired by
* the caller. Deliberately renders no mailbox content.
*/
@Composable
fun CacheEncryptionErrorScreen(onReportProblem: () -> Unit) {
Surface(modifier = Modifier.fillMaxSize(), color = MaterialTheme.colorScheme.background) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
imageVector = Icons.Filled.Lock,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
)
Spacer(Modifier.height(16.dp))
Text(
text = stringResource(R.string.cache_encryption_error_title),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(8.dp))
// The exact maintainer-specified message; do not reword.
Text(
text = stringResource(R.string.cache_encryption_error_message),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(16.dp))
Text(
text = stringResource(R.string.cache_encryption_error_body),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(24.dp))
Button(onClick = onReportProblem) {
Text(stringResource(R.string.cache_encryption_report_action))
}
}
}
}
/**
* Ephemeral review of the PII-free diagnostic report generated from the error gate. The report is held
* only in memory (never written to disk); the copy states that plainly. The user can Copy it to the
* clipboard or Save it to a file they choose — the only ways it leaves this screen.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun EphemeralReportReviewScreen(payload: String?, onBack: () -> Unit) {
val context = LocalContext.current
val clipboard = LocalClipboard.current
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val copiedMessage = stringResource(R.string.report_copied)
val savedMessage = stringResource(R.string.report_saved)
val saveLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.CreateDocument("application/json"),
) { uri ->
val text = payload
if (uri != null && text != null) {
scope.launch {
withContext(Dispatchers.IO) {
runCatching {
context.contentResolver.openOutputStream(uri)?.use { it.write(text.toByteArray()) }
}
}
snackbarHostState.showSnackbar(savedMessage)
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = { Text(stringResource(R.string.report_review_title)) },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.action_back),
)
}
},
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
) { innerPadding ->
Column(
Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(rememberScrollState())
.padding(16.dp),
) {
Text(
stringResource(R.string.cache_encryption_report_ephemeral_notice),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
if (payload == null) {
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) {
CircularProgressIndicator()
}
Spacer(Modifier.height(8.dp))
Text(
stringResource(R.string.cache_encryption_report_generating),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
SelectionContainer {
Surface(
color = MaterialTheme.colorScheme.surfaceVariant,
shape = MaterialTheme.shapes.small,
modifier = Modifier.fillMaxWidth(),
) {
Text(
text = payload,
style = MaterialTheme.typography.bodySmall,
fontFamily = FontFamily.Monospace,
modifier = Modifier.padding(12.dp),
)
}
}
Spacer(Modifier.height(16.dp))
Row(Modifier.fillMaxWidth()) {
TextButton(
onClick = {
scope.launch {
copyReportPayloadToClipboard(clipboard, payload)
snackbarHostState.showSnackbar(copiedMessage)
}
},
modifier = Modifier.weight(1f),
) {
Text(stringResource(R.string.report_copy))
}
Spacer(Modifier.width(8.dp))
TextButton(
onClick = { saveLauncher.launch("libremail-report.json") },
modifier = Modifier.weight(1f),
) {
Text(stringResource(R.string.report_save))
}
}
}
}
}
}
@@ -0,0 +1,111 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.security
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.libremail.data.local.CacheEncryptionUnavailableException
import org.libremail.data.local.DatabaseProvisioner
import org.libremail.reporting.AppLog
import org.libremail.reporting.DiagnosticsCollector
import javax.inject.Inject
/** State of the fail-closed encrypted-cache gate that wraps the app (issue #359). */
sealed interface CacheEncryptionGateState {
/** Resolving whether the encrypted cache can be opened — render a blank cover, never the app. */
data object Checking : CacheEncryptionGateState
/** The cache is openable (encrypted-and-ready, or not encrypted): show the app content. */
data object Ready : CacheEncryptionGateState
/**
* SQLCipher's native library will not load, so the encrypted cache cannot be opened. FAIL CLOSED:
* show the error gate instead of the mailbox — never an unencrypted cache.
*/
data object Unavailable : CacheEncryptionGateState
}
/**
* Drives the fail-closed encrypted-cache gate. On first composition it proactively runs the shared
* startup sequence ([DatabaseProvisioner.prepareCache]) so the cache's open mode is resolved BEFORE any
* DB-backed screen composes. If that raises [CacheEncryptionUnavailableException] — SQLCipher's native
* library failed to load (issue #359) — the gate goes to [CacheEncryptionGateState.Unavailable] and the
* host shows the encryption error screen; otherwise it goes [CacheEncryptionGateState.Ready] and the app
* renders. The failure is never memoized by the provisioner, so a fresh process (e.g. after an app
* update that ships a loadable library) re-probes and recovers automatically.
*
* From the error screen the user can generate an **ephemeral** PII-free diagnostic report
* ([prepareReport]) reusing the app's existing [DiagnosticsCollector]. Because encryption is
* unavailable in this exact moment the report cannot be encrypted at rest, so it is deliberately NOT
* written to `ReportStore` — it lives only in memory for on-screen review and the user's explicit
* Copy/Save.
*
* This VM must be hosted only AFTER the app-lock gate unlocks (see `AppLockGateHost`), so when app-lock
* is on the auth-bound passphrase is already in [org.libremail.data.security.PassphraseSession] and
* `prepareCache()` does not park waiting for authentication.
*/
@HiltViewModel
class CacheEncryptionGateViewModel @Inject constructor(
private val provisioner: DatabaseProvisioner,
private val diagnostics: DiagnosticsCollector,
) : ViewModel() {
// Injectable so the report collection (which touches DataStore + the account store) is pushed off
// the main thread in production yet runs on the test scheduler in unit tests. prepareCache() already
// switches to its own IO dispatcher internally, so the probe does not need this.
@VisibleForTesting
internal var ioDispatcher: CoroutineDispatcher = Dispatchers.IO
private val _state = MutableStateFlow<CacheEncryptionGateState>(CacheEncryptionGateState.Checking)
val state: StateFlow<CacheEncryptionGateState> = _state.asStateFlow()
// The ephemeral report payload, or null before it has been generated / after dismissal. Never
// persisted — it exists only for on-screen review and the user's explicit Copy/Save.
private val _reportPayload = MutableStateFlow<String?>(null)
val reportPayload: StateFlow<String?> = _reportPayload.asStateFlow()
init {
probe()
}
private fun probe() {
viewModelScope.launch {
_state.value = try {
provisioner.prepareCache()
CacheEncryptionGateState.Ready
} catch (e: CacheEncryptionUnavailableException) {
AppLog.w(TAG, "encrypted cache unavailable; showing the fail-closed encryption gate", e)
CacheEncryptionGateState.Unavailable
}
}
}
/**
* Generate the ephemeral PII-free diagnostic report for on-screen review (idempotent while one is
* already prepared). Reuses [DiagnosticsCollector.collectManual] — the same PII-free assembly the
* normal "Report a problem" flow uses — but the result is held only in memory here, never saved.
*/
fun prepareReport() {
if (_reportPayload.value != null) return
viewModelScope.launch {
_reportPayload.value = withContext(ioDispatcher) { diagnostics.collectManual().toSubmissionPayload() }
}
}
/** Drop the in-memory report (on leaving the review screen). */
fun dismissReport() {
_reportPayload.value = null
}
private companion object {
const val TAG = "CacheEncryptionGate"
}
}
+12
View File
@@ -390,6 +390,18 @@
<string name="report_submit_unavailable">Online submission isn\'t available in this build. Use Copy or Save to share the report.</string>
<string name="report_copied">Copied to clipboard</string>
<string name="report_saved">Saved</string>
<!-- Fail-closed encrypted-cache gate (#359): shown when SQLCipher's native library will not load,
so the opt-in encrypted cache cannot be opened. The app fails closed here instead of falling
back to an unencrypted cache. -->
<string name="cache_encryption_error_title">Encrypted mail unavailable</string>
<!-- Verbatim maintainer-specified message; do not reword. -->
<string name="cache_encryption_error_message">Error - decryption could not proceed. Native decryption library load failure.</string>
<string name="cache_encryption_error_body">A required security component could not be loaded, so your encrypted mail can\'t be opened on this device right now. Nothing was changed or deleted — your mail is still on your server. LibreMail will try again the next time you open it.</string>
<string name="cache_encryption_report_action">Report a problem</string>
<!-- Ephemeral report: it is prepared in memory only, so the copy must say it is NOT saved unless
the user explicitly saves/exports it, and that it holds no personal information. -->
<string name="cache_encryption_report_ephemeral_notice">This report contains only diagnostic information — no email addresses, message content, or passwords. It is prepared just for you to review now and is NOT saved to this device unless you tap Save.</string>
<string name="cache_encryption_report_generating">Preparing report…</string>
<string name="crash_prompt_title">LibreMail closed unexpectedly</string>
<string name="crash_prompt_message">A problem report from the last crash is ready for you to review. Nothing is sent automatically.</string>
<string name="crash_prompt_review">Review</string>
@@ -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
@@ -28,7 +29,9 @@ import org.libremail.data.settings.SettingsRepository
import java.io.File
import java.util.concurrent.Executors
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* [DatabaseProvisioner] holds the one-time startup sequence that `DatabaseModule.provideDatabase` used
@@ -56,6 +59,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<String>(), any<String>(), any()) } returns 0
every { context.getDatabasePath(any()) } returns File("libremail.db")
every { DatabaseFiles.clear(any()) } just Runs
@@ -98,6 +106,62 @@ class DatabaseProvisionerTest {
verify(exactly = 1) { DatabaseEncryption.ensureNativeLibraryLoaded() }
}
@Test
fun `a native-library load failure fails closed without opening plaintext or touching the setting`() = runTest {
every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = true, appLock = false))
// Issue #359: the SQLCipher `.so` fails to load, throwing UnsatisfiedLinkError (a LinkageError) at
// the keyed open. The provisioner must FAIL CLOSED — raise a distinct signal, never silently
// degrade to an unencrypted cache (which would defeat the user's opt-in encryption).
val nativeLoadFailure = UnsatisfiedLinkError("dlopen failed: libsqlcipher.so is not 16 KB aligned")
every { DatabaseEncryption.ensureNativeLibraryLoaded() } throws nativeLoadFailure
val error = assertFailsWith<CacheEncryptionUnavailableException> { provisioner().prepareCache() }
// The distinct signal preserves the underlying native LinkageError in its cause chain (coroutine
// stack-trace recovery may re-wrap the exception across withContext, so assert the chain rather
// than exact instance identity), and NONE of the old fail-open side effects run: the setting is
// never flipped, nothing is wiped, and no seal is reset.
assertTrue(
generateSequence(error.cause) { it.cause }.any { it is LinkageError },
"the native-load LinkageError must be preserved as the cause",
)
coVerify(exactly = 0) { settingsRepository.setEncryptCache(any()) }
verify(exactly = 0) { DatabaseFiles.clear(any()) }
coVerify(exactly = 0) { keyStore.resetSealedPassphrase() }
}
@Test
fun `a native-library load failure never wipes an already-encrypted cache`() = runTest {
every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = true, appLock = false))
every { DatabaseEncryption.isEncrypted(any()) } returns true
every { DatabaseEncryption.ensureEncrypted(any(), any()) } throws
UnsatisfiedLinkError("dlopen failed: libsqlcipher.so is not 16 KB aligned")
assertFailsWith<CacheEncryptionUnavailableException> { provisioner().prepareCache() }
// The ciphertext the plaintext opener can't parse is deliberately PRESERVED (it may become
// readable again once the library loads on a later launch), the seals stay intact, and the
// setting is untouched — the opposite of the rejected degrade-and-wipe behaviour.
verify(exactly = 0) { DatabaseFiles.clear(any()) }
coVerify(exactly = 0) { keyStore.resetSealedPassphrase() }
coVerify(exactly = 0) { settingsRepository.setEncryptCache(any()) }
}
@Test
fun `a native-library load failure is not memoized and retries on the next open`() = runTest {
every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = true, appLock = false))
every { DatabaseEncryption.ensureNativeLibraryLoaded() } throws
UnsatisfiedLinkError("dlopen failed: libsqlcipher.so is not 16 KB aligned")
val provisioner = provisioner()
assertFailsWith<CacheEncryptionUnavailableException> { provisioner.prepareCache() }
assertFailsWith<CacheEncryptionUnavailableException> { provisioner.prepareCache() }
// A failure is NOT memoized (unlike a success): each open re-runs the whole sequence, so the
// migrator ran on BOTH attempts. That is what lets a later launch recover once the library loads.
coVerify(exactly = 2) { accountDataMigrator.migrateIfNeeded() }
}
@Test
fun `prepareCache suspends on the auth-bound passphrase until it resolves`() = runTest {
every { settingsRepository.settings } returns flowOf(AppSettings(encryptCache = true, appLock = true))
@@ -2,6 +2,11 @@
package org.libremail.data.security
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.StrongBoxUnavailableException
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import org.junit.Test
import java.security.GeneralSecurityException
import javax.crypto.AEADBadTagException
@@ -70,6 +75,27 @@ class AesGcmKeystoreCipherTest {
assertTrue(error.message!!.contains("test.alias"))
}
@Test
fun `key generation falls back to a TEE-backed key when StrongBox is unavailable`() {
// AppLog.i (breadcrumb on the fallback) forwards to android.util.Log, a no-op stub under plain JVM
// tests; mock it (fully-qualified — a raw android.util.Log import is detekt-forbidden, epic #324).
mockkStatic(android.util.Log::class)
every { android.util.Log.i(any<String>(), any<String>()) } returns 0
try {
val teeKey = newAesKey()
val cipher = StrongBoxFakeCipher(teeKey)
val key = cipher.createKey()
// StrongBox is attempted first; its StrongBoxUnavailableException triggers a single retry with
// strongBox = false, and that TEE-backed key is returned — so generation succeeds everywhere.
assertSame(teeKey, key)
assertEquals(listOf(true, false), cipher.attempts)
} finally {
unmockkStatic(android.util.Log::class)
}
}
@Test
fun `a non-AEAD failure propagates unwrapped so key invalidation still surfaces`() {
// Only AEADBadTagException is remapped; every other cipher failure — including the
@@ -110,7 +136,36 @@ class AesGcmKeystoreCipherTest {
return onDecrypt(key, encoded)
}
override fun keySpec(): KeyGenParameterSpec = error("keySpec is not exercised in the JVM base test")
override fun keySpec(strongBox: Boolean): KeyGenParameterSpec =
error("keySpec is not exercised in the JVM base test")
}
/**
* A JVM-only cipher that exercises the REAL [getOrCreateKey]/StrongBox-fallback control flow (unlike
* [FakeCipher], which stubs [getOrCreateKey] out). [existingKey] returns null so a key is generated,
* and the [generateKey] seam simulates the device: a StrongBox attempt fails, the TEE attempt yields
* [teeKey]. [attempts] records the `strongBox` value of each generation attempt in order.
*/
private class StrongBoxFakeCipher(private val teeKey: SecretKey) :
AesGcmKeystoreCipher(alias = "test.alias", generateKeyOnDecrypt = true) {
val attempts = mutableListOf<Boolean>()
override fun existingKey(): SecretKey? = null
override fun generateKey(strongBox: Boolean): SecretKey {
attempts += strongBox
// Objenesis-instantiated (no stubbed-constructor call) so it is throwable under the android.jar
// stub; it is still a StrongBoxUnavailableException, so the production catch clause matches.
if (strongBox) throw mockk<StrongBoxUnavailableException>(relaxed = true)
return teeKey
}
override fun keySpec(strongBox: Boolean): KeyGenParameterSpec =
error("keySpec is bypassed because generateKey is overridden")
/** Invokes the protected [getOrCreateKey] so the StrongBox fallback runs without touching Base64. */
fun createKey(): SecretKey = getOrCreateKey()
}
private companion object {
@@ -0,0 +1,131 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.security
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.libremail.data.local.CacheEncryptionUnavailableException
import org.libremail.data.local.CacheOpenMode
import org.libremail.data.local.DatabaseProvisioner
import org.libremail.reporting.DebugReport
import org.libremail.reporting.DiagnosticsCollector
import org.libremail.reporting.ReportKind
import kotlin.test.assertEquals
/**
* Unit coverage for the fail-closed encrypted-cache gate (issue #359). Pins the gate's two security
* outcomes — that a normal start reaches [CacheEncryptionGateState.Ready] and that a
* [CacheEncryptionUnavailableException] from the provisioner resolves to
* [CacheEncryptionGateState.Unavailable] WITHOUT rethrowing/crashing — plus that the report it offers is
* ephemeral: assembled from the existing [DiagnosticsCollector] and never routed through a [ReportStore]
* (the VM has no such dependency to persist through).
*/
@OptIn(ExperimentalCoroutinesApi::class)
class CacheEncryptionGateViewModelTest {
private val dispatcher = UnconfinedTestDispatcher()
@Before
fun setUp() = Dispatchers.setMain(dispatcher)
@After
fun tearDown() = Dispatchers.resetMain()
private fun viewModel(
provisioner: DatabaseProvisioner,
diagnostics: DiagnosticsCollector = mockk(relaxed = true),
) = CacheEncryptionGateViewModel(provisioner, diagnostics).also { it.ioDispatcher = dispatcher }
private fun debugReport() = DebugReport(
id = "gate-report",
createdAtMillis = 1L,
kind = ReportKind.MANUAL,
appVersionName = "0.1.0",
appVersionCode = 1,
androidRelease = "14",
androidSdkInt = 34,
deviceManufacturer = "Google",
deviceModel = "Pixel",
stackTrace = null,
settings = mapOf("encryptCache" to "true"),
logs = listOf("W/DatabaseProvisioner: SQLCipher native library failed to load"),
)
@Test
fun `the gate reaches Ready when the cache can be opened`() = runTest(dispatcher) {
val provisioner = mockk<DatabaseProvisioner>()
coEvery { provisioner.prepareCache() } returns CacheOpenMode.Plaintext
val vm = viewModel(provisioner)
assertEquals(CacheEncryptionGateState.Ready, vm.state.value)
}
@Test
fun `the gate fails closed to Unavailable when the encrypted cache cannot be opened`() = runTest(dispatcher) {
// The probe logs the failure via AppLog.w -> android.util.Log (a no-op stub in JVM tests); mock it
// (fully-qualified — a raw android.util.Log import is detekt-forbidden, epic #324).
mockkStatic(android.util.Log::class)
every { android.util.Log.w(any<String>(), any<String>(), any()) } returns 0
try {
val provisioner = mockk<DatabaseProvisioner>()
coEvery { provisioner.prepareCache() } throws
CacheEncryptionUnavailableException(UnsatisfiedLinkError("dlopen failed: libsqlcipher.so"))
val vm = viewModel(provisioner)
// Fail closed: the error gate is shown (never the mailbox), and the VM did NOT rethrow/crash.
assertEquals(CacheEncryptionGateState.Unavailable, vm.state.value)
} finally {
unmockkStatic(android.util.Log::class)
}
}
@Test
fun `prepareReport builds an ephemeral payload from the diagnostics collector, never persisting`() =
runTest(dispatcher) {
val provisioner = mockk<DatabaseProvisioner>()
coEvery { provisioner.prepareCache() } returns CacheOpenMode.Plaintext
val diagnostics = mockk<DiagnosticsCollector>()
val report = debugReport()
coEvery { diagnostics.collectManual() } returns report
val vm = viewModel(provisioner, diagnostics)
vm.prepareReport()
advanceUntilIdle()
// The payload is exactly the reused DiagnosticsCollector rendering — and this VM depends on no
// ReportStore, so it structurally cannot write the report to disk (ephemeral by construction).
assertEquals(report.toSubmissionPayload(), vm.reportPayload.value)
coVerify(exactly = 1) { diagnostics.collectManual() }
}
@Test
fun `prepareReport is idempotent while a report is already prepared`() = runTest(dispatcher) {
val provisioner = mockk<DatabaseProvisioner>()
coEvery { provisioner.prepareCache() } returns CacheOpenMode.Plaintext
val diagnostics = mockk<DiagnosticsCollector>()
coEvery { diagnostics.collectManual() } returns debugReport()
val vm = viewModel(provisioner, diagnostics)
vm.prepareReport()
advanceUntilIdle()
vm.prepareReport() // second tap while one is already held
advanceUntilIdle()
coVerify(exactly = 1) { diagnostics.collectManual() }
}
}