diff --git a/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt index 4cf6d67..52eb3a8 100644 --- a/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt @@ -134,6 +134,20 @@ class OnboardingFlowTest { navController.navigate(Routes.onboardingAppPassword(provider.key)) }, onManualSetup = {}, + onPickOutlook = { navController.navigate(Routes.ONBOARDING_OUTLOOK_IMAP) }, + viewModel = viewModel, + ) + } + composable(Routes.ONBOARDING_OUTLOOK_IMAP) { + val viewModel = remember { AccountSetupViewModel(outlookAuthManager, accountRepo) } + OutlookImapNoticeScreen( + onBack = { navController.popBackStack() }, + onAccountAdded = { id -> + onboarding.onAccountAdded(id) + navController.navigate(Routes.ONBOARDING_ADD_ANOTHER) { + popUpTo(Routes.ONBOARDING_PICKER) + } + }, viewModel = viewModel, ) } @@ -252,6 +266,23 @@ class OnboardingFlowTest { composeTestRule.onNodeWithText("E2E first message").assertIsDisplayed() } + @Test + fun outlookPick_showsImapNoticeBeforeAuth() { + setOnboardingContent(FakeAccountRepository(), FakeMailRepository()) + + // Welcome → picker → tap Outlook. + composeTestRule.onNodeWithText(string(R.string.onboarding_add_account)).performClick() + waitForText(string(R.string.account_setup_outlook)) + composeTestRule.onNodeWithText(string(R.string.account_setup_outlook)).performClick() + + // Picking Outlook lands on the IMAP-enablement notice BEFORE any OAuth browser opens (#411): + // the interstitial's question is shown and its bottom "Sign in" button (which would continue + // the existing Outlook auth flow) is present. + waitForText(string(R.string.outlook_imap_question)) + composeTestRule.onNodeWithText(string(R.string.outlook_imap_question)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.outlook_imap_sign_in)).performScrollTo().assertIsDisplayed() + } + @Test fun yahooSetup_hasNoTwoFactorHelpLink() { setOnboardingContent(FakeAccountRepository(), FakeMailRepository()) diff --git a/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OutlookImapNoticeScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OutlookImapNoticeScreenTest.kt new file mode 100644 index 0000000..6b2417e --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OutlookImapNoticeScreenTest.kt @@ -0,0 +1,121 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.onboarding + +import android.app.Activity +import android.app.Instrumentation +import android.content.Context +import android.content.Intent +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction +import androidx.test.espresso.intent.matcher.IntentMatchers.hasComponent +import androidx.test.espresso.intent.matcher.IntentMatchers.hasData +import androidx.test.espresso.intent.matcher.UriMatchers.hasHost +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import net.openid.appauth.AuthorizationManagementActivity +import org.hamcrest.CoreMatchers.allOf +import org.hamcrest.CoreMatchers.equalTo +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.ui.FakeAccountRepository +import org.libremail.ui.accountsetup.AccountSetupViewModel +import org.libremail.ui.theme.LibreMailTheme + +/** + * End-to-end UI test for the pre-auth Outlook IMAP-enablement notice (#411). Drives the real + * [OutlookImapNoticeScreen] + [AccountSetupViewModel] over a [FakeAccountRepository]: the IMAP + * question and both outbound links render, tapping a help link fires the browser `ACTION_VIEW` + * intent, and tapping the bottom "Sign in" button starts the existing Microsoft OAuth (AppAuth) + * flow. Both launches are asserted with Espresso-Intents (mirroring `AccountPickerScreenTest`), so no + * real browser ever opens; the interstitial → OAuth navigation in the full onboarding graph is + * covered by `OnboardingFlowTest`. + */ +@RunWith(AndroidJUnit4::class) +class OutlookImapNoticeScreenTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private val context: Context = + InstrumentationRegistry.getInstrumentation().targetContext.applicationContext + + private fun string(resId: Int) = composeTestRule.activity.getString(resId) + + private fun setContent(onAccountAdded: (String) -> Unit = {}) { + val viewModel = AccountSetupViewModel(OutlookAuthManager(context), FakeAccountRepository()) + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + OutlookImapNoticeScreen(onBack = {}, onAccountAdded = onAccountAdded, viewModel = viewModel) + } + } + } + + @Test + fun showsImapQuestion_bothLinks_andSignIn() { + setContent() + + composeTestRule.onNodeWithText(string(R.string.outlook_imap_question)).performScrollTo().assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.outlook_imap_help)).performScrollTo().assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.outlook_imap_settings)).performScrollTo().assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.outlook_imap_sign_in)).performScrollTo().assertIsDisplayed() + } + + /** + * Tapping the "How to enable IMAP" link opens Microsoft's help article via + * [androidx.compose.ui.platform.UriHandler], which starts an `ACTION_VIEW` intent. Stubbing that + * intent both proves the tap launched it and stops a real browser from opening on the device. + */ + @Test + fun tappingImapHelpLink_opensTheMicrosoftArticle() { + setContent() + + Intents.init() + try { + Intents.intending(hasAction(Intent.ACTION_VIEW)) + .respondWith(Instrumentation.ActivityResult(Activity.RESULT_CANCELED, null)) + + composeTestRule.onNodeWithText(string(R.string.outlook_imap_help)) + .performScrollTo() + .performClick() + + Intents.intended(allOf(hasAction(Intent.ACTION_VIEW), hasData(hasHost(equalTo("support.microsoft.com"))))) + } finally { + Intents.release() + } + } + + /** + * Tapping "Sign in" must continue the existing Outlook OAuth flow — i.e. fire the AppAuth + * authorization intent (mirroring `AccountPickerScreenTest.tappingOutlook_...`). AppAuth wraps its + * browser launch in an intent targeting [AuthorizationManagementActivity]; that component name is + * the guaranteed, browser-independent signature of the launch. Stubbing a canceled result stops + * that activity from ever resuming and opening a real browser. + */ + @Test + fun tappingSignIn_launchesTheAppAuthBrowserIntent() { + setContent() + + Intents.init() + try { + Intents.intending(hasComponent(AuthorizationManagementActivity::class.java.name)) + .respondWith(Instrumentation.ActivityResult(Activity.RESULT_CANCELED, null)) + + composeTestRule.onNodeWithText(string(R.string.outlook_imap_sign_in)) + .performScrollTo() + .performClick() + + Intents.intended(hasComponent(AuthorizationManagementActivity::class.java.name)) + } finally { + Intents.release() + } + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt index 7363c07..8c6baa1 100644 --- a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt +++ b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt @@ -42,6 +42,7 @@ import org.libremail.ui.onboarding.ContactsAccessScreen import org.libremail.ui.onboarding.LicenseScreen import org.libremail.ui.onboarding.OnboardingViewModel import org.libremail.ui.onboarding.OnboardingWelcomeScreen +import org.libremail.ui.onboarding.OutlookImapNoticeScreen import org.libremail.ui.outbox.OutboxScreen import org.libremail.ui.reader.ReaderScreen import org.libremail.ui.reporting.ProblemReportsScreen @@ -344,29 +345,50 @@ private fun NavGraphBuilder.onboardingGraph(navController: NavHostController, li navController.navigate(Routes.onboardingAppPassword(provider.key)) }, onManualSetup = { navController.navigate(Routes.ONBOARDING_MANUAL) }, + // Onboarding interposes the IMAP-enablement notice before Microsoft sign-in (#411); + // the notice screen then runs the same Outlook OAuth flow the picker would inline. + onPickOutlook = { navController.navigate(Routes.ONBOARDING_OUTLOOK_IMAP) }, ) } - 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) }, - ) - } + onboardingSetupDestinations(navController) onboardingFinishDestinations(navController) } } +/** + * The account-setup destinations reached from the vendor picker: the pre-auth Outlook IMAP-enablement + * notice (#411), the guided app-password form, and manual IMAP/SMTP setup. Split out of + * [onboardingGraph] so each stays a readable length; all share the graph-scoped [OnboardingViewModel] + * and report a completed add through [completeAdd], which drops the setup screen and advances to the + * "add another?" prompt. + */ +private fun NavGraphBuilder.onboardingSetupDestinations(navController: NavHostController) { + composable(Routes.ONBOARDING_OUTLOOK_IMAP) { entry -> + val onboarding = onboardingViewModel(navController, entry) + OutlookImapNoticeScreen( + onBack = navController::popBackStack, + onAccountAdded = { id -> onboarding.completeAdd(navController, id) }, + ) + } + 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) }, + ) + } +} + /** * The tail of onboarding: the "add another?" prompt and the optional contacts + battery opt-in steps. * Split out of [onboardingGraph] so each stays a readable length; all share the graph-scoped diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountPickerScreen.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountPickerScreen.kt index ce3dd1c..a96ff39 100644 --- a/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountPickerScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/AccountPickerScreen.kt @@ -57,6 +57,9 @@ import org.libremail.domain.model.MailProvider * * @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. + * @param onPickOutlook when non-null, tapping Outlook delegates here instead of launching auth + * inline — onboarding uses it to first show the pre-auth IMAP-enablement notice (#411). Left null + * for the standalone "Add account" entry, which keeps launching Microsoft sign-in directly. */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -65,6 +68,7 @@ fun AccountPickerScreen( onAccountAdded: (String) -> Unit, onPickProvider: (MailProvider) -> Unit, onManualSetup: () -> Unit, + onPickOutlook: (() -> Unit)? = null, viewModel: AccountSetupViewModel = hiltViewModel(), ) { val state by viewModel.state.collectAsStateWithLifecycle() @@ -74,6 +78,19 @@ fun AccountPickerScreen( ActivityResultContracts.StartActivityForResult(), ) { result -> viewModel.onOutlookResult(result.data) } + // Launches Microsoft sign-in in place (the standalone "Add account" behaviour). Onboarding + // overrides the Outlook tap with [onPickOutlook] to interpose the IMAP-enablement notice (#411), + // which then runs this exact same flow from its "Sign in" button. + val launchOutlookInline: () -> Unit = { + viewModel.outlookAuthIntent().fold( + onSuccess = { intent -> + runCatching { outlookLauncher.launch(intent) } + .onFailure { viewModel.onOutlookLaunchFailed(it) } + }, + onFailure = { viewModel.onOutlookLaunchFailed(it) }, + ) + } + LaunchedEffect(state.status, state.addedAccountId) { if (state.status == SetupStatus.DONE) { state.addedAccountId?.let(onAccountAdded) @@ -124,15 +141,7 @@ fun AccountPickerScreen( icon = Icons.Filled.Email, label = stringResource(R.string.account_setup_outlook), enabled = !busy, - onClick = { - viewModel.outlookAuthIntent().fold( - onSuccess = { intent -> - runCatching { outlookLauncher.launch(intent) } - .onFailure { viewModel.onOutlookLaunchFailed(it) } - }, - onFailure = { viewModel.onOutlookLaunchFailed(it) }, - ) - }, + onClick = onPickOutlook ?: launchOutlookInline, ) MailProvider.entries.forEach { provider -> ProviderRow( 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 3c9b1da..3c4f8bd 100644 --- a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt +++ b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt @@ -39,6 +39,14 @@ object Routes { const val ONBOARDING_LICENSE = "onboarding/license" const val ONBOARDING_WELCOME = "onboarding/welcome" const val ONBOARDING_PICKER = "onboarding/picker" + + // Pre-auth interstitial shown when the user picks Outlook during onboarding (#411): new + // personal outlook.com accounts ship with IMAP OFF by default, so OAuth can succeed while the + // IMAP AUTHENTICATE step later fails. This screen asks the user to confirm IMAP is on and links + // Microsoft's help/settings pages before its bottom "Sign in" button continues the existing + // Outlook OAuth flow. Onboarding-only; the standalone "Add account" picker still launches auth + // inline (the reactive complement for that path is #390). + const val ONBOARDING_OUTLOOK_IMAP = "onboarding/outlook_imap" const val ONBOARDING_MANUAL = "onboarding/manual" const val ONBOARDING_ADD_ANOTHER = "onboarding/add_another" diff --git a/app/src/main/kotlin/org/libremail/ui/onboarding/OutlookImapNoticeScreen.kt b/app/src/main/kotlin/org/libremail/ui/onboarding/OutlookImapNoticeScreen.kt new file mode 100644 index 0000000..a287e7f --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/onboarding/OutlookImapNoticeScreen.kt @@ -0,0 +1,213 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.onboarding + +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +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.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.filled.Email +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.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.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import kotlinx.coroutines.launch +import org.libremail.R +import org.libremail.reporting.AppLog +import org.libremail.ui.accountsetup.AccountSetupViewModel +import org.libremail.ui.accountsetup.SetupStatus + +/** + * Pre-auth interstitial shown when the user picks Outlook during onboarding (#411), before the + * Microsoft OAuth browser opens. New personal outlook.com accounts ship with IMAP **OFF** by + * default, so OAuth can succeed while the later IMAP `AUTHENTICATE` step fails — a confusing + * dead-end (the *reactive* complement is #390). This screen asks the user to confirm IMAP is on + * first, links Microsoft's help article and the Outlook IMAP settings page, and only then continues + * the **existing** Outlook OAuth flow from its bottom "Sign in" button. + * + * The sign-in wiring is identical to the picker's Outlook row: build the AppAuth intent via + * [AccountSetupViewModel.outlookAuthIntent], launch it, and hand the redirect back to + * [AccountSetupViewModel.onOutlookResult]; a completed add is reported through [onAccountAdded]. No + * PII is logged — the account address is embedded in the id and never touched here (see + * [AccountSetupViewModel.onOutlookResult] for the add breadcrumb). + * + * @param onBack returns to the vendor picker (e.g. to choose a different provider). + * @param onAccountAdded invoked with the new account id once the Outlook flow completes. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun OutlookImapNoticeScreen( + onBack: () -> Unit, + onAccountAdded: (String) -> Unit, + viewModel: AccountSetupViewModel = hiltViewModel(), +) { + val state by viewModel.state.collectAsStateWithLifecycle() + val snackbarHostState = remember { SnackbarHostState() } + val uriHandler = LocalUriHandler.current + val scope = rememberCoroutineScope() + // Resolved up front so the non-composable failure handler can use it. + val openFailedMessage = stringResource(R.string.app_password_open_failed) + + val outlookLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult(), + ) { result -> viewModel.onOutlookResult(result.data) } + + // One-shot breadcrumb so a debug report shows the user reached the pre-auth IMAP notice (#411). + LaunchedEffect(Unit) { AppLog.i(TAG, "Outlook IMAP notice shown") } + + LaunchedEffect(state.status, state.addedAccountId) { + if (state.status == SetupStatus.DONE) { + state.addedAccountId?.let(onAccountAdded) + } + } + LaunchedEffect(state.error) { + state.error?.let { + snackbarHostState.showSnackbar(it) + viewModel.consumeError() + } + } + + // openUri throws when no browser/handler is installed; surface that as a snackbar, not a crash. + val openUrl: (String) -> Unit = { url -> + runCatching { uriHandler.openUri(url) } + .onFailure { scope.launch { snackbarHostState.showSnackbar(openFailedMessage) } } + } + + val busy = state.status == SetupStatus.CONNECTING + + Scaffold( + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.outlook_imap_title)) }, + navigationIcon = { + IconButton(onClick = onBack) { + Icon( + Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.action_back), + ) + } + }, + ) + }, + snackbarHost = { SnackbarHost(snackbarHostState) }, + ) { padding -> + Box(Modifier.fillMaxSize().padding(padding)) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + ) { + Icon( + Icons.Filled.Email, + contentDescription = null, + modifier = Modifier.size(64.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(20.dp)) + Text( + text = stringResource(R.string.outlook_imap_question), + style = MaterialTheme.typography.headlineSmall, + ) + Spacer(Modifier.height(12.dp)) + Text( + text = stringResource(R.string.outlook_imap_body), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(24.dp)) + OutlinedButton( + onClick = { + AppLog.i(TAG, "Outlook IMAP help article opened") + openUrl(IMAP_HELP_URL) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.outlook_imap_help)) + } + Spacer(Modifier.height(8.dp)) + OutlinedButton( + onClick = { + AppLog.i(TAG, "Outlook IMAP settings page opened") + openUrl(IMAP_SETTINGS_URL) + }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.outlook_imap_settings)) + } + Spacer(Modifier.height(32.dp)) + // Bottom of the visual hierarchy (#411): continues the existing Outlook OAuth flow, + // unchanged. Append any new controls AFTER this so onboarding E2E clicks stay stable. + Button( + onClick = { + AppLog.i(TAG, "Outlook sign-in continued from IMAP notice") + viewModel.outlookAuthIntent().fold( + onSuccess = { intent -> + runCatching { outlookLauncher.launch(intent) } + .onFailure { viewModel.onOutlookLaunchFailed(it) } + }, + onFailure = { viewModel.onOutlookLaunchFailed(it) }, + ) + }, + enabled = !busy, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.outlook_imap_sign_in)) + } + } + if (busy) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.scrim.copy(alpha = 0.32f)), + contentAlignment = Alignment.Center, + ) { + CircularProgressIndicator() + } + } + } + } +} + +private const val TAG = "OutlookImapNotice" + +// Microsoft's canonical "POP, IMAP, and SMTP settings for Outlook.com" support article — the +// authoritative walkthrough for switching IMAP on (verified 2026-07, issue #411). +private const val IMAP_HELP_URL = + "https://support.microsoft.com/en-us/office/pop-imap-and-smtp-settings-for-outlook-com-" + + "d088b986-291d-42b8-9564-9c414e2aa040" + +// Deep link to the Outlook.com POP/IMAP settings page ("Let devices and apps use IMAP"). If +// Microsoft changes the options path this still lands the user in Outlook.com mail settings; the +// help article above is the durable fallback. +private const val IMAP_SETTINGS_URL = "https://outlook.live.com/mail/0/options/mail/accounts/popImap" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f02be49..9212a5b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -205,6 +205,14 @@ Other (IMAP/SMTP) Choose your email provider to get started. + + Turn on IMAP for Outlook + Have you enabled IMAP for your Outlook account? + New Outlook.com accounts often have IMAP switched off. LibreMail needs IMAP turned on to receive your mail — please enable it before signing in. + How to enable IMAP for Outlook + Open Outlook IMAP settings + Sign in + Connect %1$s Unknown email provider. diff --git a/app/src/test/kotlin/org/libremail/ui/accountsetup/AccountPickerScreenJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/accountsetup/AccountPickerScreenJvmTest.kt index 33b1557..6572617 100644 --- a/app/src/test/kotlin/org/libremail/ui/accountsetup/AccountPickerScreenJvmTest.kt +++ b/app/src/test/kotlin/org/libremail/ui/accountsetup/AccountPickerScreenJvmTest.kt @@ -101,6 +101,7 @@ class AccountPickerScreenJvmTest { onAccountAdded: (String) -> Unit = {}, onPickProvider: (MailProvider) -> Unit = {}, onManualSetup: () -> Unit = {}, + onPickOutlook: (() -> Unit)? = null, ) { composeTestRule.setContent { CompositionLocalProvider( @@ -113,6 +114,7 @@ class AccountPickerScreenJvmTest { onAccountAdded = onAccountAdded, onPickProvider = onPickProvider, onManualSetup = onManualSetup, + onPickOutlook = onPickOutlook, viewModel = viewModel, ) } @@ -185,6 +187,20 @@ class AccountPickerScreenJvmTest { verify { vm.onOutlookLaunchFailed(any()) } } + @Test + fun tappingOutlook_whenOnPickOutlookProvided_delegatesInsteadOfLaunching() { + // Onboarding passes onPickOutlook to interpose the IMAP-enablement notice (#411): the tap + // must route there, NOT build/launch the auth intent inline. + val vm = viewModel() + var outlookPicked = false + setContent(vm, onPickOutlook = { outlookPicked = true }) + + composeTestRule.onNodeWithText(string(R.string.account_setup_outlook)).performClick() + + assertTrue("Outlook tap must delegate to onPickOutlook", outlookPicked) + verify(exactly = 0) { vm.outlookAuthIntent() } + } + @Test fun doneStatus_reportsTheNewAccountIdToOnAccountAdded() { var addedId: String? = null diff --git a/app/src/test/kotlin/org/libremail/ui/onboarding/OutlookImapNoticeScreenJvmTest.kt b/app/src/test/kotlin/org/libremail/ui/onboarding/OutlookImapNoticeScreenJvmTest.kt new file mode 100644 index 0000000..c0f286c --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/onboarding/OutlookImapNoticeScreenJvmTest.kt @@ -0,0 +1,246 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.onboarding + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import androidx.activity.compose.LocalActivityResultRegistryOwner +import androidx.activity.result.ActivityResultRegistry +import androidx.activity.result.ActivityResultRegistryOwner +import androidx.activity.result.contract.ActivityResultContract +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.platform.UriHandler +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasProgressBarRangeInfo +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.core.app.ActivityOptionsCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import androidx.lifecycle.compose.LocalLifecycleOwner +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.R +import org.libremail.ui.accountsetup.AccountSetupUiState +import org.libremail.ui.accountsetup.AccountSetupViewModel +import org.libremail.ui.accountsetup.SetupStatus +import org.libremail.ui.theme.LibreMailTheme +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** + * Robolectric JVM Compose test for the pre-auth Outlook IMAP-enablement notice (#411). Drives the + * real [OutlookImapNoticeScreen] on the JVM via the v2 `createComposeRule()` under + * [RobolectricTestRunner] — no emulator — so the screen counts toward JaCoCo's JVM-testable surface. + * The instrumented [OutlookImapNoticeScreenTest] stays as the on-device E2E (with Espresso-Intents it + * owns the real browser/AppAuth launch this JVM rule cannot safely surface). + * + * [AccountSetupViewModel] is mocked (its own logic is covered by `AccountSetupViewModelTest`); a + * recording [UriHandler] captures the help/settings link launches and a no-op + * [ActivityResultRegistry] lets the "Sign in" launcher register/launch without a real activity. + */ +@RunWith(RobolectricTestRunner::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +// A tall display so the whole scrolling column fits Robolectric's small default viewport, keeping +// every control on-screen for assertIsDisplayed / performClick without scrolling. +@Config(sdk = [36], qualifiers = "+w411dp-h2000dp") +class OutlookImapNoticeScreenJvmTest { + + @get:Rule + val composeTestRule = createComposeRule() + + private val context: Context get() = RuntimeEnvironment.getApplication() + + private fun string(resId: Int): String = context.getString(resId) + + /** RESUMED owner so `collectAsStateWithLifecycle` collects the view-model state. */ + private val resumedOwner = object : LifecycleOwner { + private val registry = + LifecycleRegistry.createUnsafe(this).apply { currentState = Lifecycle.State.RESUMED } + override val lifecycle: Lifecycle get() = registry + } + + /** A no-op registry so the "Sign in" launcher can register/launch without a real activity. */ + private val noopRegistryOwner = object : ActivityResultRegistryOwner { + override val activityResultRegistry = object : ActivityResultRegistry() { + override fun onLaunch( + requestCode: Int, + contract: ActivityResultContract, + input: I, + options: ActivityOptionsCompat?, + ) { + // Intentionally never dispatch a result: the launch is a no-op in this JVM test. + } + } + } + + /** Records outbound link launches so the screen's help/settings links are exercised. */ + private val openedUrls = mutableListOf() + private val recordingUriHandler = object : UriHandler { + override fun openUri(uri: String) { + openedUrls.add(uri) + } + } + + /** A handler that always fails, to drive the "couldn't open your browser" snackbar path. */ + private val throwingUriHandler = object : UriHandler { + override fun openUri(uri: String): Unit = throw ActivityNotFoundException("no browser") + } + + private fun viewModel(state: AccountSetupUiState = AccountSetupUiState()): AccountSetupViewModel { + val vm = mockk(relaxed = true) + every { vm.state } returns MutableStateFlow(state) + return vm + } + + private fun setContent( + viewModel: AccountSetupViewModel, + uriHandler: UriHandler = recordingUriHandler, + onBack: () -> Unit = {}, + onAccountAdded: (String) -> Unit = {}, + ) { + composeTestRule.setContent { + CompositionLocalProvider( + LocalLifecycleOwner provides resumedOwner, + LocalActivityResultRegistryOwner provides noopRegistryOwner, + LocalUriHandler provides uriHandler, + ) { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + OutlookImapNoticeScreen( + onBack = onBack, + onAccountAdded = onAccountAdded, + viewModel = viewModel, + ) + } + } + } + } + + @Test + fun rendersImapQuestion_bothLinks_andSignIn() { + setContent(viewModel()) + + composeTestRule.onNodeWithText(string(R.string.outlook_imap_title)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.outlook_imap_question)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.outlook_imap_body)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.outlook_imap_help)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.outlook_imap_settings)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.outlook_imap_sign_in)).assertIsDisplayed() + } + + @Test + fun tappingHelpLink_opensTheMicrosoftArticle() { + setContent(viewModel()) + + composeTestRule.onNodeWithText(string(R.string.outlook_imap_help)).performClick() + + assertTrue( + "Help link must open a support.microsoft.com article", + openedUrls.any { it.startsWith("https://support.microsoft.com/") }, + ) + } + + @Test + fun tappingSettingsLink_opensOutlookSettings() { + setContent(viewModel()) + + composeTestRule.onNodeWithText(string(R.string.outlook_imap_settings)).performClick() + + assertTrue( + "Settings link must open an outlook.live.com settings page", + openedUrls.any { it.startsWith("https://outlook.live.com/") }, + ) + } + + @Test + fun tappingSignIn_buildsTheAuthIntentAndLaunchesIt() { + val vm = viewModel() + // A real (empty) Intent is safe here: the no-op registry never actually starts it. + every { vm.outlookAuthIntent() } returns Result.success(Intent()) + setContent(vm) + + composeTestRule.onNodeWithText(string(R.string.outlook_imap_sign_in)).performClick() + + verify { vm.outlookAuthIntent() } + } + + @Test + fun tappingSignIn_whenTheIntentCannotBeBuilt_reportsLaunchFailure() { + val vm = viewModel() + every { vm.outlookAuthIntent() } returns Result.failure(ActivityNotFoundException("no browser")) + setContent(vm) + + composeTestRule.onNodeWithText(string(R.string.outlook_imap_sign_in)).performClick() + + verify { vm.onOutlookLaunchFailed(any()) } + } + + @Test + fun doneStatus_reportsTheNewAccountIdToOnAccountAdded() { + var addedId: String? = null + setContent( + viewModel(AccountSetupUiState(status = SetupStatus.DONE, addedAccountId = "outlook:me@outlook.com")), + onAccountAdded = { addedId = it }, + ) + + composeTestRule.waitUntil(5_000) { addedId != null } + assertEquals("outlook:me@outlook.com", addedId) + } + + @Test + fun anError_isSurfacedAsASnackbar() { + setContent(viewModel(AccountSetupUiState(error = "Microsoft sign-in failed"))) + + composeTestRule.waitUntil(5_000) { + composeTestRule.onAllNodesWithText("Microsoft sign-in failed").fetchSemanticsNodes().isNotEmpty() + } + composeTestRule.onNodeWithText("Microsoft sign-in failed").assertIsDisplayed() + } + + @Test + fun connectingStatus_showsBusySpinner_andDisablesSignIn() { + setContent(viewModel(AccountSetupUiState(status = SetupStatus.CONNECTING))) + + composeTestRule.onNode(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate)).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.outlook_imap_sign_in)).assertIsNotEnabled() + } + + @Test + fun tappingBack_invokesOnBack() { + var backed = false + setContent(viewModel(), onBack = { backed = true }) + + composeTestRule.onNodeWithContentDescription(string(R.string.action_back)).performClick() + + assertTrue(backed) + } + + @Test + fun openingALink_whenNoBrowser_surfacesTheOpenFailedSnackbar() { + setContent(viewModel(), uriHandler = throwingUriHandler) + + composeTestRule.onNodeWithText(string(R.string.outlook_imap_help)).performClick() + + val openFailed = string(R.string.app_password_open_failed) + composeTestRule.waitUntil(5_000) { + composeTestRule.onAllNodesWithText(openFailed).fetchSemanticsNodes().isNotEmpty() + } + composeTestRule.onNodeWithText(openFailed).assertIsDisplayed() + } +}