diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 4f0f640..ca415ff 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -14,21 +14,12 @@ plugins {
alias(libs.plugins.detekt)
}
-// Read the Gmail OAuth client id from secrets.properties (git-ignored). Empty when absent.
+// Read optional build secrets (Outlook client id, release signing) from secrets.properties
+// (git-ignored). Absent values fall back to the defaults below.
val secretsFile = rootProject.file("secrets.properties")
val secrets = Properties().apply {
if (secretsFile.exists()) secretsFile.inputStream().use { load(it) }
}
-val gmailOAuthClientId: String = secrets.getProperty("GMAIL_OAUTH_CLIENT_ID", "")
-
-// For a Google installed-app OAuth client, AppAuth's redirect is the reversed client
-// id as a custom URI scheme. Fall back to a placeholder so the manifest stays valid
-// until a real client id is set in secrets.properties.
-val gmailRedirectScheme: String = if (gmailOAuthClientId.endsWith(".apps.googleusercontent.com")) {
- "com.googleusercontent.apps." + gmailOAuthClientId.removeSuffix(".apps.googleusercontent.com")
-} else {
- "org.libremail.oauth"
-}
// Microsoft (Outlook) OAuth public client id — a GUID, not a secret. Overridable via
// secrets.properties; defaults to the app's registered client id.
@@ -37,6 +28,10 @@ val outlookOAuthClientId: String = secrets.getProperty(
"04e4aa5e-ed1f-47f9-b567-b99a0b29b3df",
)
+// Custom URI scheme AppAuth uses to capture the Outlook OAuth redirect. Must match the scheme of
+// OUTLOOK_OAUTH_REDIRECT_URI and the redirect URI registered in the Azure app registration.
+val outlookRedirectScheme = "org.libremail.outlook"
+
// Optional release signing, configured via git-ignored secrets.properties. When absent, release
// builds fall back to the debug key (installable for testing, but not publishable).
val releaseStoreFile: String? = secrets.getProperty("RELEASE_STORE_FILE")
@@ -55,12 +50,11 @@ android {
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
- buildConfigField("String", "GMAIL_OAUTH_CLIENT_ID", "\"$gmailOAuthClientId\"")
- buildConfigField("String", "GMAIL_OAUTH_REDIRECT_URI", "\"$gmailRedirectScheme:/oauth2redirect\"")
buildConfigField("String", "OUTLOOK_OAUTH_CLIENT_ID", "\"$outlookOAuthClientId\"")
- buildConfigField("String", "OUTLOOK_OAUTH_REDIRECT_URI", "\"org.libremail.outlook://oauth2redirect\"")
- // AppAuth captures the OAuth redirect via this custom scheme.
- manifestPlaceholders["appAuthRedirectScheme"] = gmailRedirectScheme
+ buildConfigField("String", "OUTLOOK_OAUTH_REDIRECT_URI", "\"$outlookRedirectScheme://oauth2redirect\"")
+ // AppAuth's bundled manifest requires this placeholder; it registers the redirect scheme on
+ // RedirectUriReceiverActivity so the Outlook sign-in redirect returns to the app.
+ manifestPlaceholders["appAuthRedirectScheme"] = outlookRedirectScheme
}
signingConfigs {
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 8ee562c..69aa810 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -10,10 +10,19 @@
+
-
+
-
-
-
-
-
-
-
+ tools:node="merge" />
diff --git a/app/src/main/kotlin/org/libremail/auth/GmailAuthManager.kt b/app/src/main/kotlin/org/libremail/auth/GmailAuthManager.kt
deleted file mode 100644
index 080dd36..0000000
--- a/app/src/main/kotlin/org/libremail/auth/GmailAuthManager.kt
+++ /dev/null
@@ -1,111 +0,0 @@
-// SPDX-License-Identifier: GPL-3.0-or-later
-package org.libremail.auth
-
-import android.content.Context
-import android.content.Intent
-import android.net.Uri
-import android.util.Base64
-import dagger.hilt.android.qualifiers.ApplicationContext
-import kotlinx.coroutines.suspendCancellableCoroutine
-import net.openid.appauth.AuthState
-import net.openid.appauth.AuthorizationException
-import net.openid.appauth.AuthorizationRequest
-import net.openid.appauth.AuthorizationResponse
-import net.openid.appauth.AuthorizationService
-import net.openid.appauth.AuthorizationServiceConfiguration
-import net.openid.appauth.ResponseTypeValues
-import org.json.JSONObject
-import org.libremail.BuildConfig
-import javax.inject.Inject
-import javax.inject.Singleton
-import kotlin.coroutines.resume
-import kotlin.coroutines.resumeWithException
-
-/**
- * Gmail OAuth 2.0 via AppAuth — Authorization Code + PKCE, no client secret. The restricted
- * `https://mail.google.com/` scope is requested so the access token works for IMAP/SMTP XOAUTH2.
- */
-@Singleton
-class GmailAuthManager @Inject constructor(@ApplicationContext private val context: Context) {
- private val serviceConfig = AuthorizationServiceConfiguration(
- Uri.parse("https://accounts.google.com/o/oauth2/v2/auth"),
- Uri.parse("https://oauth2.googleapis.com/token"),
- )
-
- /** False until a Google OAuth client id is provided in secrets.properties (see README). */
- val isConfigured: Boolean get() = BuildConfig.GMAIL_OAUTH_CLIENT_ID.isNotBlank()
-
- fun createAuthIntent(): Intent {
- val request = AuthorizationRequest.Builder(
- serviceConfig,
- BuildConfig.GMAIL_OAUTH_CLIENT_ID,
- ResponseTypeValues.CODE,
- Uri.parse(BuildConfig.GMAIL_OAUTH_REDIRECT_URI),
- )
- .setScope("openid email profile https://mail.google.com/")
- .build()
- return AuthorizationService(context).getAuthorizationRequestIntent(request)
- }
-
- suspend fun exchangeToken(responseIntent: Intent): OAuthResult {
- val response = AuthorizationResponse.fromIntent(responseIntent)
- val exception = AuthorizationException.fromIntent(responseIntent)
- if (response == null) throw exception ?: IllegalStateException("Authorization was cancelled")
-
- val service = AuthorizationService(context)
- try {
- val tokenResponse = suspendCancellableCoroutine { continuation ->
- service.performTokenRequest(response.createTokenExchangeRequest()) { token, error ->
- if (token != null) {
- continuation.resume(token)
- } else {
- continuation.resumeWithException(error ?: IllegalStateException("Token exchange failed"))
- }
- }
- }
- val authState = AuthState(response, exception).apply { update(tokenResponse, null) }
- val email = emailFromIdToken(tokenResponse.idToken)
- ?: throw IllegalStateException("Could not read the account email from the token")
- return OAuthResult(
- email = email,
- accessToken = tokenResponse.accessToken.orEmpty(),
- authStateJson = authState.jsonSerializeString(),
- )
- } finally {
- service.dispose()
- }
- }
-
- /** Refreshes the access token if needed (using the stored AuthState) for IMAP/SMTP XOAUTH2. */
- suspend fun freshAccessToken(authStateJson: String): FreshToken {
- val authState = AuthState.jsonDeserialize(authStateJson)
- val service = AuthorizationService(context)
- try {
- val accessToken = suspendCancellableCoroutine { continuation ->
- authState.performActionWithFreshTokens(service) { token, _, error ->
- if (token != null) {
- continuation.resume(token)
- } else {
- continuation.resumeWithException(error ?: IllegalStateException("Token refresh failed"))
- }
- }
- }
- return FreshToken(
- accessToken = accessToken,
- authStateJson = authState.jsonSerializeString(),
- accessTokenExpiry = authState.accessTokenExpirationTime,
- )
- } finally {
- service.dispose()
- }
- }
-
- private fun emailFromIdToken(idToken: String?): String? {
- if (idToken.isNullOrBlank()) return null
- return runCatching {
- val payload = idToken.split(".").getOrNull(1) ?: return null
- val json = String(Base64.decode(payload, Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP))
- JSONObject(json).optString("email").ifBlank { null }
- }.getOrNull()
- }
-}
diff --git a/app/src/main/kotlin/org/libremail/backup/BackupPolicy.kt b/app/src/main/kotlin/org/libremail/backup/BackupPolicy.kt
new file mode 100644
index 0000000..2ba34a6
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/backup/BackupPolicy.kt
@@ -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 = 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 = 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
+}
diff --git a/app/src/main/kotlin/org/libremail/backup/LibreMailBackupAgent.kt b/app/src/main/kotlin/org/libremail/backup/LibreMailBackupAgent.kt
new file mode 100644
index 0000000..c62629d
--- /dev/null
+++ b/app/src/main/kotlin/org/libremail/backup/LibreMailBackupAgent.kt
@@ -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)
+}
diff --git a/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt b/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt
index 6df882d..553ae00 100644
--- a/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt
+++ b/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt
@@ -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 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 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 = 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 = context.settingsDataStore.data.map { it.toAppSettings() }
val dynamicColor: Flow = 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, 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")
- }
}
diff --git a/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt b/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt
index beacac7..26bdf62 100644
--- a/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt
+++ b/app/src/main/kotlin/org/libremail/data/sync/MailConnectionFactory.kt
@@ -5,7 +5,6 @@ import kotlinx.coroutines.flow.first
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.libremail.auth.FreshToken
-import org.libremail.auth.GmailAuthManager
import org.libremail.auth.OutlookAuthManager
import org.libremail.data.local.toImapParams
import org.libremail.data.local.toSmtpParams
@@ -23,7 +22,6 @@ import javax.inject.Singleton
@Singleton
class MailConnectionFactory @Inject constructor(
private val credentialStore: CredentialStore,
- private val gmailAuthManager: GmailAuthManager,
private val outlookAuthManager: OutlookAuthManager,
private val settingsRepository: SettingsRepository,
) {
@@ -54,8 +52,6 @@ class MailConnectionFactory @Inject constructor(
private suspend fun resolveSecret(account: Account): String = when (account.authType) {
AuthType.PASSWORD_IMAP ->
credentialStore.loadSecret(account.id) ?: error("No stored credentials for ${account.email}")
- AuthType.OAUTH_GMAIL ->
- cachedAccessToken(account.id, SCOPE_GMAIL, gmailAuthManager::freshAccessToken)
AuthType.OAUTH_OUTLOOK ->
cachedAccessToken(account.id, SCOPE_OUTLOOK, outlookAuthManager::freshOutlookToken)
}
@@ -91,7 +87,6 @@ class MailConnectionFactory @Inject constructor(
private suspend fun strictStartTls(): Boolean = !settingsRepository.settings.first().allowStartTls
private companion object {
- const val SCOPE_GMAIL = "gmail"
const val SCOPE_OUTLOOK = "outlook"
const val SCOPE_GRAPH = "graph"
const val EXPIRY_BUFFER_MS = 60_000L
diff --git a/app/src/main/kotlin/org/libremail/domain/model/Account.kt b/app/src/main/kotlin/org/libremail/domain/model/Account.kt
index 3110427..3ccb80e 100644
--- a/app/src/main/kotlin/org/libremail/domain/model/Account.kt
+++ b/app/src/main/kotlin/org/libremail/domain/model/Account.kt
@@ -3,9 +3,6 @@ package org.libremail.domain.model
/** How an account authenticates with its mail server. */
enum class AuthType {
- /** Gmail via OAuth 2.0 (XOAUTH2 over IMAP/SMTP). */
- OAUTH_GMAIL,
-
/** Outlook / Microsoft via OAuth 2.0 (XOAUTH2 over IMAP/SMTP). */
OAUTH_OUTLOOK,
diff --git a/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt
index 32d925b..cab7c02 100644
--- a/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt
+++ b/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt
@@ -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 {
diff --git a/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt b/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt
index 6006e83..52da6d4 100644
--- a/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt
+++ b/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt
@@ -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) {
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 6f3da66..358f1ec 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -167,6 +167,11 @@
No accounts yet
Remove account
+
+ Backup
+ Include settings in Android Backup
+ 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.
+
Account
Signature
diff --git a/app/src/main/res/xml/backup_rules.xml b/app/src/main/res/xml/backup_rules.xml
new file mode 100644
index 0000000..0059fd1
--- /dev/null
+++ b/app/src/main/res/xml/backup_rules.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/data_extraction_rules.xml b/app/src/main/res/xml/data_extraction_rules.xml
index 39841b1..0f07896 100644
--- a/app/src/main/res/xml/data_extraction_rules.xml
+++ b/app/src/main/res/xml/data_extraction_rules.xml
@@ -1,17 +1,26 @@
-
+
-
+
diff --git a/app/src/test/kotlin/org/libremail/backup/BackupPolicyTest.kt b/app/src/test/kotlin/org/libremail/backup/BackupPolicyTest.kt
new file mode 100644
index 0000000..c9d0924
--- /dev/null
+++ b/app/src/test/kotlin/org/libremail/backup/BackupPolicyTest.kt
@@ -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"))
+ }
+}
diff --git a/app/src/test/kotlin/org/libremail/backup/DataExtractionRulesTest.kt b/app/src/test/kotlin/org/libremail/backup/DataExtractionRulesTest.kt
new file mode 100644
index 0000000..2de642c
--- /dev/null
+++ b/app/src/test/kotlin/org/libremail/backup/DataExtractionRulesTest.kt
@@ -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, val excludes: Set)
+
+ 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 / 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 {
+ 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 =
+ 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 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"))
+ }
+}
diff --git a/secrets.properties.example b/secrets.properties.example
index 48df0a9..d7f3dd1 100644
--- a/secrets.properties.example
+++ b/secrets.properties.example
@@ -1,11 +1,6 @@
-# Copy this file to `secrets.properties` (which is git-ignored) and fill in the value.
+# Copy this file to `secrets.properties` (which is git-ignored) and fill in the values you need.
+# Every value below is optional — the build works with the defaults when this file is absent.
#
-# Gmail OAuth 2.0 *Android* client ID created in Google Cloud Console.
-# See the README ("Gmail account setup") for the exact steps. Used by the app for
-# the Authorization Code + PKCE login flow; no client secret is required for an
-# installed Android app.
-GMAIL_OAUTH_CLIENT_ID=
-
# Optional: Microsoft (Outlook) OAuth public client id. A working default ships with the build;
# set this only to use your own Azure app registration.
#OUTLOOK_OAUTH_CLIENT_ID=