feat(onboarding): pre-auth Outlook IMAP-enablement screen (help/settings links + Sign In) #426
@@ -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())
|
||||
|
||||
+121
@@ -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"
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user