feat(onboarding): pre-auth Outlook IMAP-enablement screen (help/settings links + Sign In) #426

Merged
JMR-dev merged 2 commits from feat-411-outlook-imap-onboarding into main 2026-07-08 04:28:28 +00:00
9 changed files with 700 additions and 26 deletions
@@ -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())
@@ -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<ComponentActivity>()
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()
}
}
}
@@ -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
@@ -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(
@@ -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"
@@ -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"
+8
View File
@@ -205,6 +205,14 @@
<string name="account_setup_other">Other (IMAP/SMTP)</string>
<string name="account_setup_subtitle">Choose your email provider to get started.</string>
<!-- Onboarding: pre-auth Outlook IMAP-enablement notice, shown before Microsoft sign-in (#411) -->
<string name="outlook_imap_title">Turn on IMAP for Outlook</string>
<string name="outlook_imap_question">Have you enabled IMAP for your Outlook account?</string>
<string name="outlook_imap_body">New Outlook.com accounts often have IMAP switched off. LibreMail needs IMAP turned on to receive your mail — please enable it before signing in.</string>
<string name="outlook_imap_help">How to enable IMAP for Outlook</string>
<string name="outlook_imap_settings">Open Outlook IMAP settings</string>
<string name="outlook_imap_sign_in">Sign in</string>
<!-- App-password guided setup (Gmail/Yahoo/iCloud/AOL) -->
<string name="app_password_title">Connect %1$s</string>
<string name="app_password_unknown_provider">Unknown email provider.</string>
@@ -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
@@ -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 <I, O> onLaunch(
requestCode: Int,
contract: ActivityResultContract<I, O>,
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<String>()
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<AccountSetupViewModel>(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()
}
}