diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f4e7a93..6f3b4bd 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" + // Debug-report ingest endpoint (issue #34, out of scope for this repo). Empty by default: the debug // reporting client is strictly opt-in and never sends anything unless the user taps Submit AND an // endpoint is configured here (overridable via git-ignored secrets.properties). @@ -60,13 +55,12 @@ 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\"") + buildConfigField("String", "OUTLOOK_OAUTH_REDIRECT_URI", "\"$outlookRedirectScheme://oauth2redirect\"") buildConfigField("String", "DEBUG_REPORT_ENDPOINT", "\"$debugReportEndpoint\"") - // AppAuth captures the OAuth redirect via this custom scheme. - manifestPlaceholders["appAuthRedirectScheme"] = gmailRedirectScheme + // 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/androidTest/kotlin/org/libremail/ui/Fakes.kt b/app/src/androidTest/kotlin/org/libremail/ui/Fakes.kt index 6117e48..c73df14 100644 --- a/app/src/androidTest/kotlin/org/libremail/ui/Fakes.kt +++ b/app/src/androidTest/kotlin/org/libremail/ui/Fakes.kt @@ -41,6 +41,11 @@ class FakeAccountRepository( override suspend fun addImapAccount(account: Account, password: String): Result> { addedAccount = account addedPassword = password + // Mirror the real repository: a successful add makes the account observable, so screens that + // react to the account list (e.g. the mailbox after onboarding) see it appear. + if (result.isSuccess) { + accountsFlow.value = accountsFlow.value.filterNot { it.id == account.id } + account + } return result } diff --git a/app/src/androidTest/kotlin/org/libremail/ui/accountsetup/ManualSetupScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/accountsetup/ManualSetupScreenTest.kt index 41780d3..03d0cd2 100644 --- a/app/src/androidTest/kotlin/org/libremail/ui/accountsetup/ManualSetupScreenTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/ui/accountsetup/ManualSetupScreenTest.kt @@ -35,7 +35,7 @@ class ManualSetupScreenTest { // Build the view model once and capture it, so recomposition doesn't recreate it. private fun setContent( repository: FakeAccountRepository = FakeAccountRepository(), - onAccountAdded: () -> Unit = {}, + onAccountAdded: (String) -> Unit = {}, ) { val viewModel = ManualSetupViewModel(repository) composeTestRule.setContent { diff --git a/app/src/androidTest/kotlin/org/libremail/ui/mailbox/MailboxScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/mailbox/MailboxScreenTest.kt index 17d9312..0be3688 100644 --- a/app/src/androidTest/kotlin/org/libremail/ui/mailbox/MailboxScreenTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/ui/mailbox/MailboxScreenTest.kt @@ -13,6 +13,7 @@ import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTouchInput +import androidx.lifecycle.SavedStateHandle import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.Assert.assertEquals import org.junit.Rule @@ -71,7 +72,12 @@ class MailboxScreenTest { ) private fun setContent(repo: FakeMailRepository) { - val viewModel = MailboxViewModel(repo, FakeAccountRepository(accounts = listOf(account)), FakeMailSyncer()) + val viewModel = MailboxViewModel( + repo, + FakeAccountRepository(accounts = listOf(account)), + FakeMailSyncer(), + SavedStateHandle(), + ) composeTestRule.setContent { LibreMailTheme(darkTheme = false, dynamicColor = false) { MailboxScreen( diff --git a/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt new file mode 100644 index 0000000..625a328 --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt @@ -0,0 +1,215 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.onboarding + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.remember +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextInput +import androidx.lifecycle.SavedStateHandle +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.R +import org.libremail.auth.OutlookAuthManager +import org.libremail.domain.model.Message +import org.libremail.ui.FakeAccountRepository +import org.libremail.ui.FakeMailRepository +import org.libremail.ui.FakeMailSyncer +import org.libremail.ui.accountsetup.AccountPickerScreen +import org.libremail.ui.accountsetup.AccountSetupViewModel +import org.libremail.ui.accountsetup.AppPasswordSetupScreen +import org.libremail.ui.accountsetup.AppPasswordViewModel +import org.libremail.ui.mailbox.MailboxScreen +import org.libremail.ui.mailbox.MailboxViewModel +import org.libremail.ui.navigation.Routes +import org.libremail.ui.theme.LibreMailTheme + +/** + * End-to-end test of the onboarding flow: a fresh install (no accounts) walks welcome → vendor + * picker → app-password setup → "add another?" → the first account's inbox. + * + * It drives the real onboarding screens + ViewModels through a real [NavHost]. The account backend is + * the in-memory [FakeAccountRepository] (a successful add makes the account observable) rather than a + * live server — GreenMail-backed connection behaviour is covered by the repository unit tests; this + * test owns the cross-screen navigation contract. + */ +@RunWith(AndroidJUnit4::class) +class OnboardingFlowTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private fun string(resId: Int) = composeTestRule.activity.getString(resId) + + // Generous cap for the slow, animation-disabled CI matrix emulators; waitUntil returns as soon + // as the text appears, so the happy path is unaffected. + private fun waitForText(text: String) = composeTestRule.waitUntil(15_000) { + composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty() + } + + private fun inboxMessage() = Message( + id = "imap:e2e@gmail.com:INBOX:1", + accountId = "imap:e2e@gmail.com", + sender = "Welcome", + senderEmail = "welcome@gmail.com", + subject = "E2E first message", + snippet = "", + body = "", + isHtml = false, + timestampMillis = 1_000L, + isRead = false, + isStarred = false, + folder = "INBOX", + inInbox = true, + bodyFetched = false, + ) + + private fun setOnboardingContent(accountRepo: FakeAccountRepository, mailRepo: FakeMailRepository) { + val onboarding = OnboardingViewModel() + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + val navController = rememberNavController() + val context = LocalContext.current + val outlookAuthManager = remember { OutlookAuthManager(context) } + NavHost(navController = navController, startDestination = Routes.ONBOARDING_WELCOME) { + composable(Routes.ONBOARDING_WELCOME) { + OnboardingWelcomeScreen( + onAddAccount = { navController.navigate(Routes.ONBOARDING_PICKER) }, + ) + } + composable(Routes.ONBOARDING_PICKER) { + val viewModel = remember { AccountSetupViewModel(outlookAuthManager, accountRepo) } + AccountPickerScreen( + onBack = {}, + onAccountAdded = { id -> + onboarding.onAccountAdded(id) + navController.navigate(Routes.ONBOARDING_ADD_ANOTHER) + }, + onPickProvider = { provider -> + navController.navigate(Routes.onboardingAppPassword(provider.key)) + }, + onManualSetup = {}, + viewModel = viewModel, + ) + } + composable( + route = Routes.ONBOARDING_APP_PASSWORD_PATTERN, + arguments = listOf( + navArgument(Routes.APP_PASSWORD_ARG_PROVIDER) { type = NavType.StringType }, + ), + ) { entry -> + val key = entry.arguments?.getString(Routes.APP_PASSWORD_ARG_PROVIDER).orEmpty() + val viewModel = remember { + AppPasswordViewModel( + SavedStateHandle(mapOf(Routes.APP_PASSWORD_ARG_PROVIDER to key)), + accountRepo, + ) + } + AppPasswordSetupScreen( + onBack = {}, + onAccountAdded = { id -> + onboarding.onAccountAdded(id) + navController.navigate(Routes.ONBOARDING_ADD_ANOTHER) { + popUpTo(Routes.ONBOARDING_PICKER) + } + }, + viewModel = viewModel, + ) + } + composable(Routes.ONBOARDING_ADD_ANOTHER) { + AddAnotherAccountScreen( + onAddAnother = { + navController.navigate(Routes.ONBOARDING_PICKER) { + popUpTo(Routes.ONBOARDING_PICKER) { inclusive = true } + } + }, + onFinish = { + val id = onboarding.firstAddedAccountId + val dest = if (id != null) Routes.mailboxForAccount(id) else Routes.MAILBOX + navController.navigate(dest) { + popUpTo(Routes.ONBOARDING_WELCOME) { inclusive = true } + } + }, + ) + } + composable( + route = Routes.MAILBOX_PATTERN, + arguments = listOf( + navArgument(Routes.MAILBOX_ARG_ACCOUNT) { + type = NavType.StringType + defaultValue = "" + }, + ), + ) { entry -> + val account = entry.arguments?.getString(Routes.MAILBOX_ARG_ACCOUNT).orEmpty() + val viewModel = remember { + MailboxViewModel( + mailRepo, + accountRepo, + FakeMailSyncer(), + SavedStateHandle(mapOf(Routes.MAILBOX_ARG_ACCOUNT to account)), + ) + } + MailboxScreen( + onOpenMessage = {}, + onCompose = {}, + onOpenDrafts = {}, + onOpenOutbox = {}, + onAddAccount = {}, + onOpenCompose = {}, + onSelectTab = {}, + viewModel = viewModel, + ) + } + } + } + } + } + + @Test + fun onboarding_addsAppPasswordAccount_thenLandsOnFirstAccountInbox() { + val accountRepo = FakeAccountRepository() + val mailRepo = FakeMailRepository(messages = listOf(inboxMessage())) + setOnboardingContent(accountRepo, mailRepo) + + // Welcome → picker. + composeTestRule.onNodeWithText(string(R.string.onboarding_welcome_title)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.onboarding_add_account)).performClick() + + // Picker → Gmail app-password setup. + waitForText("Gmail") + composeTestRule.onNodeWithText("Gmail").performClick() + + // App-password setup: email + app password come from the user; servers come from the preset. + // performScrollTo first — on the short default matrix emulator the fields and the "Test and + // add" button sit below the fold of this scrolling screen, and a positional click on an + // off-screen button is a silent no-op (which is why this passed only on API 37's taller AVD). + waitForText(string(R.string.app_password_email)) + composeTestRule.onNodeWithText(string(R.string.app_password_email)) + .performScrollTo().performTextInput("e2e@gmail.com") + composeTestRule.onNodeWithText(string(R.string.app_password_field)) + .performScrollTo().performTextInput("app-pass") + composeTestRule.onNodeWithText(string(R.string.app_password_test_and_add)) + .performScrollTo().performClick() + + // "Add another?" prompt → No. + waitForText(string(R.string.onboarding_add_another_prompt)) + composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_no)).performClick() + + // Landed on the first (and only) account's inbox. + waitForText("E2E first message") + composeTestRule.onNodeWithText("E2E first message").assertIsDisplayed() + } +} 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/domain/model/MailProvider.kt b/app/src/main/kotlin/org/libremail/domain/model/MailProvider.kt new file mode 100644 index 0000000..c5a7188 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/domain/model/MailProvider.kt @@ -0,0 +1,90 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.domain.model + +// Standard mail submission ports, named so the presets below don't read as "magic numbers". +// File-level (not in the companion) so the enum entries can reference them during construction. +private const val IMAPS_PORT = 993 +private const val SMTP_SUBMISSION_PORT = 587 +private const val SMTPS_PORT = 465 + +/** + * Preconfigured IMAP/SMTP presets for the app-password vendors (Gmail, Yahoo, iCloud). + * + * These mirror [Account.outlook]: each entry knows its servers so onboarding only has to collect an + * email + app password (see the app-password setup screen). Outlook is intentionally NOT here — it + * uses interactive OAuth, not an app password. + * + * Port / security rationale (verified against current vendor docs): + * - IMAP is implicit TLS on 993 for all three: none of these vendors document a STARTTLS IMAP + * endpoint, so 993/[MailSecurity.SSL_TLS] is the only correct choice. + * - SMTP biases toward STARTTLS on 587 where the vendor documents it (Gmail, iCloud), matching the + * epic's "prefer STARTTLS where supported" guidance. Yahoo documents implicit TLS on 465 as its + * outgoing server, so it keeps 465/[MailSecurity.SSL_TLS]. + * - [MailSecurity.NONE] is never used — every path here is encrypted end to end. + */ +enum class MailProvider( + /** Stable lowercase key used as a navigation argument and to look a provider back up. */ + val key: String, + /** Brand name shown in the picker and setup screen (a proper noun, not localized). */ + val displayName: String, + /** The page where the user creates an app password for this provider. */ + val appPasswordHelpUrl: String, + private val imapHost: String, + private val smtpHost: String, + private val smtpPort: Int, + private val smtpSecurity: MailSecurity, +) { + GMAIL( + key = "gmail", + displayName = "Gmail", + appPasswordHelpUrl = "https://myaccount.google.com/apppasswords", + imapHost = "imap.gmail.com", + smtpHost = "smtp.gmail.com", + // Google documents smtp.gmail.com:587 with STARTTLS as the standard submission endpoint. + smtpPort = SMTP_SUBMISSION_PORT, + smtpSecurity = MailSecurity.STARTTLS, + ), + YAHOO( + key = "yahoo", + displayName = "Yahoo Mail", + appPasswordHelpUrl = "https://login.yahoo.com/account/security", + imapHost = "imap.mail.yahoo.com", + smtpHost = "smtp.mail.yahoo.com", + // Yahoo documents smtp.mail.yahoo.com:465 with implicit SSL/TLS as its outgoing server. + smtpPort = SMTPS_PORT, + smtpSecurity = MailSecurity.SSL_TLS, + ), + ICLOUD( + key = "icloud", + displayName = "iCloud Mail", + appPasswordHelpUrl = "https://appleid.apple.com", + imapHost = "imap.mail.me.com", + smtpHost = "smtp.mail.me.com", + // Apple documents smtp.mail.me.com:587 with STARTTLS for iCloud Mail. + smtpPort = SMTP_SUBMISSION_PORT, + smtpSecurity = MailSecurity.STARTTLS, + ), + ; + + /** + * Builds a [PASSWORD_IMAP][AuthType.PASSWORD_IMAP] [Account] for this provider. The caller + * supplies the address; the servers come from the preset. The id mirrors the manual-setup + * convention (`imap:`) so app-password and manual accounts share one identity scheme. + */ + fun createAccount(email: String, displayName: String = email): Account { + val address = email.trim() + return Account( + id = "imap:$address", + email = address, + displayName = displayName.trim().ifBlank { address }, + authType = AuthType.PASSWORD_IMAP, + imap = ServerConfig(imapHost, IMAPS_PORT, MailSecurity.SSL_TLS), + smtp = ServerConfig(smtpHost, smtpPort, smtpSecurity), + ) + } + + companion object { + /** Resolves a provider by its [key], or null if none matches (case-insensitive). */ + fun fromKey(key: String): MailProvider? = entries.firstOrNull { it.key.equals(key, ignoreCase = true) } + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/AppViewModel.kt b/app/src/main/kotlin/org/libremail/ui/AppViewModel.kt new file mode 100644 index 0000000..a87ed2a --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/AppViewModel.kt @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.take +import org.libremail.domain.repository.AccountRepository +import org.libremail.ui.navigation.Routes +import javax.inject.Inject + +/** + * Decides the app's start destination from the stored account count: no accounts → the onboarding + * welcome flow; otherwise the mailbox. + * + * [startDestination] is `null` until the first account snapshot loads — the UI holds (renders + * nothing) during that window so a cold start never flashes the wrong screen. Only the *first* + * determination is used ([take]), so adding the first account mid-onboarding does not later flip the + * start destination and tear down the in-progress flow. + */ +@HiltViewModel +class AppViewModel @Inject constructor(accountRepository: AccountRepository) : ViewModel() { + + val startDestination: StateFlow = accountRepository.observeAccounts() + .map { accounts -> if (accounts.isEmpty()) Routes.ONBOARDING else Routes.MAILBOX } + .take(1) + .stateIn(viewModelScope, SharingStarted.Eagerly, null) +} diff --git a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt index 282fe69..ada9b98 100644 --- a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt +++ b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt @@ -10,23 +10,32 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavBackStackEntry import androidx.navigation.NavController import androidx.navigation.NavGraph.Companion.findStartDestination +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable +import androidx.navigation.compose.navigation import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import org.libremail.R -import org.libremail.ui.accountsetup.AccountSetupScreen +import org.libremail.ui.accountsetup.AccountPickerScreen +import org.libremail.ui.accountsetup.AppPasswordSetupScreen import org.libremail.ui.accountsetup.ManualSetupScreen import org.libremail.ui.compose.ComposeScreen import org.libremail.ui.drafts.DraftsScreen import org.libremail.ui.mailbox.MailboxScreen import org.libremail.ui.navigation.Routes +import org.libremail.ui.onboarding.AddAnotherAccountScreen +import org.libremail.ui.onboarding.OnboardingViewModel +import org.libremail.ui.onboarding.OnboardingWelcomeScreen import org.libremail.ui.outbox.OutboxScreen import org.libremail.ui.reader.ReaderScreen import org.libremail.ui.reporting.ProblemReportsScreen @@ -36,15 +45,32 @@ import org.libremail.ui.settings.AccountSettingsScreen import org.libremail.ui.settings.SettingsScreen @Composable -fun LibreMailApp(startupViewModel: StartupReportViewModel = hiltViewModel()) { +fun LibreMailApp( + appViewModel: AppViewModel = hiltViewModel(), + startupViewModel: StartupReportViewModel = hiltViewModel(), +) { + val startDestination by appViewModel.startDestination.collectAsStateWithLifecycle() + // Hold (render nothing) until the account count is known, so a cold start never flashes the + // wrong screen before onboarding-vs-mailbox is decided. + val start = startDestination ?: return val navController = rememberNavController() val pendingCrash by startupViewModel.pendingCrash.collectAsStateWithLifecycle() NavHost( navController = navController, - startDestination = Routes.MAILBOX, + startDestination = start, ) { - composable(Routes.MAILBOX) { + onboardingGraph(navController) + + composable( + route = Routes.MAILBOX_PATTERN, + arguments = listOf( + navArgument(Routes.MAILBOX_ARG_ACCOUNT) { + type = NavType.StringType + defaultValue = "" + }, + ), + ) { MailboxScreen( onOpenMessage = { id -> navController.navigate(Routes.reader(id)) }, onCompose = { navController.navigate(Routes.compose()) }, @@ -113,25 +139,30 @@ fun LibreMailApp(startupViewModel: StartupReportViewModel = hiltViewModel()) { ) { AccountSettingsScreen(onBack = navController::popBackStack) } + // "Add account" entry reused by Settings and the mailbox. These reuse the SAME picker/setup + // screens as onboarding, but each pops back to where the user was on success (no "add + // another?" prompt — that is onboarding-only, see #30). composable(Routes.ACCOUNT_SETUP) { - AccountSetupScreen( + AccountPickerScreen( onBack = navController::popBackStack, + onAccountAdded = { navController.popBackStack() }, + onPickProvider = { provider -> navController.navigate(Routes.appPassword(provider.key)) }, onManualSetup = { navController.navigate(Routes.MANUAL_SETUP) }, - onAccountAdded = { - navController.navigate(Routes.MAILBOX) { - popUpTo(Routes.MAILBOX) { inclusive = true } - } - }, + ) + } + composable( + route = Routes.APP_PASSWORD_PATTERN, + arguments = listOf(navArgument(Routes.APP_PASSWORD_ARG_PROVIDER) { type = NavType.StringType }), + ) { + AppPasswordSetupScreen( + onBack = navController::popBackStack, + onAccountAdded = { navController.popBackStack(Routes.ACCOUNT_SETUP, inclusive = true) }, ) } composable(Routes.MANUAL_SETUP) { ManualSetupScreen( onBack = navController::popBackStack, - onAccountAdded = { - navController.navigate(Routes.MAILBOX) { - popUpTo(Routes.MAILBOX) { inclusive = true } - } - }, + onAccountAdded = { navController.popBackStack(Routes.ACCOUNT_SETUP, inclusive = true) }, ) } composable(Routes.DRAFTS) { @@ -176,6 +207,85 @@ private fun CrashReportDialog(onReview: () -> Unit, onLater: () -> Unit, onDisca ) } +/** + * First-run onboarding as a nested graph so a single graph-scoped [OnboardingViewModel] can track the + * first account added this session. The picker/setup screens are the same composables used by the + * top-level "Add account" routes; here, a successful add routes to the "add another?" prompt instead + * of popping back. + */ +private fun NavGraphBuilder.onboardingGraph(navController: NavHostController) { + navigation(startDestination = Routes.ONBOARDING_WELCOME, route = Routes.ONBOARDING) { + composable(Routes.ONBOARDING_WELCOME) { + OnboardingWelcomeScreen(onAddAccount = { navController.navigate(Routes.ONBOARDING_PICKER) }) + } + composable(Routes.ONBOARDING_PICKER) { entry -> + val onboarding = onboardingViewModel(navController, entry) + AccountPickerScreen( + onBack = navController::popBackStack, + onAccountAdded = { id -> + onboarding.onAccountAdded(id) + navController.navigate(Routes.ONBOARDING_ADD_ANOTHER) + }, + onPickProvider = { provider -> + navController.navigate(Routes.onboardingAppPassword(provider.key)) + }, + onManualSetup = { navController.navigate(Routes.ONBOARDING_MANUAL) }, + ) + } + composable( + route = Routes.ONBOARDING_APP_PASSWORD_PATTERN, + arguments = listOf(navArgument(Routes.APP_PASSWORD_ARG_PROVIDER) { type = NavType.StringType }), + ) { entry -> + val onboarding = onboardingViewModel(navController, entry) + AppPasswordSetupScreen( + onBack = navController::popBackStack, + onAccountAdded = { id -> onboarding.completeAdd(navController, id) }, + ) + } + composable(Routes.ONBOARDING_MANUAL) { entry -> + val onboarding = onboardingViewModel(navController, entry) + ManualSetupScreen( + onBack = navController::popBackStack, + onAccountAdded = { id -> onboarding.completeAdd(navController, id) }, + ) + } + composable(Routes.ONBOARDING_ADD_ANOTHER) { entry -> + val onboarding = onboardingViewModel(navController, entry) + AddAnotherAccountScreen( + onAddAnother = { + // Return to a fresh picker, clearing the prompt and the prior setup screen. + navController.navigate(Routes.ONBOARDING_PICKER) { + popUpTo(Routes.ONBOARDING_PICKER) { inclusive = true } + } + }, + onFinish = { + val firstId = onboarding.firstAddedAccountId + val dest = if (firstId != null) Routes.mailboxForAccount(firstId) else Routes.MAILBOX + navController.navigate(dest) { + // Leave onboarding entirely; the mailbox becomes the new back-stack root. + popUpTo(Routes.ONBOARDING) { inclusive = true } + } + }, + ) + } + } +} + +/** Resolves the onboarding-graph-scoped [OnboardingViewModel] shared across the onboarding screens. */ +@Composable +private fun onboardingViewModel(navController: NavController, entry: NavBackStackEntry): OnboardingViewModel { + val parentEntry = remember(entry) { navController.getBackStackEntry(Routes.ONBOARDING) } + return hiltViewModel(parentEntry) +} + +/** Records the added account, then advances to the "add another?" prompt (dropping the setup form). */ +private fun OnboardingViewModel.completeAdd(navController: NavController, accountId: String) { + onAccountAdded(accountId) + navController.navigate(Routes.ONBOARDING_ADD_ANOTHER) { + popUpTo(Routes.ONBOARDING_PICKER) + } +} + /** Navigate between top-level tabs, preserving each tab's back stack and state. */ private fun NavController.navigateTab(dest: TopDest) { navigate(dest.route) { diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupScreen.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountPickerScreen.kt similarity index 52% rename from app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupScreen.kt rename to app/src/main/kotlin/org/libremail/ui/accountsetup/AccountPickerScreen.kt index 4ebec0b..aec4d7e 100644 --- a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountPickerScreen.kt @@ -4,25 +4,31 @@ package org.libremail.ui.accountsetup import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.Email -import androidx.compose.material3.Button +import androidx.compose.material.icons.filled.Lock import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedCard import androidx.compose.material3.Scaffold import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState @@ -34,19 +40,31 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.libremail.R +import org.libremail.domain.model.MailProvider +/** + * The single account-vendor picker used both by first-run onboarding and the "Add account" entry + * from Settings/mailbox. It routes each choice to the correct setup path: + * - Outlook/Hotmail → the existing Microsoft OAuth flow, completed inline via [AccountSetupViewModel]. + * - Gmail / Yahoo / iCloud → the guided app-password screen with the matching [MailProvider] preset. + * - Other (IMAP/SMTP) → the manual setup screen. + * + * @param onAccountAdded invoked with the new account id when the *inline* Outlook flow completes. + * The app-password and manual paths report their own completion from their own screens. + */ @OptIn(ExperimentalMaterial3Api::class) @Composable -fun AccountSetupScreen( +fun AccountPickerScreen( onBack: () -> Unit, + onAccountAdded: (String) -> Unit, + onPickProvider: (MailProvider) -> Unit, onManualSetup: () -> Unit, - onAccountAdded: () -> Unit, viewModel: AccountSetupViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() @@ -56,8 +74,10 @@ fun AccountSetupScreen( ActivityResultContracts.StartActivityForResult(), ) { result -> viewModel.onOutlookResult(result.data) } - LaunchedEffect(state.status) { - if (state.status == SetupStatus.DONE) onAccountAdded() + LaunchedEffect(state.status, state.addedAccountId) { + if (state.status == SetupStatus.DONE) { + state.addedAccountId?.let(onAccountAdded) + } } LaunchedEffect(state.error) { state.error?.let { @@ -86,24 +106,24 @@ fun AccountSetupScreen( ) { padding -> Box(Modifier.fillMaxSize().padding(padding)) { Column( - modifier = Modifier.fillMaxSize().padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), ) { - Icon( - Icons.Filled.Email, - contentDescription = null, - modifier = Modifier.size(56.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Spacer(Modifier.height(16.dp)) Text( text = stringResource(R.string.account_setup_subtitle), - style = MaterialTheme.typography.bodyLarge, - textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 4.dp, vertical = 4.dp), ) - Spacer(Modifier.height(24.dp)) - Button( + ProviderRow( + // Recognizable brand logos would need bundled trademarked assets; until those + // exist we use a neutral mail glyph and rely on the visible label for recognition. + icon = Icons.Filled.Email, + label = stringResource(R.string.account_setup_outlook), + enabled = !busy, onClick = { viewModel.outlookAuthIntent().fold( onSuccess = { intent -> @@ -113,19 +133,22 @@ fun AccountSetupScreen( onFailure = { viewModel.onOutlookLaunchFailed(it) }, ) }, - enabled = !busy, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(R.string.account_setup_outlook)) + ) + MailProvider.entries.forEach { provider -> + ProviderRow( + icon = Icons.Filled.Email, + label = provider.displayName, + enabled = !busy, + onClick = { onPickProvider(provider) }, + ) } - Spacer(Modifier.height(12.dp)) - OutlinedButton( + HorizontalDivider(Modifier.padding(vertical = 4.dp)) + ProviderRow( + icon = Icons.Filled.Lock, + label = stringResource(R.string.account_setup_other), + enabled = !busy, onClick = onManualSetup, - enabled = !busy, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(R.string.account_setup_other)) - } + ) } if (busy) { Box( @@ -140,3 +163,26 @@ fun AccountSetupScreen( } } } + +@Composable +private fun ProviderRow(icon: ImageVector, label: String, enabled: Boolean, onClick: () -> Unit) { + OutlinedCard(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = enabled, onClickLabel = label, onClick = onClick) + .padding(horizontal = 16.dp, vertical = 18.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, contentDescription = null, tint = MaterialTheme.colorScheme.primary) + Spacer(Modifier.width(16.dp)) + Text(label, style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) + Icon( + Icons.AutoMirrored.Filled.KeyboardArrowRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt index b2281e1..f6552bf 100644 --- a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountSetupViewModel.kt @@ -13,13 +13,19 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.libremail.auth.OutlookAuthManager +import org.libremail.domain.model.Account import org.libremail.domain.repository.AccountRepository import javax.inject.Inject /** Stage of an account-setup attempt, shared by the Outlook and manual flows. */ enum class SetupStatus { IDLE, CONNECTING, DONE } -data class AccountSetupUiState(val status: SetupStatus = SetupStatus.IDLE, val error: String? = null) +data class AccountSetupUiState( + val status: SetupStatus = SetupStatus.IDLE, + val error: String? = null, + /** Set alongside [SetupStatus.DONE]: the id of the account that was just added. */ + val addedAccountId: String? = null, +) @HiltViewModel class AccountSetupViewModel @Inject constructor( @@ -59,8 +65,11 @@ class AccountSetupViewModel @Inject constructor( runCatching { val oauth = outlookAuthManager.exchangeToken(data) accountRepository.addOutlookAccount(oauth.email, oauth.accessToken, oauth.authStateJson).getOrThrow() + Account.outlook(oauth.email).id }.fold( - onSuccess = { _state.update { it.copy(status = SetupStatus.DONE) } }, + onSuccess = { accountId -> + _state.update { it.copy(status = SetupStatus.DONE, addedAccountId = accountId) } + }, onFailure = { e -> // Stripped from release builds by the Log.d ProGuard rule (keeps any account // address / token detail out of shipped logs); visible in debug for diagnosis. diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreen.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreen.kt new file mode 100644 index 0000000..ff910e9 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreen.kt @@ -0,0 +1,262 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.accountsetup + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.launch +import org.libremail.R +import org.libremail.domain.model.MailProvider +import org.libremail.domain.model.MailSecurity +import org.libremail.domain.model.ServerConfig + +/** + * Guided app-password setup for the preset vendors (Gmail/Yahoo/iCloud). Explains what an app + * password is, warns to keep it safe, links out to the provider's app-password page, and collects + * only an email + app password (the servers come from the [MailProvider] preset). Verifies and + * persists via the same repository path as manual setup, surfacing failures as an inline snackbar. + * + * @param onAccountAdded invoked with the new account id after a successful add. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AppPasswordSetupScreen( + onBack: () -> Unit, + onAccountAdded: (String) -> Unit, + viewModel: AppPasswordViewModel = hiltViewModel(), +) { + val form by viewModel.form.collectAsStateWithLifecycle() + val provider = viewModel.provider + val snackbarHostState = remember { SnackbarHostState() } + val uriHandler = LocalUriHandler.current + val scope = rememberCoroutineScope() + // Resolved up front so the failure handler (a non-composable lambda) can use it. + val openFailedMessage = stringResource(R.string.app_password_open_failed) + + LaunchedEffect(form.status, form.addedAccountId) { + if (form.status == SetupStatus.DONE) { + form.addedAccountId?.let(onAccountAdded) + } + } + LaunchedEffect(form.error) { + form.error?.let { + snackbarHostState.showSnackbar(it) + viewModel.consumeError() + } + } + + val busy = form.status == SetupStatus.CONNECTING + + Scaffold( + topBar = { + TopAppBar( + title = { + Text( + provider?.let { stringResource(R.string.app_password_title, it.displayName) } + ?: stringResource(R.string.title_account_setup), + ) + }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.action_back), + ) + } + }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + if (provider == null) { + // Defensive: onboarding only ever routes valid provider keys here. + Text( + text = stringResource(R.string.app_password_unknown_provider), + modifier = Modifier.padding(padding).padding(24.dp), + ) + return@Scaffold + } + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + ) { + InfoCard( + icon = Icons.Filled.Info, + text = stringResource(providerIntro(provider)), + ) + Spacer(Modifier.height(8.dp)) + InfoCard( + icon = Icons.Filled.Info, + text = stringResource(R.string.app_password_what_is), + ) + Spacer(Modifier.height(8.dp)) + InfoCard( + icon = Icons.Filled.Warning, + text = stringResource(R.string.app_password_warning), + ) + + Spacer(Modifier.height(12.dp)) + OutlinedButton( + onClick = { + // openUri throws if no browser/handler is installed; surface it instead of crashing. + runCatching { uriHandler.openUri(provider.appPasswordHelpUrl) } + .onFailure { scope.launch { snackbarHostState.showSnackbar(openFailedMessage) } } + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.app_password_open_page, provider.displayName)) + } + + Spacer(Modifier.height(20.dp)) + OutlinedTextField( + value = form.email, + onValueChange = viewModel::onEmail, + label = { Text(stringResource(R.string.app_password_email)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Email), + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(12.dp)) + OutlinedTextField( + value = form.appPassword, + onValueChange = viewModel::onAppPassword, + label = { Text(stringResource(R.string.app_password_field)) }, + singleLine = true, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + modifier = Modifier.fillMaxWidth(), + ) + + Spacer(Modifier.height(8.dp)) + val account = remember(provider) { provider.createAccount("") } + AdvancedServers( + expanded = form.advancedExpanded, + onToggle = viewModel::toggleAdvanced, + imap = account.imap, + smtp = account.smtp, + ) + + Spacer(Modifier.height(24.dp)) + Button( + onClick = viewModel::testAndSave, + enabled = form.isValid && !busy, + modifier = Modifier.fillMaxWidth(), + ) { + if (busy) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + Spacer(Modifier.width(8.dp)) + } + Text(stringResource(R.string.app_password_test_and_add)) + } + } + } +} + +@Composable +private fun InfoCard(icon: ImageVector, text: String) { + Row(verticalAlignment = Alignment.Top) { + Icon( + icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp).padding(top = 2.dp), + ) + Spacer(Modifier.width(12.dp)) + Text(text, style = MaterialTheme.typography.bodyMedium) + } +} + +/** A collapsible, read-only view of the preset servers for users who want to confirm them. */ +@Composable +private fun AdvancedServers(expanded: Boolean, onToggle: () -> Unit, imap: ServerConfig, smtp: ServerConfig) { + Row( + modifier = Modifier.fillMaxWidth().clickable(onClick = onToggle).padding(vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + stringResource(R.string.app_password_show_servers), + style = MaterialTheme.typography.titleMedium, + modifier = Modifier.weight(1f), + ) + Icon( + Icons.Filled.ArrowDropDown, + contentDescription = null, + modifier = Modifier.rotate(if (expanded) 180f else 0f), + ) + } + AnimatedVisibility(visible = expanded) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text( + stringResource(R.string.app_password_server_imap, imap.host, imap.port, imap.security.label()), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + stringResource(R.string.app_password_server_smtp, smtp.host, smtp.port, smtp.security.label()), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +private fun providerIntro(provider: MailProvider): Int = when (provider) { + MailProvider.GMAIL -> R.string.app_password_intro_gmail + MailProvider.YAHOO -> R.string.app_password_intro_yahoo + MailProvider.ICLOUD -> R.string.app_password_intro_icloud +} + +private fun MailSecurity.label(): String = when (this) { + MailSecurity.SSL_TLS -> "SSL/TLS" + MailSecurity.STARTTLS -> "STARTTLS" + MailSecurity.NONE -> "None" +} diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/AppPasswordViewModel.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/AppPasswordViewModel.kt new file mode 100644 index 0000000..9964c0a --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/AppPasswordViewModel.kt @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.accountsetup + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import org.libremail.domain.model.MailProvider +import org.libremail.domain.repository.AccountRepository +import org.libremail.ui.navigation.Routes +import javax.inject.Inject + +data class AppPasswordForm( + val email: String = "", + val appPassword: String = "", + val advancedExpanded: Boolean = false, + val status: SetupStatus = SetupStatus.IDLE, + val error: String? = null, + /** Set alongside [SetupStatus.DONE]: the id of the account that was just added. */ + val addedAccountId: String? = null, +) { + val isValid: Boolean get() = email.isNotBlank() && appPassword.isNotBlank() +} + +/** + * Backs the guided app-password setup screen (#29) for the preset vendors (Gmail/Yahoo/iCloud). + * + * The provider is passed as a nav argument and resolved from the [MailProvider] registry, which + * supplies the servers. The user only supplies an email + app password; this builds a + * `PASSWORD_IMAP` [org.libremail.domain.model.Account] from the preset and reuses + * [AccountRepository.addImapAccount] (live connection test + persist), exactly like manual setup. + */ +@HiltViewModel +class AppPasswordViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, + private val accountRepository: AccountRepository, +) : ViewModel() { + + /** The provider preset selected in the picker; null only if an unknown key was routed here. */ + val provider: MailProvider? = + savedStateHandle.get(Routes.APP_PASSWORD_ARG_PROVIDER)?.let(MailProvider::fromKey) + + private val _form = MutableStateFlow(AppPasswordForm()) + val form: StateFlow = _form.asStateFlow() + + fun onEmail(value: String) = _form.update { it.copy(email = value) } + fun onAppPassword(value: String) = _form.update { it.copy(appPassword = value) } + fun toggleAdvanced() = _form.update { it.copy(advancedExpanded = !it.advancedExpanded) } + fun consumeError() = _form.update { it.copy(error = null) } + + fun testAndSave() { + val provider = provider + if (provider == null) { + _form.update { it.copy(error = "Unknown email provider") } + return + } + val f = _form.value + if (!f.isValid) { + _form.update { it.copy(error = "Enter your email address and app password") } + return + } + val account = provider.createAccount(f.email) + viewModelScope.launch { + _form.update { it.copy(status = SetupStatus.CONNECTING, error = null) } + accountRepository.addImapAccount(account, f.appPassword).fold( + onSuccess = { + _form.update { it.copy(status = SetupStatus.DONE, addedAccountId = account.id) } + }, + onFailure = { e -> + _form.update { + it.copy( + status = SetupStatus.IDLE, + error = e.message ?: "Could not connect to the server", + ) + } + }, + ) + } + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupScreen.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupScreen.kt index b06fe85..6d111cb 100644 --- a/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupScreen.kt @@ -52,14 +52,16 @@ import org.libremail.domain.model.MailSecurity @Composable fun ManualSetupScreen( onBack: () -> Unit, - onAccountAdded: () -> Unit, + onAccountAdded: (String) -> Unit, viewModel: ManualSetupViewModel = hiltViewModel(), ) { val form by viewModel.form.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } - LaunchedEffect(form.status) { - if (form.status == SetupStatus.DONE) onAccountAdded() + LaunchedEffect(form.status, form.addedAccountId) { + if (form.status == SetupStatus.DONE) { + form.addedAccountId?.let(onAccountAdded) + } } LaunchedEffect(form.error) { form.error?.let { diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupViewModel.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupViewModel.kt index 95776e5..01b8237 100644 --- a/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/ManualSetupViewModel.kt @@ -28,6 +28,8 @@ data class ManualSetupForm( val advancedExpanded: Boolean = false, val status: SetupStatus = SetupStatus.IDLE, val error: String? = null, + /** Set alongside [SetupStatus.DONE]: the id of the account that was just added. */ + val addedAccountId: String? = null, ) { val isValid: Boolean get() = email.isNotBlank() && password.isNotBlank() && imapHost.isNotBlank() && smtpHost.isNotBlank() @@ -73,7 +75,7 @@ class ManualSetupViewModel @Inject constructor(private val accountRepository: Ac viewModelScope.launch { _form.update { it.copy(status = SetupStatus.CONNECTING, error = null) } accountRepository.addImapAccount(account, f.password).fold( - onSuccess = { _form.update { it.copy(status = SetupStatus.DONE) } }, + onSuccess = { _form.update { it.copy(status = SetupStatus.DONE, addedAccountId = account.id) } }, onFailure = { e -> _form.update { it.copy( diff --git a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt index bb7061e..5c87165 100644 --- a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxScreen.kt @@ -37,7 +37,6 @@ import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Search import androidx.compose.material3.AlertDialog -import androidx.compose.material3.Button import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DrawerValue import androidx.compose.material3.DropdownMenu @@ -86,6 +85,7 @@ import org.libremail.domain.model.Folder import org.libremail.domain.model.FolderRole import org.libremail.domain.model.Message import org.libremail.domain.model.ReplyMode +import org.libremail.ui.onboarding.WelcomeContent @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -240,7 +240,9 @@ fun MailboxScreen( ) { padding -> Box(modifier = Modifier.fillMaxSize().padding(padding)) { if (!hasAccounts) { - NoAccountState(onAddAccount = onAddAccount) + // Onboarding covers the fresh-install empty case; this is the runtime fallback + // (e.g. the last account was removed). Reuses the same welcome invitation. + WelcomeContent(onAddAccount = onAddAccount, modifier = Modifier.fillMaxSize()) } else { val accountsById = remember(accounts) { accounts.associateBy { it.id } } val showAccount = selectedAccountId == null && accounts.size >= 2 @@ -686,34 +688,6 @@ private fun MoveFolderDialog(folders: List, onSelect: (Folder) -> Unit, ) } -@Composable -private fun NoAccountState(onAddAccount: () -> Unit) { - Column( - modifier = Modifier.fillMaxSize().padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Icon( - Icons.Filled.Email, - contentDescription = null, - modifier = Modifier.size(48.dp), - tint = MaterialTheme.colorScheme.primary, - ) - Spacer(Modifier.height(16.dp)) - Text(stringResource(R.string.mailbox_welcome_title), style = MaterialTheme.typography.titleMedium) - Spacer(Modifier.height(4.dp)) - Text( - stringResource(R.string.mailbox_welcome_subtitle), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - Spacer(Modifier.height(24.dp)) - Button(onClick = onAddAccount) { - Text(stringResource(R.string.settings_add_account)) - } - } -} - @Composable private fun NoMessagesState(modifier: Modifier = Modifier) { Column( diff --git a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt index 878f4d9..180b9a4 100644 --- a/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/mailbox/MailboxViewModel.kt @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.ui.mailbox +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -29,6 +30,7 @@ import org.libremail.domain.model.Message import org.libremail.domain.model.ReplyMode import org.libremail.domain.repository.AccountRepository import org.libremail.domain.repository.MailRepository +import org.libremail.ui.navigation.Routes import javax.inject.Inject const val INBOX = "INBOX" @@ -42,8 +44,14 @@ class MailboxViewModel @Inject constructor( private val mailRepository: MailRepository, accountRepository: AccountRepository, private val mailSyncer: Syncer, + savedStateHandle: SavedStateHandle, ) : ViewModel() { + // Optional "open filtered to this account" arg — set when onboarding lands the user on the + // first account they added, so the mailbox opens that account's inbox rather than the unified view. + private val initialAccountId: String? = + savedStateHandle.get(Routes.MAILBOX_ARG_ACCOUNT)?.takeIf { it.isNotBlank() } + val accounts: StateFlow> = accountRepository.observeAccounts() .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) @@ -52,7 +60,7 @@ class MailboxViewModel @Inject constructor( .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false) /** null = unified "All inboxes"; otherwise the account whose mail is shown. */ - private val _selectedAccountId = MutableStateFlow(null) + private val _selectedAccountId = MutableStateFlow(initialAccountId) val selectedAccountId: StateFlow = _selectedAccountId.asStateFlow() /** The folder whose mail is shown (always a concrete folder; defaults to the inbox). */ @@ -60,7 +68,7 @@ class MailboxViewModel @Inject constructor( val selectedFolder: StateFlow = _selectedFolder.asStateFlow() /** Which account's folders the drawer lists. null follows the mailbox selection / first account. */ - private val explicitDrawerAccountId = MutableStateFlow(null) + private val explicitDrawerAccountId = MutableStateFlow(initialAccountId) /** The account the drawer is browsing: explicit drawer pick, else the filtered account, else the first. */ val drawerAccount: StateFlow = @@ -241,11 +249,13 @@ class MailboxViewModel @Inject constructor( } init { - // Fall back to the unified inbox if the filtered account is removed. + // Fall back to the unified inbox if the filtered account is removed. The list.isNotEmpty() + // guard avoids clobbering a seeded account filter during the initial empty emission (before + // the account list first loads from the database). viewModelScope.launch { accounts.collect { list -> val selected = _selectedAccountId.value - if (selected != null && list.none { it.id == selected }) { + if (selected != null && list.isNotEmpty() && list.none { it.id == selected }) { _selectedAccountId.value = null _selectedFolder.value = INBOX } diff --git a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt index e03afc0..ba80f01 100644 --- a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt +++ b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt @@ -13,6 +13,31 @@ object Routes { const val OUTBOX = "outbox" const val PROBLEM_REPORTS = "problem_reports" + // The mailbox can be opened filtered to a single account (used when onboarding lands the user on + // the first account they added). The bare MAILBOX route resolves here with an empty account arg, + // so it stays a valid start destination and bottom-tab target. + const val MAILBOX_ARG_ACCOUNT = "account" + const val MAILBOX_PATTERN = "mailbox?account={$MAILBOX_ARG_ACCOUNT}" + fun mailboxForAccount(accountId: String) = "mailbox?account=${Uri.encode(accountId)}" + + // App-password guided setup, parameterized by provider key (see MailProvider). Reused by both + // onboarding and the standalone "Add account" entry. + const val APP_PASSWORD_ARG_PROVIDER = "provider" + const val APP_PASSWORD_PATTERN = "app_password/{$APP_PASSWORD_ARG_PROVIDER}" + fun appPassword(provider: String) = "app_password/${Uri.encode(provider)}" + + // Onboarding first-run flow (nested graph). ONBOARDING is the graph route; the rest are its + // destinations. The graph owns the "first account added this session" state via a graph-scoped + // ViewModel, so the picker/setup screens are registered inside it for onboarding and reused as + // the top-level ACCOUNT_SETUP / APP_PASSWORD / MANUAL_SETUP routes for "Add account" later. + const val ONBOARDING = "onboarding" + const val ONBOARDING_WELCOME = "onboarding/welcome" + const val ONBOARDING_PICKER = "onboarding/picker" + const val ONBOARDING_MANUAL = "onboarding/manual" + const val ONBOARDING_ADD_ANOTHER = "onboarding/add_another" + const val ONBOARDING_APP_PASSWORD_PATTERN = "onboarding/app_password/{$APP_PASSWORD_ARG_PROVIDER}" + fun onboardingAppPassword(provider: String) = "onboarding/app_password/${Uri.encode(provider)}" + const val READER_ARG_ID = "messageId" const val READER_PATTERN = "reader/{$READER_ARG_ID}" fun reader(messageId: String) = "reader/${Uri.encode(messageId)}" diff --git a/app/src/main/kotlin/org/libremail/ui/onboarding/AddAnotherAccountScreen.kt b/app/src/main/kotlin/org/libremail/ui/onboarding/AddAnotherAccountScreen.kt new file mode 100644 index 0000000..5740b52 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/onboarding/AddAnotherAccountScreen.kt @@ -0,0 +1,84 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.onboarding + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.libremail.R + +/** + * Shown after an account is added *during onboarding*: confirms the add and asks whether to add + * another. Yes returns to the vendor picker; No finishes onboarding and opens the first account's + * inbox. Only part of the onboarding flow — adding an account from Settings later skips this prompt. + */ +@Composable +fun AddAnotherAccountScreen(onAddAnother: () -> Unit, onFinish: () -> Unit) { + Scaffold { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + Icons.Filled.CheckCircle, + contentDescription = null, + modifier = Modifier.size(72.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(24.dp)) + Text( + text = stringResource(R.string.onboarding_account_added_title), + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.onboarding_add_another_prompt), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(32.dp)) + Button( + onClick = onAddAnother, + modifier = Modifier + .fillMaxWidth() + .widthIn(max = 360.dp), + ) { + Text(stringResource(R.string.onboarding_add_another_yes)) + } + Spacer(Modifier.height(12.dp)) + OutlinedButton( + onClick = onFinish, + modifier = Modifier + .fillMaxWidth() + .widthIn(max = 360.dp), + ) { + Text(stringResource(R.string.onboarding_add_another_no)) + } + } + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/onboarding/OnboardingViewModel.kt b/app/src/main/kotlin/org/libremail/ui/onboarding/OnboardingViewModel.kt new file mode 100644 index 0000000..f9d5659 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/onboarding/OnboardingViewModel.kt @@ -0,0 +1,28 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.onboarding + +import androidx.lifecycle.ViewModel +import dagger.hilt.android.lifecycle.HiltViewModel +import javax.inject.Inject + +/** + * Session state for one run of the onboarding flow. Scoped to the onboarding nav graph's back-stack + * entry, so it is created when onboarding starts and cleared when the graph is popped. + * + * Its only job is to remember the **first** account added during this session: when the user + * finishes ("No, don't add another"), onboarding opens that account's inbox (see #30). + */ +@HiltViewModel +class OnboardingViewModel @Inject constructor() : ViewModel() { + + /** The id of the first account added this session, or null if none has been added yet. */ + var firstAddedAccountId: String? = null + private set + + /** Records a freshly added account. Only the first one sticks — later adds don't overwrite it. */ + fun onAccountAdded(accountId: String) { + if (firstAddedAccountId == null) { + firstAddedAccountId = accountId + } + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/onboarding/OnboardingWelcomeScreen.kt b/app/src/main/kotlin/org/libremail/ui/onboarding/OnboardingWelcomeScreen.kt new file mode 100644 index 0000000..22e3631 --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/onboarding/OnboardingWelcomeScreen.kt @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.onboarding + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Email +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import org.libremail.R + +/** + * First-run welcome. Invites the user to connect their first mailbox and hands off to the vendor + * picker. Shown as the onboarding start destination when the app launches with no accounts. + */ +@Composable +fun OnboardingWelcomeScreen(onAddAccount: () -> Unit) { + Scaffold { padding -> + WelcomeContent( + onAddAccount = onAddAccount, + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) + } +} + +/** + * The welcome body: a headline, a short subtitle, and the "Add account" call to action. Extracted so + * the mailbox's empty state (when the last account is removed) reuses the exact same invitation + * instead of a separate blank-inbox screen. + */ +@Composable +fun WelcomeContent(onAddAccount: () -> Unit, modifier: Modifier = Modifier) { + Column( + modifier = modifier.padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + Icons.Filled.Email, + contentDescription = null, + modifier = Modifier.size(72.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(24.dp)) + Text( + text = stringResource(R.string.onboarding_welcome_title), + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource(R.string.onboarding_welcome_subtitle), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(32.dp)) + Button( + onClick = onAddAccount, + modifier = Modifier + .fillMaxWidth() + .widthIn(max = 360.dp), + ) { + Text(stringResource(R.string.onboarding_add_account)) + } + } +} 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 3b13e91..c015376 100644 --- a/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt @@ -122,6 +122,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 a29b5d4..00437ac 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -30,8 +30,6 @@ Search mail No results Pull down to refresh - Welcome to LibreMail - Add an account to start reading your mail. %1$d selected @@ -102,10 +100,36 @@ Couldn\'t download %1$s No app can open this file - - Sign in with Microsoft + + Welcome to LibreMail + A private, open-source home for all your email. Add your first account to get started. + Add an email account + Account added + Would you like to add another email account? + Add another account + No, take me to my inbox + + + Outlook or Hotmail Other (IMAP/SMTP) - Choose how you want to connect your mailbox. + Choose your email provider to get started. + + + Connect %1$s + Unknown email provider. + To connect Gmail, create an app password in your Google Account. Gmail requires 2-Step Verification to be turned on before you can create one. + To connect Yahoo Mail, generate an app password from your Yahoo Account security settings. + To connect iCloud Mail, create an app-specific password from your Apple ID account page. + An app password is a one-off password that lets an app sign in to your account without your main password or a two-factor code. + Store this app password carefully — it grants full access to your email. LibreMail keeps it only on this device. + Create an app password for %1$s + Couldn\'t open your browser + Email address + App password + Server settings + Incoming (IMAP): %1$s:%2$d (%3$s) + Outgoing (SMTP): %1$s:%2$d (%3$s) + Test & add account IMAP / SMTP @@ -143,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/app/src/test/kotlin/org/libremail/domain/model/MailProviderTest.kt b/app/src/test/kotlin/org/libremail/domain/model/MailProviderTest.kt new file mode 100644 index 0000000..52a8519 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/domain/model/MailProviderTest.kt @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.domain.model + +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Locks down the preconfigured server settings for the app-password vendors. These are easy to get + * subtly wrong (a swapped port or the wrong transport security) and painful to debug on-device, so + * they are asserted explicitly here rather than trusted to a code review. + */ +class MailProviderTest { + + @Test + fun `gmail preset uses documented imap and starttls smtp endpoints`() { + val account = MailProvider.GMAIL.createAccount("user@gmail.com") + + assertEquals("imap.gmail.com", account.imap.host) + assertEquals(993, account.imap.port) + assertEquals(MailSecurity.SSL_TLS, account.imap.security) + + assertEquals("smtp.gmail.com", account.smtp.host) + assertEquals(587, account.smtp.port) + assertEquals(MailSecurity.STARTTLS, account.smtp.security) + } + + @Test + fun `yahoo preset uses documented imap and implicit-tls smtp endpoints`() { + val account = MailProvider.YAHOO.createAccount("user@yahoo.com") + + assertEquals("imap.mail.yahoo.com", account.imap.host) + assertEquals(993, account.imap.port) + assertEquals(MailSecurity.SSL_TLS, account.imap.security) + + assertEquals("smtp.mail.yahoo.com", account.smtp.host) + assertEquals(465, account.smtp.port) + assertEquals(MailSecurity.SSL_TLS, account.smtp.security) + } + + @Test + fun `icloud preset uses documented imap and starttls smtp endpoints`() { + val account = MailProvider.ICLOUD.createAccount("user@icloud.com") + + assertEquals("imap.mail.me.com", account.imap.host) + assertEquals(993, account.imap.port) + assertEquals(MailSecurity.SSL_TLS, account.imap.security) + + assertEquals("smtp.mail.me.com", account.smtp.host) + assertEquals(587, account.smtp.port) + assertEquals(MailSecurity.STARTTLS, account.smtp.security) + } + + @Test + fun `no provider ever uses insecure transport`() { + MailProvider.entries.forEach { provider -> + val account = provider.createAccount("user@example.com") + assertTrue( + account.imap.security != MailSecurity.NONE, + "${provider.key} IMAP must not use MailSecurity.NONE", + ) + assertTrue( + account.smtp.security != MailSecurity.NONE, + "${provider.key} SMTP must not use MailSecurity.NONE", + ) + } + } + + @Test + fun `every provider resolves to a password-imap account with a help url`() { + MailProvider.entries.forEach { provider -> + val account = provider.createAccount("user@example.com") + assertEquals(AuthType.PASSWORD_IMAP, account.authType) + assertTrue( + provider.appPasswordHelpUrl.startsWith("https://"), + "${provider.key} must expose an https app-password help URL", + ) + } + } + + @Test + fun `createAccount trims the email and derives a stable id and display name`() { + val account = MailProvider.GMAIL.createAccount(" User@Gmail.com ") + + assertEquals("User@Gmail.com", account.email) + assertEquals("imap:User@Gmail.com", account.id) + assertEquals("User@Gmail.com", account.displayName) + } + + @Test + fun `createAccount keeps an explicit non-blank display name`() { + val account = MailProvider.GMAIL.createAccount("user@gmail.com", displayName = "Work") + + assertEquals("Work", account.displayName) + } + + @Test + fun `fromKey looks up providers case-insensitively and returns null for unknowns`() { + assertEquals(MailProvider.GMAIL, MailProvider.fromKey("gmail")) + assertEquals(MailProvider.YAHOO, MailProvider.fromKey("YAHOO")) + assertEquals(MailProvider.ICLOUD, MailProvider.fromKey("iCloud")) + assertNull(MailProvider.fromKey("outlook")) + assertNull(MailProvider.fromKey("")) + } + + @Test + fun `provider keys are unique and lowercase`() { + val keys = MailProvider.entries.map { it.key } + assertEquals(keys.toSet().size, keys.size, "provider keys must be unique") + keys.forEach { key -> assertEquals(key.lowercase(), key, "provider key must be lowercase") } + } + + @Test + fun `display names are present for the picker`() { + MailProvider.entries.forEach { provider -> + assertNotNull(provider.displayName) + assertTrue(provider.displayName.isNotBlank()) + } + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/accountsetup/AppPasswordViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/accountsetup/AppPasswordViewModelTest.kt new file mode 100644 index 0000000..81b13ec --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/accountsetup/AppPasswordViewModelTest.kt @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.accountsetup + +import androidx.lifecycle.SavedStateHandle +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.slot +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.libremail.domain.model.Account +import org.libremail.domain.model.MailProvider +import org.libremail.domain.model.MailSecurity +import org.libremail.domain.repository.AccountRepository +import org.libremail.ui.navigation.Routes +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class AppPasswordViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + + @Before + fun setUp() = Dispatchers.setMain(testDispatcher) + + @After + fun tearDown() = Dispatchers.resetMain() + + private fun viewModel(repo: AccountRepository, providerKey: String = MailProvider.GMAIL.key) = AppPasswordViewModel( + SavedStateHandle(mapOf(Routes.APP_PASSWORD_ARG_PROVIDER to providerKey)), + repo, + ) + + @Test + fun `provider is resolved from the nav argument`() { + val vm = viewModel(mockk(relaxed = true), providerKey = "icloud") + assertEquals(MailProvider.ICLOUD, vm.provider) + } + + @Test + fun `valid input builds the preset account and persists it`() = runTest(testDispatcher) { + val repo = mockk() + val account = slot() + coEvery { repo.addImapAccount(capture(account), "app-pass") } returns Result.success(listOf("INBOX")) + val vm = viewModel(repo) + + vm.onEmail(" user@gmail.com ") + vm.onAppPassword("app-pass") + vm.testAndSave() + + coVerify { repo.addImapAccount(any(), "app-pass") } + // Servers come from the Gmail preset, not from any user input. + assertEquals("imap.gmail.com", account.captured.imap.host) + assertEquals(993, account.captured.imap.port) + assertEquals(MailSecurity.SSL_TLS, account.captured.imap.security) + assertEquals("smtp.gmail.com", account.captured.smtp.host) + assertEquals(587, account.captured.smtp.port) + assertEquals(MailSecurity.STARTTLS, account.captured.smtp.security) + assertEquals("user@gmail.com", account.captured.email) + + assertEquals(SetupStatus.DONE, vm.form.value.status) + assertEquals("imap:user@gmail.com", vm.form.value.addedAccountId) + } + + @Test + fun `blank email or app password surfaces an error without contacting the server`() = runTest(testDispatcher) { + val repo = mockk(relaxed = true) + val vm = viewModel(repo) + + vm.onEmail("") + vm.onAppPassword("app-pass") + vm.testAndSave() + + assertTrue(vm.form.value.error != null) + assertEquals(SetupStatus.IDLE, vm.form.value.status) + assertNull(vm.form.value.addedAccountId) + coVerify(exactly = 0) { repo.addImapAccount(any(), any()) } + } + + @Test + fun `a connection failure is surfaced inline and the account is not marked added`() = runTest(testDispatcher) { + val repo = mockk() + coEvery { repo.addImapAccount(any(), any()) } returns Result.failure(RuntimeException("Login failed")) + val vm = viewModel(repo) + + vm.onEmail("user@gmail.com") + vm.onAppPassword("wrong") + vm.testAndSave() + + assertEquals("Login failed", vm.form.value.error) + assertEquals(SetupStatus.IDLE, vm.form.value.status) + assertNull(vm.form.value.addedAccountId) + } + + @Test + fun `an unknown provider key surfaces an error and never contacts the server`() = runTest(testDispatcher) { + val repo = mockk(relaxed = true) + val vm = viewModel(repo, providerKey = "bogus") + + assertNull(vm.provider) + vm.onEmail("user@example.com") + vm.onAppPassword("app-pass") + vm.testAndSave() + + assertTrue(vm.form.value.error != null) + coVerify(exactly = 0) { repo.addImapAccount(any(), any()) } + } + + @Test + fun `isValid requires both an email and an app password`() { + val vm = viewModel(mockk(relaxed = true)) + assertTrue(!vm.form.value.isValid) + vm.onEmail("user@gmail.com") + assertTrue(!vm.form.value.isValid) + vm.onAppPassword("app-pass") + assertTrue(vm.form.value.isValid) + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/mailbox/MailboxViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/mailbox/MailboxViewModelTest.kt index e60501c..25cb649 100644 --- a/app/src/test/kotlin/org/libremail/ui/mailbox/MailboxViewModelTest.kt +++ b/app/src/test/kotlin/org/libremail/ui/mailbox/MailboxViewModelTest.kt @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.ui.mailbox +import androidx.lifecycle.SavedStateHandle import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -29,6 +30,7 @@ import org.libremail.domain.model.ReplyMode import org.libremail.domain.model.ServerConfig import org.libremail.domain.repository.AccountRepository import org.libremail.domain.repository.MailRepository +import org.libremail.ui.navigation.Routes import kotlin.test.assertEquals import kotlin.test.assertNull import kotlin.test.assertTrue @@ -374,12 +376,27 @@ class MailboxViewModelTest { coVerify { repo.buildReplyDraft("imap:a:INBOX:1", ReplyMode.REPLY_ALL) } } + @Test + fun `opens filtered to the account passed as a nav argument`() = runTest(testDispatcher) { + val vm = createViewModel( + accounts = listOf(alice, bob), + messages = listOf(msg("imap:a:INBOX:1", "imap:a", "INBOX"), msg("imap:b:INBOX:1", "imap:b", "INBOX")), + initialAccountId = "imap:a", + ) + backgroundScope.launch { vm.messages.collect {} } + + assertEquals("imap:a", vm.selectedAccountId.value) + assertEquals("INBOX", vm.selectedFolder.value) + assertEquals(listOf("imap:a:INBOX:1"), vm.messages.value.map { it.id }) + } + private fun createViewModel( accounts: List, messages: List, folders: Map> = emptyMap(), syncer: MailSyncer = mockk(relaxed = true), repo: MailRepository = mockk(relaxed = true), + initialAccountId: String? = null, ): MailboxViewModel { every { repo.observeMessages() } returns MutableStateFlow(messages) every { repo.observeDrafts() } returns flowOf(emptyList()) @@ -389,7 +406,10 @@ class MailboxViewModelTest { } val accountRepository = mockk(relaxed = true) every { accountRepository.observeAccounts() } returns MutableStateFlow(accounts) - return MailboxViewModel(repo, accountRepository, syncer) + val savedState = initialAccountId?.let { + SavedStateHandle(mapOf(Routes.MAILBOX_ARG_ACCOUNT to it)) + } ?: SavedStateHandle() + return MailboxViewModel(repo, accountRepository, syncer, savedState) } private fun account(id: String, email: String) = Account( 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=