Merge remote-tracking branch 'origin/main' into feat-onboarding-flow
This commit is contained in:
+10
-16
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
@@ -61,25 +70,20 @@
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- Captures the Microsoft OAuth redirect for Outlook sign-in. AppAuth registers the
|
||||
Gmail scheme via ${appAuthRedirectScheme}; this adds the Outlook scheme. The redirect
|
||||
URI org.libremail.outlook://oauth2redirect must be registered as a public-client
|
||||
(mobile/desktop) redirect in the Azure app registration.
|
||||
AppAuth's RedirectUriReceiverActivity extends AppCompatActivity, so it needs an
|
||||
AppCompat theme; without this it inherits the app's Theme.Material shell and crashes
|
||||
("You need to use a Theme.AppCompat theme") when the redirect launches it. We reuse the
|
||||
translucent theme AppAuth itself applies to AuthorizationManagementActivity. -->
|
||||
<!-- Captures the Microsoft OAuth redirect for Outlook sign-in. AppAuth's bundled manifest
|
||||
already declares RedirectUriReceiverActivity with an intent-filter for
|
||||
${appAuthRedirectScheme}, which build.gradle.kts sets to the Outlook scheme
|
||||
(org.libremail.outlook); the redirect URI org.libremail.outlook://oauth2redirect must
|
||||
be registered as a public-client (mobile/desktop) redirect in the Azure app
|
||||
registration. We only merge a theme onto that activity here: AppAuth declares no theme
|
||||
and RedirectUriReceiverActivity extends AppCompatActivity, so without an AppCompat theme
|
||||
it inherits the app's Theme.Material shell and crashes ("You need to use a Theme.AppCompat
|
||||
theme") when the redirect launches it. We reuse the translucent theme AppAuth itself
|
||||
applies to AuthorizationManagementActivity. -->
|
||||
<activity
|
||||
android:name="net.openid.appauth.RedirectUriReceiverActivity"
|
||||
android:exported="true"
|
||||
android:theme="@style/Theme.AppCompat.Translucent.NoTitleBar"
|
||||
tools:node="merge">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
<data android:scheme="org.libremail.outlook" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
tools:node="merge" />
|
||||
</application>
|
||||
</manifest>
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -167,6 +167,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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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=
|
||||
|
||||
Reference in New Issue
Block a user