From 657af48052a812dce198caa74ea65c60c935ab52 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Tue, 30 Jun 2026 23:44:17 -0500 Subject: [PATCH 1/2] refactor(auth): remove dead Gmail OAuth code path Gmail authenticates via app password + preconfigured IMAP/SMTP (decision in #9), never OAuth, so the Gmail-OAuth implementation was unreachable dead code. Remove it while keeping Outlook's AppAuth OAuth path intact. - Delete auth/GmailAuthManager.kt (shared OAuthResult/FreshToken kept). - Drop AuthType.OAUTH_GMAIL and its exhaustive `when` branch, the gmailAuthManager injection, and the SCOPE_GMAIL constant in MailConnectionFactory. - Remove GMAIL_OAUTH_* BuildConfig fields, gmailOAuthClientId, and the gmailRedirectScheme val from app/build.gradle.kts. - Repoint the AppAuth manifestPlaceholders["appAuthRedirectScheme"] to the Outlook scheme (org.libremail.outlook). AppAuth's bundled manifest now registers that scheme on RedirectUriReceiverActivity, so the app manifest's now-redundant Outlook intent-filter is removed; the AppCompat theme override (crash fix) is preserved. Verified the merged manifest. - Drop GMAIL_OAUTH_CLIENT_ID from secrets.properties.example. authType persists as the enum name in a TEXT column, so removing a constant needs no Room schema change or migration. Closes #39 Co-Authored-By: Claude Opus 4.8 --- app/build.gradle.kts | 26 ++-- app/src/main/AndroidManifest.xml | 27 ++--- .../org/libremail/auth/GmailAuthManager.kt | 111 ------------------ .../data/sync/MailConnectionFactory.kt | 5 - .../org/libremail/domain/model/Account.kt | 3 - secrets.properties.example | 9 +- 6 files changed, 23 insertions(+), 158 deletions(-) delete mode 100644 app/src/main/kotlin/org/libremail/auth/GmailAuthManager.kt 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..434581c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -61,25 +61,20 @@ android:resource="@xml/file_paths" /> - + - - - - - - - + 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/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/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= From ffbb88222778389ee0941f21ff64c3f9ed5f0f01 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Tue, 30 Jun 2026 23:56:18 -0500 Subject: [PATCH 2/2] feat(backup): opt-in Android Backup for settings only Add an opt-in (off by default) toggle to include app data in system Android Backup / Auto Backup, gated so only re-creatable user preferences are ever backed up. - Flip allowBackup to true and add LibreMailBackupAgent, which enforces the runtime opt-in: onFullBackup runs only when the user enables "Include settings in Android Backup" (default off), so no data leaves the device otherwise. allowBackup is a manifest flag and can't be toggled at runtime, hence the agent. - Rewrite data_extraction_rules.xml (API 31+) and add backup_rules.xml (API 29-30) as strict allowlists that back up ONLY the libremail_settings DataStore. The Keystore-sealed cache passphrase (libremail_dbkey) and the encrypted credentials + mail-cache database (libremail.db) are excluded by omission; the cache re-downloads on next sync. - Add includeInBackup preference + setter (nudges BackupManager on change) and a "Backup" settings section with F-Droid-honest copy (off by default, uses Google infrastructure). - BackupPolicy is the single source of truth for eligible/excluded paths; unit tests cover the toggle default and assert the shipped XML resources include only settings and never the secrets/DB. Co-Authored-By: Claude Opus 4.8 --- app/src/main/AndroidManifest.xml | 11 ++- .../org/libremail/backup/BackupPolicy.kt | 40 +++++++++ .../libremail/backup/LibreMailBackupAgent.kt | 42 +++++++++ .../data/settings/SettingsRepository.kt | 87 ++++++++++++------- .../libremail/ui/settings/SettingsScreen.kt | 9 ++ .../ui/settings/SettingsViewModel.kt | 1 + app/src/main/res/values/strings.xml | 5 ++ app/src/main/res/xml/backup_rules.xml | 16 ++++ .../main/res/xml/data_extraction_rules.xml | 23 +++-- .../org/libremail/backup/BackupPolicyTest.kt | 46 ++++++++++ .../backup/DataExtractionRulesTest.kt | 76 ++++++++++++++++ 11 files changed, 318 insertions(+), 38 deletions(-) create mode 100644 app/src/main/kotlin/org/libremail/backup/BackupPolicy.kt create mode 100644 app/src/main/kotlin/org/libremail/backup/LibreMailBackupAgent.kt create mode 100644 app/src/main/res/xml/backup_rules.xml create mode 100644 app/src/test/kotlin/org/libremail/backup/BackupPolicyTest.kt create mode 100644 app/src/test/kotlin/org/libremail/backup/DataExtractionRulesTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8ee562c..a942157 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -10,10 +10,19 @@ + = 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/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 a4df47f..33b1ab9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -143,6 +143,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")) + } +}