feat(backup): opt-in Android Backup for settings only #41

Merged
JMR-dev merged 2 commits from feat-backup-optin into main 2026-07-01 14:59:40 +00:00
11 changed files with 318 additions and 38 deletions
+10 -1
View File
@@ -10,10 +10,19 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- Android Backup is opt-in and OFF by default (issue #21). allowBackup can't be toggled at
runtime, so LibreMailBackupAgent gates it on the "Include settings in Android Backup"
preference: with backup disabled the agent ships nothing. When enabled, only the settings
DataStore is backed up per the allowlist in data_extraction_rules (API 31+) /
backup_rules (API 29-30) — never credentials, the mail cache, or the Keystore-sealed cache
passphrase. fullBackupOnly keeps this to Auto Backup (full-data) only. -->
<application
android:name=".LibreMailApplication"
android:allowBackup="false"
android:allowBackup="true"
android:backupAgent=".backup.LibreMailBackupAgent"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:fullBackupOnly="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
@@ -0,0 +1,40 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.backup
import org.libremail.data.settings.AppSettings
/**
* Single source of truth for what LibreMail is willing to hand to Android Backup. Kept in lockstep
* with `res/xml/data_extraction_rules.xml` (API 31+) and `res/xml/backup_rules.xml` (API 29-30); the
* path constants here are asserted against those resources by `DataExtractionRulesTest`.
*
* Only re-creatable user preferences are eligible. The mail cache re-downloads on the next sync, and
* the credentials plus the Keystore-sealed cache passphrase are device-bound secrets that would only
* ever restore as undecryptable ciphertext — so they are never backed up.
*/
object BackupPolicy {
/** `filesDir`-relative DataStore file holding user preferences — the only data we back up. */
const val SAFE_SETTINGS_FILE: String = "datastore/libremail_settings.preferences_pb"
/** `filesDir`-relative paths that must never leave the device. */
val EXCLUDED_FILE_PATHS: List<String> = listOf(
// Keystore-sealed SQLCipher passphrase for the encrypted cache: the wrapping key is
// non-exportable and device-bound, so this ciphertext is useless anywhere else.
"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",
)
/**
* Whether Android Backup may run for this app. Opt-in and OFF by default: nothing is backed up
* (or transferred device-to-device) unless the user has explicitly enabled it in Settings.
*/
fun shouldBackUp(settings: AppSettings): Boolean = settings.includeInBackup
}
@@ -0,0 +1,42 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.backup
import android.app.backup.BackupAgentHelper
import android.app.backup.FullBackupDataOutput
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import org.libremail.data.settings.settingsDataStore
import org.libremail.data.settings.toAppSettings
/**
* Enforces the runtime backup opt-in on top of Android Auto Backup.
*
* `android:allowBackup` is a manifest flag that can't be toggled at runtime, so the "include settings
* in Android Backup" preference is enforced here instead: [onFullBackup] runs the backup only when the
* user has opted in (the default is off, so no app data leaves the device). When opted in, it defers to
* the framework, which applies the allowlist in `res/xml/data_extraction_rules.xml` (and
* `res/xml/backup_rules.xml` on API < 31) — backing up the user-preferences DataStore only, never the
* mail cache, the encrypted credentials, or the Keystore-sealed cache passphrase.
*
* Extends [BackupAgentHelper] (rather than raw `BackupAgent`) so the unused key/value backup/restore
* paths inherit safe no-op implementations; only full-data backup is used (`fullBackupOnly=true`), and
* full-data restore uses the default `onRestoreFile` handling.
*
* The opt-in flag is read directly from the shared [settingsDataStore] singleton so it does not depend
* on Hilt or `Application.onCreate` having run in the framework's restricted backup mode.
*/
class LibreMailBackupAgent : BackupAgentHelper() {
override fun onFullBackup(data: FullBackupDataOutput) {
if (backupOptedIn()) {
super.onFullBackup(data)
}
}
/** Reads the opt-in flag; any failure defaults to "not opted in" so we never back up by accident. */
private fun backupOptedIn(): Boolean = runCatching {
runBlocking {
BackupPolicy.shouldBackUp(applicationContext.settingsDataStore.data.first().toAppSettings())
}
}.getOrDefault(false)
}
@@ -1,6 +1,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.data.settings
import android.app.backup.BackupManager
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
@@ -15,7 +16,15 @@ import kotlinx.coroutines.flow.map
import javax.inject.Inject
import javax.inject.Singleton
private val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(name = "libremail_settings")
/**
* User-preferences DataStore. Exposed as `internal` (not `private`) and read via [toAppSettings] so
* that [org.libremail.backup.LibreMailBackupAgent] can consult the backup opt-in flag through the
* exact same singleton instance. The system instantiates the backup agent in the app process while
* the app may already hold this DataStore open; constructing a second DataStore for the same file
* would crash with "There are multiple DataStores active for the same file", so both sides must go
* through this one delegate.
*/
internal val Context.settingsDataStore: DataStore<Preferences> by preferencesDataStore(name = "libremail_settings")
/**
* How aggressively the app downloads message content during sync.
@@ -33,23 +42,40 @@ data class AppSettings(
val allowStartTls: Boolean = false,
val loadRemoteImages: Boolean = false,
val encryptCache: Boolean = false,
val includeInBackup: Boolean = false,
val fetchPolicy: FetchPolicy = FetchPolicy.ALWAYS,
)
private object Keys {
val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color")
val NEW_MAIL_NOTIFICATIONS = booleanPreferencesKey("new_mail_notifications")
val PUSH_IDLE = booleanPreferencesKey("push_idle")
val ALLOW_STARTTLS = booleanPreferencesKey("allow_starttls")
val LOAD_REMOTE_IMAGES = booleanPreferencesKey("load_remote_images")
val ENCRYPT_CACHE = booleanPreferencesKey("encrypt_cache")
val INCLUDE_IN_BACKUP = booleanPreferencesKey("include_in_backup")
val FETCH_POLICY = stringPreferencesKey("fetch_policy")
}
/**
* Maps persisted preferences to [AppSettings]. Shared with the backup agent so it reads the opt-in
* flag (and its default) through exactly the same logic the app uses.
*/
internal fun Preferences.toAppSettings(): AppSettings = AppSettings(
dynamicColor = this[Keys.DYNAMIC_COLOR] ?: true,
newMailNotifications = this[Keys.NEW_MAIL_NOTIFICATIONS] ?: true,
pushIdle = this[Keys.PUSH_IDLE] ?: true,
allowStartTls = this[Keys.ALLOW_STARTTLS] ?: false,
loadRemoteImages = this[Keys.LOAD_REMOTE_IMAGES] ?: false,
encryptCache = this[Keys.ENCRYPT_CACHE] ?: false,
includeInBackup = this[Keys.INCLUDE_IN_BACKUP] ?: false,
fetchPolicy = this[Keys.FETCH_POLICY]?.let { runCatching { FetchPolicy.valueOf(it) }.getOrNull() }
?: FetchPolicy.ALWAYS,
)
@Singleton
class SettingsRepository @Inject constructor(@ApplicationContext private val context: Context) {
val settings: Flow<AppSettings> = context.settingsDataStore.data.map { prefs ->
AppSettings(
dynamicColor = prefs[DYNAMIC_COLOR] ?: true,
newMailNotifications = prefs[NEW_MAIL_NOTIFICATIONS] ?: true,
pushIdle = prefs[PUSH_IDLE] ?: true,
allowStartTls = prefs[ALLOW_STARTTLS] ?: false,
loadRemoteImages = prefs[LOAD_REMOTE_IMAGES] ?: false,
encryptCache = prefs[ENCRYPT_CACHE] ?: false,
fetchPolicy = prefs[FETCH_POLICY]?.let { runCatching { FetchPolicy.valueOf(it) }.getOrNull() }
?: FetchPolicy.ALWAYS,
)
}
val settings: Flow<AppSettings> = context.settingsDataStore.data.map { it.toAppSettings() }
val dynamicColor: Flow<Boolean> = settings.map { it.dynamicColor }
@@ -57,27 +83,28 @@ class SettingsRepository @Inject constructor(@ApplicationContext private val con
suspend fun fetchPolicy(): FetchPolicy = settings.first().fetchPolicy
suspend fun setDynamicColor(value: Boolean) = put(DYNAMIC_COLOR, value)
suspend fun setNewMailNotifications(value: Boolean) = put(NEW_MAIL_NOTIFICATIONS, value)
suspend fun setPushIdle(value: Boolean) = put(PUSH_IDLE, value)
suspend fun setAllowStartTls(value: Boolean) = put(ALLOW_STARTTLS, value)
suspend fun setLoadRemoteImages(value: Boolean) = put(LOAD_REMOTE_IMAGES, value)
suspend fun setEncryptCache(value: Boolean) = put(ENCRYPT_CACHE, 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)
suspend fun setAllowStartTls(value: Boolean) = put(Keys.ALLOW_STARTTLS, value)
suspend fun setLoadRemoteImages(value: Boolean) = put(Keys.LOAD_REMOTE_IMAGES, value)
suspend fun setEncryptCache(value: Boolean) = put(Keys.ENCRYPT_CACHE, value)
/**
* Opts this app in/out of system Android Backup. Off by default. After persisting, nudges the
* framework so the change takes effect on the next backup pass — enabling schedules a backup of
* the safe settings, disabling schedules one that ships nothing (clearing any prior cloud copy).
*/
suspend fun setIncludeInBackup(value: Boolean) {
put(Keys.INCLUDE_IN_BACKUP, value)
runCatching { BackupManager(context).dataChanged() }
}
suspend fun setFetchPolicy(value: FetchPolicy) {
context.settingsDataStore.edit { it[FETCH_POLICY] = value.name }
context.settingsDataStore.edit { it[Keys.FETCH_POLICY] = value.name }
}
private suspend fun put(key: Preferences.Key<Boolean>, value: Boolean) {
context.settingsDataStore.edit { it[key] = value }
}
private companion object {
val DYNAMIC_COLOR = booleanPreferencesKey("dynamic_color")
val NEW_MAIL_NOTIFICATIONS = booleanPreferencesKey("new_mail_notifications")
val PUSH_IDLE = booleanPreferencesKey("push_idle")
val ALLOW_STARTTLS = booleanPreferencesKey("allow_starttls")
val LOAD_REMOTE_IMAGES = booleanPreferencesKey("load_remote_images")
val ENCRYPT_CACHE = booleanPreferencesKey("encrypt_cache")
val FETCH_POLICY = stringPreferencesKey("fetch_policy")
}
}
@@ -113,6 +113,15 @@ fun SettingsScreen(
)
HorizontalDivider()
SectionHeader(stringResource(R.string.settings_backup))
SwitchRow(
title = stringResource(R.string.settings_backup_include),
checked = settings.includeInBackup,
onCheckedChange = viewModel::setIncludeInBackup,
subtitle = stringResource(R.string.settings_backup_include_summary),
)
HorizontalDivider()
AdvancedHeader(expanded = advancedExpanded, onToggle = viewModel::toggleAdvanced)
AnimatedVisibility(visible = advancedExpanded) {
Column {
@@ -41,6 +41,7 @@ class SettingsViewModel @Inject constructor(
fun setAllowStartTls(value: Boolean) = update { settingsRepository.setAllowStartTls(value) }
fun setLoadRemoteImages(value: Boolean) = update { settingsRepository.setLoadRemoteImages(value) }
fun setEncryptCache(value: Boolean) = update { settingsRepository.setEncryptCache(value) }
fun setIncludeInBackup(value: Boolean) = update { settingsRepository.setIncludeInBackup(value) }
fun setFetchPolicy(value: FetchPolicy) = update { settingsRepository.setFetchPolicy(value) }
private inline fun update(crossinline action: suspend () -> Unit) {
+5
View File
@@ -143,6 +143,11 @@
<string name="settings_no_accounts">No accounts yet</string>
<string name="account_remove">Remove account</string>
<!-- Backup -->
<string name="settings_backup">Backup</string>
<string name="settings_backup_include">Include settings in Android Backup</string>
<string name="settings_backup_include_summary">Let Android back up your LibreMail preferences (Google Auto Backup) so they restore when you set up a new device. Your mail, accounts, passwords, and encryption keys are never backed up — only app settings. Off by default; uses Google infrastructure.</string>
<!-- Per-account settings -->
<string name="settings_account_title">Account</string>
<string name="settings_signature">Signature</string>
+16
View File
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<!--
Auto Backup allowlist for API 29-30 (pre-Android 12), mirroring res/xml/data_extraction_rules.xml
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
outside an <include>, so exclusion is expressed by omission rather than explicit <exclude> entries.)
-->
<full-backup-content>
<include domain="file" path="datastore/libremail_settings.preferences_pb" />
</full-backup-content>
+16 -7
View File
@@ -1,17 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- SPDX-License-Identifier: GPL-3.0-or-later -->
<!--
LibreMail stores encrypted IMAP/OAuth credentials and cached mail in its Room database, encrypted
with a non-exportable Android Keystore key. The key never leaves the device, so backing up or
transferring the ciphertext would only restore undecryptable data (and needlessly ship mail and
credential blobs off-device). Exclude all app data from both cloud backup and device-to-device
transfer; accounts are simply re-added on a new device.
Android Backup allowlist for LibreMail (API 31+). Backup is opt-in and OFF by default:
LibreMailBackupAgent only lets a backup or device transfer run once the user enables "Include
settings in Android Backup", so nothing here leaves the device unless explicitly chosen.
<include> makes each section a strict allowlist: ONLY the listed path — the libremail_settings
DataStore of re-creatable user preferences — is ever backed up. Everything else is excluded by
omission, which deliberately keeps these off-device:
- 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.
(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.)
-->
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" path="." />
<include domain="file" path="datastore/libremail_settings.preferences_pb" />
</cloud-backup>
<device-transfer>
<exclude domain="root" path="." />
<include domain="file" path="datastore/libremail_settings.preferences_pb" />
</device-transfer>
</data-extraction-rules>
@@ -0,0 +1,46 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.backup
import org.junit.Test
import org.libremail.data.settings.AppSettings
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class BackupPolicyTest {
@Test
fun `backup is off by default`() {
assertFalse(AppSettings().includeInBackup, "the opt-in default must be off")
assertFalse(BackupPolicy.shouldBackUp(AppSettings()), "no backup runs without opting in")
}
@Test
fun `backup runs only when the user opts in`() {
assertTrue(BackupPolicy.shouldBackUp(AppSettings(includeInBackup = true)))
assertFalse(BackupPolicy.shouldBackUp(AppSettings(includeInBackup = false)))
}
@Test
fun `only the settings datastore is eligible for backup`() {
assertEquals("datastore/libremail_settings.preferences_pb", BackupPolicy.SAFE_SETTINGS_FILE)
// The safe file must not be, or resemble, a secret store.
assertFalse(BackupPolicy.SAFE_SETTINGS_FILE.contains("dbkey"))
}
@Test
fun `the keystore-sealed db key is never eligible for backup`() {
assertTrue(
BackupPolicy.EXCLUDED_FILE_PATHS.any { it.contains("libremail_dbkey") },
"the sealed cache passphrase DataStore must be excluded",
)
}
@Test
fun `the credentials and mail-cache database is never eligible for backup`() {
assertTrue(BackupPolicy.EXCLUDED_DATABASE_PATHS.contains("libremail.db"))
// WAL/SHM/journal side-files can hold recently written rows too.
assertTrue(BackupPolicy.EXCLUDED_DATABASE_PATHS.contains("libremail.db-wal"))
assertTrue(BackupPolicy.EXCLUDED_DATABASE_PATHS.contains("libremail.db-shm"))
}
}
@@ -0,0 +1,76 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.backup
import org.junit.Test
import org.w3c.dom.Element
import java.io.File
import javax.xml.parsers.DocumentBuilderFactory
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
/**
* Validates the shipped Android Backup rule resources directly, so they can't silently drift from
* [BackupPolicy] or from the acceptance criteria of issue #21: only the settings DataStore may be
* eligible, and the Keystore-sealed cache key plus the credentials/mail database must be excluded.
*/
class DataExtractionRulesTest {
private data class Rules(val includes: Set<String>, val excludes: Set<String>)
private fun resource(name: String): File {
// Gradle runs unit tests with the module dir (app/) as the working dir; fall back to the repo
// root in case a runner starts elsewhere.
val candidates = listOf(
File("src/main/res/xml/$name"),
File("app/src/main/res/xml/$name"),
)
return candidates.firstOrNull { it.exists() }
?: error("Could not locate $name; looked in ${candidates.map { it.absolutePath }}")
}
/** Collects the `domain:path` pairs of every <include>/<exclude> under the given section element. */
private fun parseSection(file: File, sectionTag: String): Rules {
val doc = DocumentBuilderFactory.newInstance().newDocumentBuilder().parse(file)
val section = doc.getElementsByTagName(sectionTag).item(0) as Element
fun collect(tag: String): Set<String> {
val nodes = section.getElementsByTagName(tag)
return (0 until nodes.length).map { i ->
val e = nodes.item(i) as Element
"${e.getAttribute("domain")}:${e.getAttribute("path")}"
}.toSet()
}
return Rules(includes = collect("include"), excludes = collect("exclude"))
}
private val safeFile = "file:${BackupPolicy.SAFE_SETTINGS_FILE}"
private val secretPaths: List<String> =
BackupPolicy.EXCLUDED_FILE_PATHS.map { "file:$it" } +
BackupPolicy.EXCLUDED_DATABASE_PATHS.map { "database:$it" }
private fun assertSafe(rules: Rules) {
// Strict allowlist: the settings DataStore is the ONLY thing eligible for backup/transfer.
// Everything else — crucially the Keystore-sealed cache key and the credentials/mail
// database — is excluded simply by not being listed.
assertEquals(setOf(safeFile), rules.includes, "only the settings DataStore may be backed up")
assertTrue(rules.excludes.isEmpty(), "rules are allowlist-only; no <exclude> entries expected")
secretPaths.forEach { secret ->
assertFalse(secret in rules.includes, "$secret must never be eligible for backup")
}
}
@Test
fun `data extraction rules (API 31+) back up only settings for cloud backup`() {
assertSafe(parseSection(resource("data_extraction_rules.xml"), "cloud-backup"))
}
@Test
fun `data extraction rules (API 31+) back up only settings for device transfer`() {
assertSafe(parseSection(resource("data_extraction_rules.xml"), "device-transfer"))
}
@Test
fun `full backup content (API 29-30) mirrors the same exclusions`() {
assertSafe(parseSection(resource("backup_rules.xml"), "full-backup-content"))
}
}