From e2e2c36d1d492574acc74cbe5b178d52d62e29d5 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Wed, 1 Jul 2026 00:13:32 -0500 Subject: [PATCH] feat(onboarding): first-run flow, vendor picker, and app-password setup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the onboarding epic (#26–#31) as a single feature: - #26 First-run nav scaffold: gate the start destination on the account count (no accounts -> onboarding, else mailbox) via AppViewModel, holding render until the count is known so there is no cold-start flash. Onboarding is a nested nav graph with a graph-scoped OnboardingViewModel tracking the first account added this session. Removes NoAccountState in favour of a shared welcome/empty state. - #28 Provider registry: MailProvider presets for Gmail/Yahoo/iCloud (IMAP+SMTP host/port/security, help URL) mirroring Account.outlook, biased to STARTTLS where documented; host/port/security unit-tested. - #27 Vendor picker: AccountPickerScreen replaces the two-button setup screen as the single entry point (onboarding + Settings/mailbox "Add account"), routing Outlook -> OAuth, Gmail/Yahoo/iCloud -> app-password, Other -> manual. - #29 App-password guided screen: one reusable screen per provider key with explanation + security warning + help link; verifies/persists via addImapAccount. ViewModel unit tests cover valid/invalid/failure paths. - #30 "Add another?" prompt + first-account landing: after each onboarding add, offer Yes (back to picker) / No (open the first account's inbox, per-account filtered via a MAILBOX account nav arg). Only inside onboarding. - #31 Instrumented onboarding E2E (welcome -> picker -> app-password add -> add-another -> first inbox); managed-device list and CI matrix already in lockstep. All new files carry the SPDX header. Co-Authored-By: Claude Opus 4.8 --- .../kotlin/org/libremail/ui/Fakes.kt | 5 + .../ui/accountsetup/ManualSetupScreenTest.kt | 2 +- .../libremail/ui/mailbox/MailboxScreenTest.kt | 8 +- .../ui/onboarding/OnboardingFlowTest.kt | 206 ++++++++++++++ .../libremail/domain/model/MailProvider.kt | 90 ++++++ .../kotlin/org/libremail/ui/AppViewModel.kt | 32 +++ .../kotlin/org/libremail/ui/LibreMailApp.kt | 140 +++++++++- ...tSetupScreen.kt => AccountPickerScreen.kt} | 112 +++++--- .../ui/accountsetup/AccountSetupViewModel.kt | 13 +- .../ui/accountsetup/AppPasswordSetupScreen.kt | 262 ++++++++++++++++++ .../ui/accountsetup/AppPasswordViewModel.kt | 85 ++++++ .../ui/accountsetup/ManualSetupScreen.kt | 8 +- .../ui/accountsetup/ManualSetupViewModel.kt | 4 +- .../org/libremail/ui/mailbox/MailboxScreen.kt | 34 +-- .../libremail/ui/mailbox/MailboxViewModel.kt | 18 +- .../org/libremail/ui/navigation/Routes.kt | 25 ++ .../ui/onboarding/AddAnotherAccountScreen.kt | 84 ++++++ .../ui/onboarding/OnboardingViewModel.kt | 28 ++ .../ui/onboarding/OnboardingWelcomeScreen.kt | 85 ++++++ app/src/main/res/values/strings.xml | 34 ++- .../domain/model/MailProviderTest.kt | 122 ++++++++ .../accountsetup/AppPasswordViewModelTest.kt | 127 +++++++++ .../ui/mailbox/MailboxViewModelTest.kt | 22 +- 23 files changed, 1450 insertions(+), 96 deletions(-) create mode 100644 app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt create mode 100644 app/src/main/kotlin/org/libremail/domain/model/MailProvider.kt create mode 100644 app/src/main/kotlin/org/libremail/ui/AppViewModel.kt rename app/src/main/kotlin/org/libremail/ui/accountsetup/{AccountSetupScreen.kt => AccountPickerScreen.kt} (52%) create mode 100644 app/src/main/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreen.kt create mode 100644 app/src/main/kotlin/org/libremail/ui/accountsetup/AppPasswordViewModel.kt create mode 100644 app/src/main/kotlin/org/libremail/ui/onboarding/AddAnotherAccountScreen.kt create mode 100644 app/src/main/kotlin/org/libremail/ui/onboarding/OnboardingViewModel.kt create mode 100644 app/src/main/kotlin/org/libremail/ui/onboarding/OnboardingWelcomeScreen.kt create mode 100644 app/src/test/kotlin/org/libremail/domain/model/MailProviderTest.kt create mode 100644 app/src/test/kotlin/org/libremail/ui/accountsetup/AppPasswordViewModelTest.kt 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..30299ad --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt @@ -0,0 +1,206 @@ +// 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.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) + + private fun waitForText(text: String) = composeTestRule.waitUntil(5_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. + waitForText(string(R.string.app_password_email)) + composeTestRule.onNodeWithText(string(R.string.app_password_email)).performTextInput("e2e@gmail.com") + composeTestRule.onNodeWithText(string(R.string.app_password_field)).performTextInput("app-pass") + composeTestRule.onNodeWithText(string(R.string.app_password_test_and_add)).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/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 0301b0f..291eeb1 100644 --- a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt +++ b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt @@ -6,34 +6,60 @@ import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Text 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.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.settings.AccountSettingsScreen import org.libremail.ui.settings.SettingsScreen @Composable -fun LibreMailApp() { +fun LibreMailApp(appViewModel: AppViewModel = 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() 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()) }, @@ -89,25 +115,30 @@ fun LibreMailApp() { ) { 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) { @@ -122,6 +153,85 @@ fun LibreMailApp() { } } +/** + * 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 5ddb2d5..bd8234c 100644 --- a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt +++ b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt @@ -12,6 +12,31 @@ object Routes { const val DRAFTS = "drafts" const val OUTBOX = "outbox" + // 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/res/values/strings.xml b/app/src/main/res/values/strings.xml index a4df47f..6f3da66 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 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(