Merge branch 'main' into fix-reader-inline-images

This commit is contained in:
Jason Ross
2026-07-02 11:25:41 -05:00
committed by GitHub
41 changed files with 1827 additions and 157 deletions
+13
View File
@@ -91,6 +91,19 @@
android:exported="false"
android:foregroundServiceType="dataSync" />
<!-- Separate-process trampoline for the app-lock key-invalidation recovery restart. Runs in
its own ":restart" process (android:process) so it survives the main process being killed
and can reliably relaunch the app from the outside — a same-process "startActivity then
exit(0)" restart races ActivityManager and can be dropped (the ProcessPhoenix pattern).
Not exported; only ProcessRestarter starts it. Translucent + excluded from recents so it
never flashes UI or lingers in the switcher before it finishes and exits its own process. -->
<activity
android:name=".restart.RestartActivity"
android:excludeFromRecents="true"
android:exported="false"
android:process=":restart"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<!-- Shares downloaded attachments with viewer apps via a content:// URI. -->
<provider
android:name="androidx.core.content.FileProvider"
@@ -21,6 +21,7 @@ import org.libremail.reporting.AppLog
import org.libremail.reporting.CrashReporter
import org.libremail.reporting.DiagnosticsCollector
import org.libremail.reporting.RingLogBuffer
import org.libremail.restart.ProcessRestarter
import javax.inject.Inject
@HiltAndroidApp
@@ -60,6 +61,12 @@ class LibreMailApplication :
override fun onCreate() {
super.onCreate()
// Android also instantiates this Application in the separate ":restart" trampoline process
// (see ProcessRestarter / RestartActivity), which exists only to relaunch the app from outside
// a dying main process and is torn down within milliseconds. It must NOT run any of the app's
// normal startup work — crash reporting, WorkManager scheduling, IDLE push all belong to the
// main process. The main process (no ":restart" suffix) is unaffected, so normal launch is too.
if (isRestartTrampolineProcess()) return
// Wire up debug reporting first so crashes during the rest of startup are still captured.
AppLog.install(ringLogBuffer)
crashReporter.install()
@@ -98,6 +105,10 @@ class LibreMailApplication :
if (pushShouldBeActive) idlePushManager.start()
}
/** True when this Application instance is the one Android spun up in the ":restart" aux process. */
private fun isRestartTrampolineProcess(): Boolean =
Application.getProcessName() == packageName + ProcessRestarter.PROCESS_SUFFIX
private companion object {
const val TAG = "LibreMail"
}
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.backup
import org.libremail.data.local.DatabaseFiles
import org.libremail.data.settings.AppSettings
/**
@@ -24,13 +25,16 @@ object BackupPolicy {
"datastore/libremail_dbkey.preferences_pb",
)
/** `databases`-dir-relative names that must never leave the device (encrypted credentials + mail cache). */
val EXCLUDED_DATABASE_PATHS: List<String> = listOf(
"libremail.db",
"libremail.db-wal",
"libremail.db-shm",
"libremail.db-journal",
)
/**
* `databases`-dir-relative names that must never leave the device: the encrypted mail cache
* ([DatabaseFiles.NAME]) AND the accounts + encrypted-credentials database
* ([DatabaseFiles.ACCOUNTS_NAME]), each with its SQLite sidecars. Derived from [DatabaseFiles]
* rather than hand-listed, so a newly added database can never silently fall out of the
* never-back-up set (issue #103).
*/
val EXCLUDED_DATABASE_PATHS: List<String> =
DatabaseFiles.fileNames(DatabaseFiles.NAME) +
DatabaseFiles.fileNames(DatabaseFiles.ACCOUNTS_NAME)
/**
* Whether Android Backup may run for this app. Opt-in and OFF by default: nothing is backed up
@@ -0,0 +1,37 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.contacts
/**
* Where the optional contacts-autocomplete permission stands, as the Settings entry (#129) shows it.
* - [GRANTED]: on — recipient autocomplete works.
* - [DENIED]: off but re-requestable in-app (never asked, or denied once without "don't ask again").
* - [BLOCKED]: off and no longer re-requestable — the only way back is the system settings screen.
*/
enum class ContactPermissionState { GRANTED, DENIED, BLOCKED }
/**
* Pure mapping from the three Android permission signals to a [ContactPermissionState]. Kept free of
* Android types so it is exhaustively unit-testable; the live inputs are read by
* [ContactsPermissionManager] (grant), the Activity (`shouldShowRequestPermissionRationale`), and
* [org.libremail.data.settings.SettingsRepository] (whether the system dialog has ever been shown).
*/
object ContactPermissionDecision {
/**
* Resolve the current state:
* - [granted]: `READ_CONTACTS` is held → [ContactPermissionState.GRANTED].
* - [showRationale]: the OS says a rationale should precede a re-request, i.e. the user denied
* once without "don't ask again" → still re-requestable, [ContactPermissionState.DENIED].
* - [alreadyRequested]: the system dialog has been shown before. Combined with `!showRationale`
* (and not granted) this is the permanently-denied case → [ContactPermissionState.BLOCKED].
*
* The remaining case — not granted, no rationale, never requested — is a fresh install that has
* simply never asked, so an in-app request will still surface the dialog: [ContactPermissionState.DENIED].
*/
fun resolve(granted: Boolean, showRationale: Boolean, alreadyRequested: Boolean): ContactPermissionState = when {
granted -> ContactPermissionState.GRANTED
showRationale -> ContactPermissionState.DENIED
alreadyRequested -> ContactPermissionState.BLOCKED
else -> ContactPermissionState.DENIED
}
}
@@ -0,0 +1,39 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.contacts
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.provider.Settings
import androidx.core.content.ContextCompat
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* Reads this app's `READ_CONTACTS` grant and deep-links to the system screen where it can be changed.
* `READ_CONTACTS` powers recipient autocomplete only (see [ContactsRepository]); the whole feature is
* optional and degrades gracefully when the permission is absent.
*
* Deliberately Context-only so it can back both the onboarding opt-in step and the Settings entry.
* The `shouldShowRequestPermissionRationale` signal needs an Activity, so it is read in the Compose
* layer and combined with [ContactPermissionDecision]; this manager stays free of Activity state.
*/
@Singleton
class ContactsPermissionManager @Inject constructor(@ApplicationContext private val context: Context) {
/** True when `READ_CONTACTS` is currently granted to this app. */
fun hasPermission(): Boolean = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) ==
PackageManager.PERMISSION_GRANTED
/**
* Intent to this app's system details screen, where **Permissions → Contacts** can be toggled.
* Used to recover the permanently-denied ("Don't allow" / don't-ask-again) case, which can no
* longer be re-requested in-app. Always resolvable since API 9.
*/
fun settingsIntent(): Intent = Intent(
Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", context.packageName, null),
)
}
@@ -2,9 +2,8 @@
package org.libremail.data.local
import android.content.Context
import java.io.File
/** Central name and wipe helper for the Room cache database file (and its SQLite sidecars). */
/** Central names and wipe helper for the Room database files (and their SQLite sidecars). */
object DatabaseFiles {
const val NAME = "libremail.db"
@@ -17,15 +16,27 @@ object DatabaseFiles {
const val ACCOUNTS_NAME = "libremail-accounts.db"
/**
* Delete the database and any WAL/SHM/journal sidecars. Call only when no connection is open —
* used by the "clear + re-sync" path when the encryption key is invalidated and the encrypted
* database can no longer be decrypted.
* The statically-nameable SQLite sidecars that accompany a database file. A transient `-mj*`
* master journal can also exist, but its suffix is random and so can't be listed by name —
* [clear] leans on [Context.deleteDatabase] to sweep that one up.
*/
private val SIDECAR_SUFFIXES = listOf("-wal", "-shm", "-journal")
/**
* [name] plus each of its statically-nameable sidecars. The single source of truth for which
* on-disk files make up a database file; `BackupPolicy` derives its never-back-up set from this
* so a new database (or a new sidecar suffix) can never silently fall out of the exclusions.
*/
fun fileNames(name: String): List<String> = listOf(name) + SIDECAR_SUFFIXES.map { name + it }
/**
* Delete the cache database ([NAME]) and every sidecar — including the `-mj*` master journal a
* hand-rolled suffix list would miss — via [Context.deleteDatabase]. NEVER touches
* [ACCOUNTS_NAME], so a cache-key invalidation keeps the user signed in (issue #111). Call only
* when no connection is open — used by the "clear + re-sync" path when the encryption key is
* invalidated and the encrypted database can no longer be decrypted.
*/
fun clear(context: Context) {
val db = context.getDatabasePath(NAME)
val dir = db.parentFile ?: return
listOf("", "-wal", "-shm", "-journal").forEach { suffix ->
File(dir, db.name + suffix).delete()
}
context.deleteDatabase(NAME)
}
}
@@ -0,0 +1,136 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
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.SettingsRepository
import javax.inject.Inject
import javax.inject.Singleton
/** How the cache database ([LibreMailDatabase]) must be opened, decided by [DatabaseProvisioner]. */
sealed interface CacheOpenMode {
/** Open with SQLCipher, keyed by [passphrase] — the opt-in encrypted cache. */
data class Encrypted(val passphrase: String) : CacheOpenMode
/** Open with the default framework helper — the cache is plaintext on disk. */
data object Plaintext : CacheOpenMode
}
/**
* Runs the one-time, blocking startup sequence that must complete BEFORE Room opens either database —
* exactly once, memoized, and OFF the Hilt injection path (issue #93).
*
* `DatabaseModule.provideDatabase` used to do this work inline, with `runBlocking`, while Hilt
* constructed the singleton [LibreMailDatabase]: a DataStore read, a Keystore op, a possible SQLCipher
* re-key conversion, and (since #111) the cross-database [AccountDataMigrator]. All of it ran
* synchronously on whichever thread first injected the database — which can be the main thread — so the
* first DB access could jank or ANR (worst with the encrypted cache on). This class moves that work
* behind [prepareCache]; the Hilt providers wire it into a [DeferredOpenHelperFactory] so it runs
* lazily, on Room's background open, never at inject time.
*
* The sequence, its ordering, and its crash-safety are unchanged from the old `provideDatabase` — only
* WHERE and WHEN it runs moved:
* 1. If a screen-lock change flagged the encrypted cache for wiping, wipe it and reset its seals
* (before Room opens the file, so no open connection is deleted underneath it).
* 2. Run [AccountDataMigrator] — the one-time move of accounts/credentials/settings/signatures into
* the non-auth [AccountDatabase] (issue #111). MUST precede opening the cache (whose
* [MIGRATION_15_16] drops the moved tables) AND opening [AccountDatabase] (which reads the copied
* rows). Both databases' open paths gate on [prepareCache], so the migrate-before-open guarantee
* holds regardless of which database Room opens first.
* 3. Resolve the encryption gate: convert the on-disk cache to the form the `encryptCache` setting
* asks for, and report how the cache must be opened.
*
* [prepareCache] is memoized on success and guarded by a [Mutex], so the first database to open runs
* the sequence and any concurrent or later opener awaits the same result. A failure is NOT memoized, so
* it retries on the next open — preserving the migrator's "crash-loop rather than lose data" contract
* (a throw here means the cache never opens, so [MIGRATION_15_16] never drops the not-yet-copied rows).
*/
@Singleton
class DatabaseProvisioner internal constructor(
private val context: Context,
private val keyStore: DatabaseKeyStore,
private val settingsRepository: SettingsRepository,
private val accountDataMigrator: AccountDataMigrator,
private val ioDispatcher: CoroutineDispatcher,
) {
@Inject
constructor(
@ApplicationContext context: Context,
keyStore: DatabaseKeyStore,
settingsRepository: SettingsRepository,
accountDataMigrator: AccountDataMigrator,
) : this(context, keyStore, settingsRepository, accountDataMigrator, Dispatchers.IO)
private val mutex = Mutex()
@Volatile
private var prepared: CacheOpenMode? = null
/**
* Runs the startup sequence exactly once (on [ioDispatcher]) and returns how the cache must be
* opened. Idempotent and safe to call concurrently from both databases' open paths; the blocking
* work runs on [ioDispatcher], never on the caller's thread past the suspension point.
*/
suspend fun prepareCache(): CacheOpenMode {
prepared?.let { return it }
return mutex.withLock {
prepared ?: withContext(ioDispatcher) { runStartupSequence() }.also { prepared = it }
}
}
private suspend fun runStartupSequence(): CacheOpenMode {
val dbFile = context.getDatabasePath(DatabaseFiles.NAME)
// A screen-lock change (biometric re-enrollment / lock removal) can invalidate the auth-bound
// key so the encrypted cache is no longer decryptable. AppLockViewModel records that and
// restarts the app; we wipe the cache HERE — before Room opens it — so the file is never
// deleted from under an open connection. Crash-safe order: wipe + reset the seals, and only THEN
// clear the flag, so a kill mid-wipe just repeats the idempotent wipe next start. Only
// libremail.db is wiped: accounts/credentials live in AccountDatabase (a separate file), so the
// user stays signed in across the wipe (issue #111).
if (keyStore.isClearPending()) {
DatabaseFiles.clear(context)
keyStore.resetSealedPassphrase()
keyStore.clearClearPending()
}
// One-time move of accounts/credentials/settings/signatures into the non-auth AccountDatabase
// (issue #111). MUST run before the cache opens: opening it applies MIGRATION_15_16, which drops
// the moved tables. Runs AFTER the wipe above so an unrecoverable-key cache is gone first
// (nothing left to move) and we never block waiting on a passphrase we can't get.
accountDataMigrator.migrateIfNeeded()
// Opt-in at-rest encryption of the local cache (off by default). The conversion runs here —
// before the database is opened — so it never races an open connection; toggling the setting
// therefore takes effect on the next app start. The passphrase source is resolved from which
// seal actually exists (DatabaseKeyStore.resolvePassphrase), NOT from the app-lock setting (a
// separate DataStore that can disagree). When app-lock is ON the sealing key is auth-bound, so
// 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()
val appLock = settings.appLock
return when {
settings.encryptCache -> {
val passphrase = keyStore.resolvePassphrase(appLock)
DatabaseEncryption.ensureEncrypted(dbFile, passphrase)
CacheOpenMode.Encrypted(passphrase)
}
DatabaseEncryption.isEncrypted(dbFile) -> {
// Encryption was turned back off — decrypt so the default (unkeyed) open succeeds.
val passphrase = keyStore.resolvePassphrase(appLock)
DatabaseEncryption.ensurePlaintext(dbFile, passphrase)
CacheOpenMode.Plaintext
}
else -> CacheOpenMode.Plaintext
}
}
}
@@ -0,0 +1,75 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.local
import androidx.sqlite.db.SupportSQLiteDatabase
import androidx.sqlite.db.SupportSQLiteOpenHelper
/**
* A [SupportSQLiteOpenHelper.Factory] that defers building the REAL open helper — and any blocking work
* that choosing and creating it entails — from Room's build/inject path to the FIRST actual database
* open (issue #93).
*
* Room calls [create] and [SupportSQLiteOpenHelper.setWriteAheadLoggingEnabled] while it builds the
* database, on whichever thread injected it (possibly the main thread); neither may block. This factory
* hands back a thin handle whose delegate is materialised only when the database is first opened
* (`writableDatabase` / `readableDatabase`), which Room performs on its background query executor. The
* [buildDelegate] lambda is where the caller runs the startup gate (see [DatabaseProvisioner]) and
* picks the concrete factory — so all of that runs off the injection path and off the main thread.
*/
internal class DeferredOpenHelperFactory(
private val buildDelegate: (SupportSQLiteOpenHelper.Configuration) -> SupportSQLiteOpenHelper,
) : SupportSQLiteOpenHelper.Factory {
override fun create(configuration: SupportSQLiteOpenHelper.Configuration): SupportSQLiteOpenHelper =
DeferredOpenHelper(configuration, buildDelegate)
}
/**
* The lazy handle returned by [DeferredOpenHelperFactory]. Everything Room touches before the first
* open is cheap; [buildDelegate] (which does the blocking work) runs only when [writableDatabase] or
* [readableDatabase] is first read.
*/
private class DeferredOpenHelper(
private val configuration: SupportSQLiteOpenHelper.Configuration,
private val buildDelegate: (SupportSQLiteOpenHelper.Configuration) -> SupportSQLiteOpenHelper,
) : SupportSQLiteOpenHelper {
private val lock = Any()
/** Guarded by [lock]. Null until the database is first opened — `create()` must stay non-blocking. */
private var delegate: SupportSQLiteOpenHelper? = null
/**
* Guarded by [lock]. Room may set WAL before the first open; we remember the value and apply it when
* the delegate is built, rather than building the delegate early (which would run the gate at inject
* time). Null means "Room never asked", so the delegate keeps the real factory's own default.
*/
private var writeAheadLoggingEnabled: Boolean? = null
override val databaseName: String?
get() = configuration.name
override fun setWriteAheadLoggingEnabled(enabled: Boolean) {
synchronized(lock) {
writeAheadLoggingEnabled = enabled
delegate?.setWriteAheadLoggingEnabled(enabled)
}
}
override val writableDatabase: SupportSQLiteDatabase
get() = delegate().writableDatabase
override val readableDatabase: SupportSQLiteDatabase
get() = delegate().readableDatabase
override fun close() {
// Never opened means nothing to close; do NOT build the delegate just to close it.
synchronized(lock) { delegate?.close() }
}
private fun delegate(): SupportSQLiteOpenHelper = synchronized(lock) {
delegate ?: buildDelegate(configuration).also { built ->
writeAheadLoggingEnabled?.let(built::setWriteAheadLoggingEnabled)
delegate = built
}
}
}
@@ -72,6 +72,8 @@ private object Keys {
val RETENTION_COUNT = intPreferencesKey("retention_count")
val RETENTION_MONTHS = intPreferencesKey("retention_months")
val BATTERY_PROMPT_HANDLED = booleanPreferencesKey("battery_prompt_handled")
val CONTACTS_PROMPT_HANDLED = booleanPreferencesKey("contacts_prompt_handled")
val CONTACTS_PERMISSION_REQUESTED = booleanPreferencesKey("contacts_permission_requested")
}
/**
@@ -118,6 +120,29 @@ class SettingsRepository @Inject constructor(@ApplicationContext private val con
suspend fun setBatteryPromptHandled(value: Boolean) = put(Keys.BATTERY_PROMPT_HANDLED, value)
/**
* One-time onboarding flag: whether the user has already seen/acted on the "contacts access"
* opt-in step, so onboarding offers it at most once (see #127). Like [isBatteryPromptHandled] this
* is internal onboarding state, not a user-facing preference — the Settings contacts entry (#129)
* is the way to enable autocomplete later.
*/
suspend fun isContactsPromptHandled(): Boolean =
context.settingsDataStore.data.map { it[Keys.CONTACTS_PROMPT_HANDLED] ?: false }.first()
suspend fun setContactsPromptHandled(value: Boolean) = put(Keys.CONTACTS_PROMPT_HANDLED, value)
/**
* Whether the `READ_CONTACTS` system dialog has ever actually been shown (from the onboarding step
* or the Settings entry). It is the only reliable signal — combined with the Activity's
* `shouldShowRequestPermissionRationale` — that separates "never asked yet" from "permanently
* denied", so the Settings entry (#129) can offer an in-app request versus a deep-link to system
* settings. See [ContactPermissionDecision][org.libremail.contacts.ContactPermissionDecision].
*/
val contactsPermissionRequested: Flow<Boolean> =
context.settingsDataStore.data.map { it[Keys.CONTACTS_PERMISSION_REQUESTED] ?: false }
suspend fun setContactsPermissionRequested(value: Boolean) = put(Keys.CONTACTS_PERMISSION_REQUESTED, value)
suspend fun setDynamicColor(value: Boolean) = put(Keys.DYNAMIC_COLOR, value)
suspend fun setNewMailNotifications(value: Boolean) = put(Keys.NEW_MAIL_NOTIFICATIONS, value)
suspend fun setPushIdle(value: Boolean) = put(Keys.PUSH_IDLE, value)
@@ -6,6 +6,7 @@ import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.Operation
import androidx.work.OutOfQuotaPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
@@ -48,13 +49,18 @@ class SyncScheduler @Inject constructor(
workManager.enqueueUniquePeriodicWork(PERIODIC_WORK, PERIODIC_POLICY, request)
}
/** One-shot sync, e.g. right after an account is added. */
fun syncNow() {
/**
* One-shot sync, e.g. right after an account is added. Returns the enqueue [Operation] so callers
* that must guarantee the WorkSpec is durably persisted before killing the process (the app-lock
* recovery restart) can await it — WorkManager persists the WorkSpec asynchronously on its serial
* task executor, so a fire-and-forget enqueue can be lost to a racing process death.
*/
fun syncNow(): Operation {
val request = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(networkConstraint)
.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
.build()
workManager.enqueueUniqueWork(ONESHOT_WORK, ExistingWorkPolicy.REPLACE, request)
return workManager.enqueueUniqueWork(ONESHOT_WORK, ExistingWorkPolicy.REPLACE, request)
}
/**
@@ -3,14 +3,17 @@ package org.libremail.di
import android.content.Context
import androidx.room.Room
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.runBlocking
import org.libremail.data.local.AccountDatabase
import org.libremail.data.local.DatabaseFiles.ACCOUNTS_NAME
import org.libremail.data.local.LibreMailDatabase
import org.libremail.data.local.DatabaseProvisioner
import org.libremail.data.local.DeferredOpenHelperFactory
import org.libremail.data.local.dao.AccountDao
import org.libremail.data.local.dao.AccountSettingsDao
import org.libremail.data.local.dao.CredentialDao
@@ -27,17 +30,26 @@ import javax.inject.Singleton
object AccountDatabaseModule {
/**
* The plaintext account store. Depends on [LibreMailDatabase] purely for construction ordering:
* building the cache runs the one-time [org.libremail.data.local.AccountDataMigrator] (which
* populates this file on a dedicated connection) and then drops the moved tables, so by the time
* Room opens this file the data is already present and no other connection is touching it.
* The plaintext account store. Its OPEN is gated on [DatabaseProvisioner.prepareCache] so the
* one-time [org.libremail.data.local.AccountDataMigrator] (which populates this file on a dedicated
* connection, then drops the moved tables from the cache) has finished before Room opens this file —
* 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.
*/
@Provides
@Singleton
fun provideAccountDatabase(
@ApplicationContext context: Context,
@Suppress("UNUSED_PARAMETER") cacheDatabase: LibreMailDatabase,
): AccountDatabase = Room.databaseBuilder(context, AccountDatabase::class.java, ACCOUNTS_NAME).build()
provisioner: DatabaseProvisioner,
): AccountDatabase = Room.databaseBuilder(context, AccountDatabase::class.java, ACCOUNTS_NAME)
.openHelperFactory(
DeferredOpenHelperFactory { configuration ->
runBlocking { provisioner.prepareCache() }
FrameworkSQLiteOpenHelperFactory().create(configuration)
},
)
.build()
@Provides
fun provideAccountDao(database: AccountDatabase): AccountDao = database.accountDao()
@@ -3,17 +3,18 @@ package org.libremail.di
import android.content.Context
import androidx.room.Room
import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import net.zetetic.database.sqlcipher.SupportOpenHelperFactory
import org.libremail.data.local.AccountDataMigrator
import org.libremail.data.local.DatabaseEncryption
import org.libremail.data.local.CacheOpenMode
import org.libremail.data.local.DatabaseFiles
import org.libremail.data.local.DatabaseProvisioner
import org.libremail.data.local.DeferredOpenHelperFactory
import org.libremail.data.local.LibreMailDatabase
import org.libremail.data.local.MIGRATION_10_11
import org.libremail.data.local.MIGRATION_11_12
@@ -37,8 +38,6 @@ import org.libremail.data.local.dao.DraftDao
import org.libremail.data.local.dao.FolderDao
import org.libremail.data.local.dao.MessageDao
import org.libremail.data.local.dao.OutboxDao
import org.libremail.data.security.DatabaseKeyStore
import org.libremail.data.settings.SettingsRepository
import javax.inject.Singleton
@Module
@@ -47,13 +46,8 @@ object DatabaseModule {
@Provides
@Singleton
fun provideDatabase(
@ApplicationContext context: Context,
keyStore: DatabaseKeyStore,
settingsRepository: SettingsRepository,
accountDataMigrator: AccountDataMigrator,
): LibreMailDatabase {
val builder = Room.databaseBuilder(context, LibreMailDatabase::class.java, DB_NAME)
fun provideDatabase(@ApplicationContext context: Context, provisioner: DatabaseProvisioner): LibreMailDatabase =
Room.databaseBuilder(context, LibreMailDatabase::class.java, DB_NAME)
.addMigrations(
MIGRATION_1_2,
MIGRATION_2_3,
@@ -72,59 +66,29 @@ object DatabaseModule {
MIGRATION_15_16,
MIGRATION_16_17,
)
// No destructive fallback: the migration chain is complete, and silently dropping the
// mail/message tables would lose cached data. A missing migration should fail loudly in
// testing instead.
// No destructive fallback: the migration chain is complete, and silently dropping the
// mail/message tables would lose cached data. A missing migration should fail loudly in
// testing instead.
//
// All blocking startup work — the issue-#111 AccountDataMigrator, the encrypted-cache
// conversion, and the Keystore passphrase resolution — is deferred OFF this injection path
// (issue #93). The factory below runs DatabaseProvisioner.prepareCache() lazily, when Room
// first OPENS the cache on its background query executor, never on the (possibly main)
// thread that injects this singleton. prepareCache() still performs that sequence before the
// file opens and in the same order, so the migrate-before-open guarantee and the encryption
// gate are unchanged — only where/when they run moved.
.openHelperFactory(
DeferredOpenHelperFactory { configuration ->
val realFactory = when (val mode = runBlocking { provisioner.prepareCache() }) {
is CacheOpenMode.Encrypted ->
SupportOpenHelperFactory(mode.passphrase.toByteArray(Charsets.US_ASCII), null, false)
// Opt-in at-rest encryption of the local cache (off by default). The conversion runs here —
// before the database is opened — so it never races an open connection; toggling the setting
// therefore takes effect on the next app start. The passphrase is sealed by the Keystore.
//
// The passphrase source is resolved from which seal actually exists
// ([DatabaseKeyStore.resolvePassphrase]), NOT from the app-lock setting (a separate DataStore
// that can disagree). When app-lock is ON the sealing key is auth-bound, so resolvePassphrase
// waits on PassphraseSession until the user authenticates. This provider must therefore never
// be constructed on the main thread while the cache is locked — LibreMailApplication injects
// AccountRepository lazily and the sync/push workers fail fast when locked, and the gate
// composes no DB-backed screen until Unlocked.
val dbFile = context.getDatabasePath(DB_NAME)
// A screen-lock change (biometric re-enrollment / lock removal) can invalidate the auth-bound
// key so the encrypted cache is no longer decryptable. AppLockViewModel records that and
// restarts the app; we wipe the cache HERE — at cold start, before Room opens — so the file is
// never deleted from under an open connection. Crash-safe order: wipe + reset the seals, and
// only THEN clear the flag, so a kill mid-wipe just repeats the idempotent wipe next start.
// Only libremail.db is wiped: accounts/credentials live in AccountDatabase (a separate file),
// so the user stays signed in across the wipe (issue #111).
if (runBlocking { keyStore.isClearPending() }) {
DatabaseFiles.clear(context)
runBlocking {
keyStore.resetSealedPassphrase()
keyStore.clearClearPending()
}
}
// One-time move of accounts/credentials/settings/signatures into the non-auth AccountDatabase
// (issue #111). MUST run before builder.build() below: opening the cache applies MIGRATION_15_16,
// which drops the moved tables. It runs AFTER the wipe above so an unrecoverable-key cache is
// gone first (nothing left to move) and we never block waiting on a passphrase we can't get.
runBlocking { accountDataMigrator.migrateIfNeeded() }
val settings = runBlocking { settingsRepository.settings.first() }
val appLock = settings.appLock
if (settings.encryptCache) {
val passphrase = runBlocking { keyStore.resolvePassphrase(appLock) }
DatabaseEncryption.ensureEncrypted(dbFile, passphrase)
builder.openHelperFactory(
SupportOpenHelperFactory(passphrase.toByteArray(Charsets.US_ASCII), null, false),
CacheOpenMode.Plaintext -> FrameworkSQLiteOpenHelperFactory()
}
realFactory.create(configuration)
},
)
} else if (DatabaseEncryption.isEncrypted(dbFile)) {
// Encryption was turned back off — decrypt so the default (unkeyed) open succeeds.
val passphrase = runBlocking { keyStore.resolvePassphrase(appLock) }
DatabaseEncryption.ensurePlaintext(dbFile, passphrase)
}
return builder.build()
}
.build()
@Provides
fun provideMessageDao(database: LibreMailDatabase): MessageDao = database.messageDao()
@@ -0,0 +1,44 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.restart
import android.content.Context
import android.content.Intent
import android.os.Process
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
/**
* Relaunches the whole app in a brand-new process by handing off to [RestartActivity], a trampoline
* that runs in the separate `:restart` process. Because the trampoline survives the current process
* being killed, the relaunch it issues cannot be dropped by ActivityManager scheduling it into the
* dying process — the failure mode of a same-process "startActivity then exit(0)" restart.
*
* Used by the app-lock key-invalidation recovery to bounce the process so the cache is wiped safely at
* the next cold start (before Room reopens it).
*/
@Singleton
class ProcessRestarter @Inject constructor(@ApplicationContext private val context: Context) {
/**
* Start the [RestartActivity] trampoline in the `:restart` process, passing it this (main) process
* PID so it can kill us and relaunch from the outside. Returns immediately; the actual kill +
* relaunch happens in the trampoline process moments later.
*/
fun restart() {
val trampoline = Intent(context, RestartActivity::class.java).apply {
// Required because we may be started from a non-Activity (Application) context.
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
putExtra(RestartActivity.EXTRA_ORIGINAL_PID, Process.myPid())
}
context.startActivity(trampoline)
}
companion object {
/**
* The `android:process` suffix of [RestartActivity] (must match AndroidManifest.xml). The
* Application uses it to skip its normal startup work when it is spun up in this aux process.
*/
const val PROCESS_SUFFIX = ":restart"
}
}
@@ -0,0 +1,59 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.restart
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.os.Process
import android.util.Log
/**
* Separate-process trampoline that performs an app relaunch from OUTSIDE the process being killed.
*
* Declared in the manifest with `android:process=":restart"`, so Android runs it in its own process.
* That is the whole point: it kills the original (main) process by PID and only THEN starts the main
* launcher activity, so the relaunch is scheduled from a process that is NOT the one being torn down.
* A same-process "startActivity then Runtime.exit(0)" restart races ActivityManager — the relaunch can
* be scheduled into the dying process and silently dropped, so the app just closes. Issuing it from a
* surviving process (the ProcessPhoenix pattern) makes the relaunch reliable.
*
* DEVICE-ONLY: the multi-process kill/relaunch cannot be exercised in JVM unit tests; see
* [ProcessRestarter] for the (testable) intent/targeting seam and AppLockViewModelTest for the
* ordering guarantees around it.
*/
class RestartActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Kill the original main process FIRST so the relaunch below spins up a genuinely fresh
// process — one whose cold DatabaseModule performs the pending cache wipe before Room opens.
// If we relaunched while the old process were still alive, ActivityManager could route the
// launch back into it and the wipe-on-cold-start would never run.
val originalPid = intent.getIntExtra(EXTRA_ORIGINAL_PID, INVALID_PID)
if (originalPid > INVALID_PID && originalPid != Process.myPid()) {
Process.killProcess(originalPid)
}
val launchIntent = packageManager.getLaunchIntentForPackage(packageName)
?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
if (launchIntent != null) {
startActivity(launchIntent)
} else {
Log.w(TAG, "no launch intent for $packageName; cannot relaunch after restart")
}
finish()
// Tear down this trampoline process too: its only job was to issue the relaunch from outside
// the dying main process.
Runtime.getRuntime().exit(0)
}
companion object {
/** Extra carrying the PID of the main process to kill, so the relaunch starts a fresh one. */
const val EXTRA_ORIGINAL_PID = "org.libremail.restart.ORIGINAL_PID"
private const val INVALID_PID = -1
private const val TAG = "LibreMailRestart"
}
}
@@ -37,6 +37,7 @@ import org.libremail.ui.mailbox.MailboxScreen
import org.libremail.ui.navigation.Routes
import org.libremail.ui.onboarding.AddAnotherAccountScreen
import org.libremail.ui.onboarding.BatteryOptimizationScreen
import org.libremail.ui.onboarding.ContactsAccessScreen
import org.libremail.ui.onboarding.OnboardingViewModel
import org.libremail.ui.onboarding.OnboardingWelcomeScreen
import org.libremail.ui.outbox.OutboxScreen
@@ -327,12 +328,15 @@ private fun NavGraphBuilder.onboardingGraph(navController: NavHostController) {
}
/**
* The tail of onboarding: the "add another?" prompt and the optional battery opt-in step. Split out of
* [onboardingGraph] so each stays a readable length; both share the graph-scoped [OnboardingViewModel].
* The tail of onboarding: the "add another?" prompt and the optional contacts + battery opt-in steps.
* Split out of [onboardingGraph] so each stays a readable length; all share the graph-scoped
* [OnboardingViewModel]. The optional steps chain — contacts (#127) then battery (#49) — each shown
* only when needed; any that isn't is skipped, and a still-undecided (null) decision fails open.
*/
private fun NavGraphBuilder.onboardingFinishDestinations(navController: NavHostController) {
composable(Routes.ONBOARDING_ADD_ANOTHER) { entry ->
val onboarding = onboardingViewModel(navController, entry)
val contactsPromptNeeded by onboarding.contactsPromptNeeded.collectAsStateWithLifecycle()
val batteryPromptNeeded by onboarding.batteryPromptNeeded.collectAsStateWithLifecycle()
AddAnotherAccountScreen(
onAddAnother = {
@@ -341,14 +345,18 @@ private fun NavGraphBuilder.onboardingFinishDestinations(navController: NavHostC
popUpTo(Routes.ONBOARDING_PICKER) { inclusive = true }
}
},
onFinish = { navController.advanceOnboarding(onboarding, contactsPromptNeeded, batteryPromptNeeded) },
)
}
composable(Routes.ONBOARDING_CONTACTS) { entry ->
val onboarding = onboardingViewModel(navController, entry)
val batteryPromptNeeded by onboarding.batteryPromptNeeded.collectAsStateWithLifecycle()
ContactsAccessScreen(
viewModel = onboarding,
onFinish = {
// Offer the battery opt-in as a final step when it's needed; otherwise go straight to
// the inbox. A still-undecided (null) decision fails open to finishing.
if (batteryPromptNeeded == true) {
navController.navigate(Routes.ONBOARDING_BATTERY)
} else {
navController.finishOnboarding(onboarding.firstAddedAccountId)
}
onboarding.markContactsPromptHandled()
// Contacts is skipped here (it was the step just shown); only battery may remain.
navController.advanceOnboarding(onboarding, contactsPromptNeeded = false, batteryPromptNeeded)
},
)
}
@@ -364,6 +372,23 @@ private fun NavGraphBuilder.onboardingFinishDestinations(navController: NavHostC
}
}
/**
* Advances through the optional onboarding tail: the next still-needed opt-in step (contacts, then
* battery), or the inbox once none remain. Each `*PromptNeeded` is the graph-scoped decision; `null`
* (undecided) is treated as "not needed" so a slow read never blocks the end of onboarding.
*/
private fun NavHostController.advanceOnboarding(
onboarding: OnboardingViewModel,
contactsPromptNeeded: Boolean?,
batteryPromptNeeded: Boolean?,
) {
when {
contactsPromptNeeded == true -> navigate(Routes.ONBOARDING_CONTACTS)
batteryPromptNeeded == true -> navigate(Routes.ONBOARDING_BATTERY)
else -> finishOnboarding(onboarding.firstAddedAccountId)
}
}
/** Leaves onboarding for the inbox — the first account added this session, or the unfiltered mailbox. */
private fun NavController.finishOnboarding(firstAccountId: String?) {
val dest = if (firstAccountId != null) Routes.mailboxForAccount(firstAccountId) else Routes.MAILBOX
@@ -67,6 +67,8 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.collect
import org.libremail.R
@@ -81,10 +83,6 @@ fun ComposeScreen(onBack: () -> Unit, viewModel: ComposeViewModel = hiltViewMode
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
val permissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { granted -> viewModel.onContactsPermission(granted) }
val attachmentPicker = rememberLauncherForActivityResult(
ActivityResultContracts.OpenMultipleDocuments(),
) { uris ->
@@ -98,16 +96,15 @@ fun ComposeScreen(onBack: () -> Unit, viewModel: ComposeViewModel = hiltViewMode
)
}
LaunchedEffect(Unit) {
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) ==
PackageManager.PERMISSION_GRANTED
if (granted) {
viewModel.onContactsPermission(
true,
)
} else {
permissionLauncher.launch(Manifest.permission.READ_CONTACTS)
}
// Reflect the current READ_CONTACTS grant without ever prompting: the request now lives in the
// onboarding contacts step (#127) and the Settings entry (#129), so compose only reads state.
// Re-checked on resume so enabling autocomplete later (e.g. from Settings) takes effect the next
// time compose is shown. Denial degrades gracefully — searchContacts() guards on this flag.
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
viewModel.onContactsPermission(
ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) ==
PackageManager.PERMISSION_GRANTED,
)
}
LaunchedEffect(Unit) { viewModel.finished.collect { onBack() } }
BackHandler { viewModel.onExit() }
@@ -2,16 +2,18 @@
package org.libremail.ui.lock
import android.content.Context
import android.content.Intent
import android.os.SystemClock
import android.security.keystore.KeyPermanentlyInvalidatedException
import android.security.keystore.UserNotAuthenticatedException
import android.util.Log
import androidx.annotation.VisibleForTesting
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.work.Operation
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -30,6 +32,10 @@ import org.libremail.data.security.LockState
import org.libremail.data.security.PassphraseSession
import org.libremail.data.settings.SettingsRepository
import org.libremail.data.sync.SyncScheduler
import org.libremail.restart.ProcessRestarter
import java.util.concurrent.ExecutionException
import java.util.concurrent.TimeUnit
import java.util.concurrent.TimeoutException
import javax.inject.Inject
/** UI state of the app-lock gate that wraps the whole app. */
@@ -73,6 +79,10 @@ class AppLockViewModel @Inject constructor(
private val databaseKeyCipher: DatabaseKeyCipher,
private val session: PassphraseSession,
private val syncScheduler: SyncScheduler,
// Issues the key-invalidation recovery relaunch from a separate ":restart" process that survives
// this process being killed, so the relaunch can't be dropped by ActivityManager scheduling it
// into the dying process (the same-process "startActivity then exit(0)" race).
private val processRestarter: ProcessRestarter,
// Application-scoped (see SecurityModule): the gate is injected rather than owned by this
// Activity-scoped ViewModel so the inactivity grace window survives Activity recreation — Back on
// the task root finishes the Activity and clears its ViewModelStore on API 29/30, which would
@@ -83,6 +93,12 @@ class AppLockViewModel @Inject constructor(
private val _uiState = MutableStateFlow<AppLockUiState>(AppLockUiState.Checking)
val uiState: StateFlow<AppLockUiState> = _uiState.asStateFlow()
// The dispatcher for blocking Keystore/DataStore/WorkManager work pushed off the main thread.
// Injectable so the recovery flow (clearCacheAndRestart) runs on the test scheduler and its
// ordering — enqueue durably persisted BEFORE the restart — is deterministically verifiable.
@VisibleForTesting
internal var defaultDispatcher: CoroutineDispatcher = Dispatchers.Default
// Cached so onBackground / onForeground can cover the content synchronously (before the async
// settings read) whenever app-lock is on — so no stale mailbox frame renders on resume.
@Volatile private var appLockEnabledCached = false
@@ -125,7 +141,7 @@ class AppLockViewModel @Inject constructor(
// App-lock is on: cover any showing content while we resolve, so no stale mailbox frame
// renders before the (async) decision lands.
if (_uiState.value == AppLockUiState.Unlocked) _uiState.value = AppLockUiState.Checking
val action = withContext(Dispatchers.Default) {
val action = withContext(defaultDispatcher) {
KeyInvalidationPolicy.decide(
appLockEnabled = true,
encryptCacheEnabled = settings.encryptCache,
@@ -173,7 +189,7 @@ class AppLockViewModel @Inject constructor(
/** Called by the host after a successful `BiometricPrompt`. */
fun onAuthenticated() {
viewModelScope.launch {
when (withContext(Dispatchers.Default) { unlockOrArm() }) {
when (withContext(defaultDispatcher) { unlockOrArm() }) {
UnlockResult.OK -> {
gate.onAuthenticated()
publish()
@@ -258,25 +274,52 @@ class AppLockViewModel @Inject constructor(
}
private suspend fun clearCacheAndRestart(disableAppLock: Boolean) {
withContext(Dispatchers.Default) {
withContext(defaultDispatcher) {
// Record the wipe intent BEFORE flipping app-lock off, so a crash between the two writes
// leaves the wipe still pending (recoverable) rather than a disabled gate over a stale key.
// Both are DataStore edits that only return once durably committed, so they survive the
// restart below without further ceremony.
databaseKeyStore.setClearPending()
if (disableAppLock) settingsRepository.setAppLock(false)
syncScheduler.syncNow() // persisted by WorkManager; survives the restart
// Enqueue the post-wipe re-sync and BLOCK until WorkManager has durably persisted its
// WorkSpec before we hand off to the restart. syncNow() only *schedules* the insert on
// WorkManager's serial task executor; killing the process (via restartProcess) can race
// that async insert and drop the re-sync, leaving an empty mailbox after the wipe until the
// next periodic sync. Awaiting the enqueue Operation makes "survives the restart" real.
awaitSyncEnqueue(syncScheduler.syncNow())
}
restartProcess()
}
/**
* Block until WorkManager confirms the re-sync WorkSpec is durably persisted, bounded by
* [SYNC_ENQUEUE_TIMEOUT_SECONDS] so a stuck insert can never wedge recovery. A timeout/failure is
* logged and we restart anyway: the periodic sync will still eventually refill the wiped cache, so
* a best-effort wait is strictly better than the previous fire-and-forget enqueue. Runs on
* [defaultDispatcher] (never the main thread) because [Operation.result]'s get blocks.
*/
private fun awaitSyncEnqueue(operation: Operation) {
try {
operation.result.get(SYNC_ENQUEUE_TIMEOUT_SECONDS, TimeUnit.SECONDS)
} catch (e: TimeoutException) {
Log.w(TAG, "re-sync enqueue not confirmed within timeout; restarting anyway", e)
} catch (e: ExecutionException) {
Log.w(TAG, "re-sync enqueue failed; restarting anyway", e)
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
Log.w(TAG, "interrupted awaiting re-sync enqueue; restarting anyway", e)
}
}
/**
* Relaunch the app in a fresh process so [org.libremail.di.DatabaseModule] wipes the cache before
* Room reopens it. DEVICE-ONLY: process restart cannot be exercised in JVM unit tests.
* Room reopens it. Delegates to [ProcessRestarter], which issues the relaunch from a separate
* process that survives this one being killed — a same-process "startActivity then exit(0)" is
* unreliable because ActivityManager may schedule the relaunch into the dying process and drop it.
* DEVICE-ONLY end to end: the multi-process kill/relaunch cannot be exercised in JVM unit tests.
*/
private fun restartProcess() {
val intent = context.packageManager.getLaunchIntentForPackage(context.packageName)
?.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK)
if (intent != null) context.startActivity(intent)
Runtime.getRuntime().exit(0)
processRestarter.restart()
}
// Monotonic clock so a wall-clock change can't extend the inactivity grace window.
@@ -284,5 +327,10 @@ class AppLockViewModel @Inject constructor(
private companion object {
const val TAG = "LibreMailAppLock"
// Upper bound on waiting for WorkManager to persist the re-sync WorkSpec. The insert is
// normally sub-second; this only caps a pathological stall so recovery can't hang before the
// restart. On timeout we restart anyway (the periodic sync still refills the cache later).
const val SYNC_ENQUEUE_TIMEOUT_SECONDS = 5L
}
}
@@ -36,6 +36,11 @@ object Routes {
const val ONBOARDING_MANUAL = "onboarding/manual"
const val ONBOARDING_ADD_ANOTHER = "onboarding/add_another"
// Optional onboarding step: invites the user to allow contacts access for on-device recipient
// autocomplete (#127). Shown only when the permission isn't already granted and the user hasn't
// handled it before; skippable, and precedes the battery step in the finish tail.
const val ONBOARDING_CONTACTS = "onboarding/contacts"
// Optional final onboarding step: invites the user to allow unrestricted background/battery usage
// so push (IMAP IDLE) and periodic sync aren't throttled by Doze (#49). Shown only when the app
// isn't already exempt and the user hasn't handled it before; otherwise onboarding skips straight
@@ -0,0 +1,172 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.onboarding
import android.Manifest
import androidx.activity.compose.LocalActivity
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.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.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Person
import androidx.compose.material3.Button
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.app.ActivityCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.libremail.R
/**
* Optional onboarding step (shown only when needed, see [OnboardingViewModel.contactsPromptNeeded]):
* invites the user to allow contacts access for on-device recipient autocomplete. The rationale is
* on-screen up front — contacts are used **only** for suggesting recipients while composing and are
* never uploaded (#128) — and the step is clearly skippable (#127): **Not now** proceeds without it.
*
* The `READ_CONTACTS` request fires **once**, from here — the compose screen no longer prompts. After
* a grant the screen shows a "done" state; a later change of heart is handled by the Settings entry
* (#129). On returning from anywhere the grant is re-read so the screen reflects the current state.
*
* @param viewModel the graph-scoped onboarding view model (holds the live grant + the handled flag).
* @param onFinish leaves the step for the next destination; the caller also marks the prompt handled.
*/
@Composable
fun ContactsAccessScreen(viewModel: OnboardingViewModel, onFinish: () -> Unit) {
val granted by viewModel.contactsGranted.collectAsStateWithLifecycle()
val activity = LocalActivity.current
var showRationale by remember { mutableStateOf(false) }
fun refreshRationale() {
showRationale = activity != null &&
ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.READ_CONTACTS)
}
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { result ->
viewModel.onContactsPermissionResult(result)
refreshRationale()
}
// Re-check the grant (and whether a rationale is now owed) on resume so a change made elsewhere —
// e.g. the user granted from system settings — is reflected when this step comes back to the fore.
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
viewModel.refreshContactsStatus()
refreshRationale()
}
ContactsAccessContent(
granted = granted,
showRationale = showRationale,
onAllow = {
// Persist "the dialog was shown" up front so a permanent denial is later distinguishable
// from "never asked" in Settings, even if the process dies before the result arrives.
viewModel.markContactsPermissionRequested()
launcher.launch(Manifest.permission.READ_CONTACTS)
},
onSkip = onFinish,
onContinue = onFinish,
)
}
/**
* Presentational body of the contacts-access step, split out so its three paths — skip, grant (the
* "done" state), and request (with the [showRationale] re-ask explanation) — are driven deterministically
* in tests without a live system permission dialog.
*/
@Composable
fun ContactsAccessContent(
granted: Boolean,
showRationale: Boolean,
onAllow: () -> Unit,
onSkip: () -> Unit,
onContinue: () -> Unit,
) {
Scaffold { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
imageVector = if (granted) Icons.Filled.CheckCircle else Icons.Filled.Person,
contentDescription = null,
modifier = Modifier.size(72.dp),
tint = MaterialTheme.colorScheme.primary,
)
Spacer(Modifier.height(24.dp))
Text(
text = stringResource(
if (granted) R.string.onboarding_contacts_done_title else R.string.onboarding_contacts_title,
),
style = MaterialTheme.typography.headlineSmall,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(8.dp))
Text(
text = stringResource(
if (granted) R.string.onboarding_contacts_done_body else R.string.onboarding_contacts_body,
),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(32.dp))
if (granted) {
Button(
onClick = onContinue,
modifier = Modifier.fillMaxWidth().widthIn(max = 360.dp),
) {
Text(stringResource(R.string.onboarding_contacts_continue))
}
} else {
if (showRationale) {
Text(
text = stringResource(R.string.onboarding_contacts_rationale),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
Spacer(Modifier.height(24.dp))
}
Button(
onClick = onAllow,
modifier = Modifier.fillMaxWidth().widthIn(max = 360.dp),
) {
Text(stringResource(R.string.onboarding_contacts_allow))
}
Spacer(Modifier.height(12.dp))
OutlinedButton(
onClick = onSkip,
modifier = Modifier.fillMaxWidth().widthIn(max = 360.dp),
) {
Text(stringResource(R.string.onboarding_contacts_not_now))
}
}
}
}
}
@@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import org.libremail.contacts.ContactsPermissionManager
import org.libremail.data.settings.SettingsRepository
import org.libremail.push.BatteryOptimizationManager
import org.libremail.push.BatteryPromptDecision
@@ -19,11 +20,13 @@ import javax.inject.Inject
* entry, so it is created when onboarding starts and cleared when the graph is popped.
*
* It remembers the **first** account added this session (so finishing opens that account's inbox, see
* #30) and decides whether to show the "unrestricted battery" opt-in step before finishing (see #49).
* #30) and decides which optional opt-in steps to show before finishing: the "contacts access" step
* for recipient autocomplete (#127) and the "unrestricted battery" step for instant push (#49).
*/
@HiltViewModel
class OnboardingViewModel @Inject constructor(
private val batteryOptimizationManager: BatteryOptimizationManager,
private val contactsPermissionManager: ContactsPermissionManager,
private val settingsRepository: SettingsRepository,
) : ViewModel() {
@@ -45,6 +48,20 @@ class OnboardingViewModel @Inject constructor(
/** Live "Unrestricted" status, re-read when the opt-in step resumes (e.g. back from Settings). */
val batteryUnrestricted: StateFlow<Boolean> = _batteryUnrestricted.asStateFlow()
private val _contactsPromptNeeded = MutableStateFlow<Boolean?>(null)
/**
* Whether onboarding should show the optional contacts-access step. `null` until decided; like
* [batteryPromptNeeded] the finish path treats `null` as "skip". Offered only when the permission
* isn't already granted and the user hasn't already handled the step on a previous onboarding run.
*/
val contactsPromptNeeded: StateFlow<Boolean?> = _contactsPromptNeeded.asStateFlow()
private val _contactsGranted = MutableStateFlow(contactsPermissionManager.hasPermission())
/** Live `READ_CONTACTS` grant, re-read when the contacts step resumes and after a request result. */
val contactsGranted: StateFlow<Boolean> = _contactsGranted.asStateFlow()
init {
viewModelScope.launch {
val unrestricted = batteryOptimizationManager.isIgnoringBatteryOptimizations()
@@ -55,6 +72,11 @@ class OnboardingViewModel @Inject constructor(
alreadyHandled = settingsRepository.isBatteryPromptHandled(),
)
}
viewModelScope.launch {
_contactsPromptNeeded.value =
!contactsPermissionManager.hasPermission() &&
!settingsRepository.isContactsPromptHandled()
}
}
/** Records a freshly added account. Only the first one sticks — later adds don't overwrite it. */
@@ -78,4 +100,27 @@ class OnboardingViewModel @Inject constructor(
fun markBatteryPromptHandled() {
viewModelScope.launch { settingsRepository.setBatteryPromptHandled(true) }
}
/** Re-read the live `READ_CONTACTS` grant; call when the contacts step resumes. */
fun refreshContactsStatus() {
_contactsGranted.value = contactsPermissionManager.hasPermission()
}
/** Fold the result of the system contacts-permission dialog back into [contactsGranted]. */
fun onContactsPermissionResult(granted: Boolean) {
_contactsGranted.value = granted
}
/**
* Record that the `READ_CONTACTS` system dialog is about to be (or has been) shown, so a later
* permanent denial is distinguishable from "never asked" in Settings (#129). Call before launching.
*/
fun markContactsPermissionRequested() {
viewModelScope.launch { settingsRepository.setContactsPermissionRequested(true) }
}
/** Record that the user has seen/acted on the contacts opt-in so onboarding won't ask again. */
fun markContactsPromptHandled() {
viewModelScope.launch { settingsRepository.setContactsPromptHandled(true) }
}
}
@@ -1,6 +1,10 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.settings
import android.Manifest
import androidx.activity.compose.LocalActivity
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
@@ -12,6 +16,7 @@ import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowDropDown
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -20,11 +25,14 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
@@ -32,11 +40,14 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.app.ActivityCompat
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.libremail.R
import org.libremail.contacts.ContactPermissionDecision
import org.libremail.contacts.ContactPermissionState
import org.libremail.data.settings.FetchPolicy
import org.libremail.ui.LibreMailBottomBar
import org.libremail.ui.TopDest
@@ -56,9 +67,28 @@ fun SettingsScreen(
val appLockMessage by viewModel.appLockMessage.collectAsStateWithLifecycle()
val batteryUnrestricted by viewModel.batteryUnrestricted.collectAsStateWithLifecycle()
val context = LocalContext.current
val activity = LocalActivity.current
val resources = LocalResources.current
val snackbarHostState = remember { SnackbarHostState() }
// Contacts-autocomplete entry (#129): its on / off / blocked-in-settings state is derived from the
// live grant, the Activity's rationale signal, and whether the dialog was ever shown — recomputed
// on resume (e.g. back from system settings) and when the "requested" flag flips.
val contactsRequested by viewModel.contactsPermissionRequested.collectAsStateWithLifecycle()
var contactsState by remember { mutableStateOf(ContactPermissionState.DENIED) }
var showContactsRationale by remember { mutableStateOf(false) }
var showContactsBlocked by remember { mutableStateOf(false) }
fun resolveContactsState() = ContactPermissionDecision.resolve(
granted = viewModel.hasContactsPermission(),
showRationale = activity != null &&
ActivityCompat.shouldShowRequestPermissionRationale(activity, Manifest.permission.READ_CONTACTS),
alreadyRequested = contactsRequested,
)
val contactsPermissionLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(),
) { contactsState = resolveContactsState() }
LaunchedEffect(contactsRequested) { contactsState = resolveContactsState() }
// Surface a rejected app-lock toggle via the canonical snackbar pattern (matches MailboxScreen).
// The ViewModel holds the @StringRes id; resolve it here via LocalResources (so it re-resolves on
// configuration changes) at the display boundary, then clear it.
@@ -69,8 +99,11 @@ fun SettingsScreen(
}
}
// Re-read the battery status on resume so it reflects any change made in system settings.
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.refreshBatteryStatus() }
// Re-read the battery + contacts state on resume so both reflect changes made in system settings.
LifecycleEventEffect(Lifecycle.Event.ON_RESUME) {
viewModel.refreshBatteryStatus()
contactsState = resolveContactsState()
}
Scaffold(
topBar = { TopAppBar(title = { Text(stringResource(R.string.title_settings)) }) },
@@ -108,6 +141,23 @@ fun SettingsScreen(
)
HorizontalDivider()
SectionHeader(stringResource(R.string.settings_contacts))
ContactAutocompleteRow(
state = contactsState,
onClick = {
when (contactsState) {
// Already on: send to system settings, the only place to turn it back off.
ContactPermissionState.GRANTED ->
runCatching { context.startActivity(viewModel.contactsSettingsIntent()) }
// Re-requestable in-app: explain first (#128), then launch the system dialog.
ContactPermissionState.DENIED -> showContactsRationale = true
// Permanently denied: an in-app request is a no-op, so deep-link to settings.
ContactPermissionState.BLOCKED -> showContactsBlocked = true
}
},
)
HorizontalDivider()
SectionHeader(stringResource(R.string.settings_appearance))
SwitchRow(
title = stringResource(R.string.settings_dynamic_color),
@@ -211,6 +261,69 @@ fun SettingsScreen(
}
}
}
if (showContactsRationale) {
ContactsPermissionDialog(
title = stringResource(R.string.settings_contacts_dialog_title),
body = stringResource(R.string.settings_contacts_rationale),
confirm = stringResource(R.string.settings_contacts_allow),
onConfirm = {
showContactsRationale = false
// Mark the dialog as shown BEFORE launching, so a permanent denial reads as "blocked".
viewModel.markContactsPermissionRequested()
contactsPermissionLauncher.launch(Manifest.permission.READ_CONTACTS)
},
onDismiss = { showContactsRationale = false },
)
}
if (showContactsBlocked) {
ContactsPermissionDialog(
title = stringResource(R.string.settings_contacts_dialog_title),
body = stringResource(R.string.settings_contacts_blocked_body),
confirm = stringResource(R.string.settings_contacts_open_settings),
onConfirm = {
showContactsBlocked = false
runCatching { context.startActivity(viewModel.contactsSettingsIntent()) }
},
onDismiss = { showContactsBlocked = false },
)
}
}
/**
* The contacts-autocomplete row (#129). Its subtitle reflects the current [state]: on, off (tap to
* turn on), or blocked in system settings. Extracted so each state renders deterministically in tests.
*/
@Composable
internal fun ContactAutocompleteRow(state: ContactPermissionState, onClick: () -> Unit) {
val subtitleRes = when (state) {
ContactPermissionState.GRANTED -> R.string.settings_contacts_autocomplete_on
ContactPermissionState.DENIED -> R.string.settings_contacts_autocomplete_off
ContactPermissionState.BLOCKED -> R.string.settings_contacts_autocomplete_blocked
}
ClickRow(
title = stringResource(R.string.settings_contacts_autocomplete),
subtitle = stringResource(subtitleRes),
onClick = onClick,
)
}
/** Shared confirm/cancel dialog for the contacts rationale (before a request) and the blocked case. */
@Composable
private fun ContactsPermissionDialog(
title: String,
body: String,
confirm: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit,
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = { Text(body) },
confirmButton = { TextButton(onClick = onConfirm) { Text(confirm) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringResource(R.string.cancel)) } },
)
}
@Composable
@@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.libremail.R
import org.libremail.contacts.ContactsPermissionManager
import org.libremail.data.security.AppLockManager
import org.libremail.data.security.DatabaseKeyStore
import org.libremail.data.settings.AppSettings
@@ -31,6 +32,7 @@ class SettingsViewModel @Inject constructor(
private val appLockManager: AppLockManager,
private val databaseKeyStore: DatabaseKeyStore,
private val batteryOptimizationManager: BatteryOptimizationManager,
private val contactsPermissionManager: ContactsPermissionManager,
private val syncScheduler: SyncScheduler,
) : ViewModel() {
@@ -40,6 +42,14 @@ class SettingsViewModel @Inject constructor(
val settings: StateFlow<AppSettings> = settingsRepository.settings
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), AppSettings())
/**
* Whether the `READ_CONTACTS` system dialog has ever been shown, so the contacts entry (#129) can
* tell "never asked" (an in-app request still works) from "permanently denied" (Settings only).
* See [ContactPermissionDecision][org.libremail.contacts.ContactPermissionDecision].
*/
val contactsPermissionRequested: StateFlow<Boolean> = settingsRepository.contactsPermissionRequested
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
private val _advancedExpanded = MutableStateFlow(false)
val advancedExpanded: StateFlow<Boolean> = _advancedExpanded.asStateFlow()
@@ -62,6 +72,15 @@ class SettingsViewModel @Inject constructor(
/** Intent to the system screen where the user flips this app to "Unrestricted". */
fun batterySettingsIntent(): Intent = batteryOptimizationManager.settingsIntent()
/** Whether `READ_CONTACTS` is currently granted (drives the contacts-autocomplete row's state). */
fun hasContactsPermission(): Boolean = contactsPermissionManager.hasPermission()
/** Intent to this app's system details screen, to enable contacts when it's permanently denied. */
fun contactsSettingsIntent(): Intent = contactsPermissionManager.settingsIntent()
/** Persist that the contacts dialog is being shown, so a later denial reads as "blocked", not "off". */
fun markContactsPermissionRequested() = update { settingsRepository.setContactsPermissionRequested(true) }
fun setDynamicColor(value: Boolean) = update { settingsRepository.setDynamicColor(value) }
fun setNewMailNotifications(value: Boolean) = update { settingsRepository.setNewMailNotifications(value) }
fun setPushIdle(value: Boolean) = update { settingsRepository.setPushIdle(value) }
+22
View File
@@ -159,6 +159,16 @@
<string name="onboarding_battery_done_body">Background usage is unrestricted — new mail will arrive instantly.</string>
<string name="onboarding_battery_continue">Continue to inbox</string>
<!-- Onboarding: optional contacts-access opt-in for recipient autocomplete (#127, #128) -->
<string name="onboarding_contacts_title">Suggest recipients as you type</string>
<string name="onboarding_contacts_body">Allow access to your contacts and LibreMail will suggest matching names and email addresses while you compose. This happens entirely on your device — your contacts are never uploaded or shared. It\'s optional; you can skip it and enter addresses yourself.</string>
<string name="onboarding_contacts_rationale">LibreMail needs the Contacts permission to suggest recipients. It\'s used only for on-device autocomplete — nothing is uploaded.</string>
<string name="onboarding_contacts_allow">Allow contacts access</string>
<string name="onboarding_contacts_not_now">Not now</string>
<string name="onboarding_contacts_done_title">Autocomplete is on</string>
<string name="onboarding_contacts_done_body">LibreMail will suggest recipients from your contacts as you compose — all on this device.</string>
<string name="onboarding_contacts_continue">Continue</string>
<!-- Account setup (vendor picker) -->
<string name="account_setup_outlook">Outlook or Hotmail</string>
<string name="account_setup_other">Other (IMAP/SMTP)</string>
@@ -296,6 +306,18 @@
<string name="settings_adv_battery_unrestricted">Unrestricted — instant background mail is allowed.</string>
<string name="settings_adv_battery_optimized">Optimized by Android — new mail may be delayed. Tap to allow unrestricted background usage.</string>
<!-- Contacts / recipient autocomplete (#129) -->
<string name="settings_contacts">Contacts</string>
<string name="settings_contacts_autocomplete">Recipient autocomplete</string>
<string name="settings_contacts_autocomplete_on">On — suggesting recipients from your contacts as you type. Tap to manage.</string>
<string name="settings_contacts_autocomplete_off">Off — tap to suggest recipients from your contacts. On-device only; never uploaded.</string>
<string name="settings_contacts_autocomplete_blocked">Blocked in system settings — tap to open settings and allow Contacts access.</string>
<string name="settings_contacts_dialog_title">Recipient autocomplete</string>
<string name="settings_contacts_rationale">LibreMail suggests recipients from your device contacts as you compose. This happens entirely on your device — your contacts are never uploaded or shared.</string>
<string name="settings_contacts_allow">Allow</string>
<string name="settings_contacts_blocked_body">Contacts access is turned off for LibreMail in Android settings. Open settings and allow Contacts to enable recipient autocomplete.</string>
<string name="settings_contacts_open_settings">Open settings</string>
<!-- Diagnostics / debug reporting -->
<string name="settings_diagnostics">Diagnostics</string>
<string name="settings_report_problem">Report a problem</string>
+5 -4
View File
@@ -5,10 +5,11 @@
which applies on API 31+. Backup is still gated by LibreMailBackupAgent (opt-in, OFF by default).
<include> makes this a strict allowlist: ONLY the libremail_settings DataStore is backed up. The
Keystore-sealed cache passphrase (datastore/libremail_dbkey.preferences_pb) and the encrypted
credentials + mail-cache database (libremail.db and its -wal/-shm/-journal side files) are kept
off-device by being omitted from the allowlist; the mail cache re-downloads on the next sync and
accounts are re-added on a new device. (Lint's FullBackupContent rule forbids <exclude> paths
Keystore-sealed cache passphrase (datastore/libremail_dbkey.preferences_pb), the mail-cache
database (libremail.db) and the accounts + encrypted-credentials database (libremail-accounts.db,
which issue #111 split out of libremail.db) — each with their -wal/-shm/-journal side files — are
kept off-device by being omitted from the allowlist; the mail cache re-downloads on the next sync
and accounts are re-added on a new device. (Lint's FullBackupContent rule forbids <exclude> paths
outside an <include>, so exclusion is expressed by omission rather than explicit <exclude> entries.)
-->
<full-backup-content>
@@ -11,8 +11,11 @@
- datastore/libremail_dbkey.preferences_pb: the Keystore-sealed SQLCipher passphrase for the
encrypted cache. The wrapping Keystore key is non-exportable and device-bound, so the
ciphertext is useless anywhere else.
- libremail.db (+ -wal/-shm/-journal): encrypted IMAP/OAuth credentials and the cached mail.
The cache re-downloads on the next sync; accounts are re-added on a new device.
- libremail.db (+ -wal/-shm/-journal): the cached mail; re-downloads on the next sync.
- libremail-accounts.db (+ -wal/-shm/-journal): accounts, encrypted IMAP/OAuth credentials,
per-account settings and signatures (issue #111 moved these out of libremail.db). The
credentials are sealed with a device-bound key, so they would only ever restore as
undecryptable ciphertext; accounts are re-added on a new device.
(Lint's FullBackupContent rule forbids <exclude> paths outside an <include>, so the exclusions are
expressed by simply not listing those paths rather than as explicit <exclude> entries.)
-->