feat(auth): detect "IMAP disabled" AUTHENTICATE failures and prompt the user to enable IMAP #430
+42
@@ -7,6 +7,7 @@ import android.content.Intent
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onAllNodesWithText
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
import androidx.compose.ui.test.performScrollTo
|
import androidx.compose.ui.test.performScrollTo
|
||||||
@@ -15,8 +16,11 @@ import androidx.lifecycle.SavedStateHandle
|
|||||||
import androidx.test.espresso.intent.Intents
|
import androidx.test.espresso.intent.Intents
|
||||||
import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction
|
import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction
|
||||||
import androidx.test.espresso.intent.matcher.IntentMatchers.hasData
|
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.ext.junit.runners.AndroidJUnit4
|
||||||
|
import jakarta.mail.AuthenticationFailedException
|
||||||
import org.hamcrest.CoreMatchers.allOf
|
import org.hamcrest.CoreMatchers.allOf
|
||||||
|
import org.hamcrest.CoreMatchers.equalTo
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
@@ -108,4 +112,42 @@ class AppPasswordSetupScreenTest {
|
|||||||
Intents.release()
|
Intents.release()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the connection test fails specifically because IMAP is disabled (Gmail's "not enabled for
|
||||||
|
* IMAP use"), the screen surfaces the actionable "turn on IMAP" dialog instead of a generic error,
|
||||||
|
* and its help link opens the provider's enable-IMAP page (#390). Driving the failure through a
|
||||||
|
* [FakeAccountRepository] exercises the real classification + dialog wiring end to end on device.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun imapDisabledFailure_showsThePrompt_andHelpLinkOpensTheProviderPage() {
|
||||||
|
setContent(
|
||||||
|
repository = FakeAccountRepository(
|
||||||
|
result = Result.failure(
|
||||||
|
AuthenticationFailedException("Your account is not enabled for IMAP use"),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText(string(R.string.app_password_email)).performTextInput("me@gmail.com")
|
||||||
|
composeTestRule.onNodeWithText(string(R.string.app_password_field)).performTextInput("app-pw-1234")
|
||||||
|
composeTestRule.onNodeWithText(string(R.string.app_password_test_and_add)).performScrollTo().performClick()
|
||||||
|
|
||||||
|
composeTestRule.waitUntil(5_000) {
|
||||||
|
composeTestRule.onAllNodesWithText(string(R.string.imap_disabled_title)).fetchSemanticsNodes().isNotEmpty()
|
||||||
|
}
|
||||||
|
composeTestRule.onNodeWithText(string(R.string.imap_disabled_message, provider.displayName)).assertIsDisplayed()
|
||||||
|
|
||||||
|
Intents.init()
|
||||||
|
try {
|
||||||
|
Intents.intending(hasAction(Intent.ACTION_VIEW))
|
||||||
|
.respondWith(Instrumentation.ActivityResult(Activity.RESULT_CANCELED, null))
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText(string(R.string.imap_disabled_help)).performClick()
|
||||||
|
|
||||||
|
Intents.intended(allOf(hasAction(Intent.ACTION_VIEW), hasData(hasHost(equalTo("support.google.com")))))
|
||||||
|
} finally {
|
||||||
|
Intents.release()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
package org.libremail.mail
|
||||||
|
|
||||||
|
import jakarta.mail.AuthenticationFailedException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classifies an IMAP authentication failure that surfaced from [ImapClient.openConnectedStore]'s
|
||||||
|
* `store.connect` (i.e. an `AUTHENTICATE`/`LOGIN` rejection) into the one distinction the account-setup
|
||||||
|
* UI cares about: **"IMAP access is switched off for this account"** vs any other auth failure (a wrong
|
||||||
|
* password, an expired/invalid token, a network error, …). Only the former can be fixed by the user
|
||||||
|
* flipping a provider-side toggle, so only it earns the actionable "turn on IMAP" prompt (issue #390);
|
||||||
|
* everything else keeps the existing generic error.
|
||||||
|
*
|
||||||
|
* The heuristic is deliberately conservative — misclassifying a wrong password as "IMAP disabled" would
|
||||||
|
* send the user down a dead end — so it fires on only two well-motivated signals (see [isImapDisabled]).
|
||||||
|
*/
|
||||||
|
object ImapAuthError {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when [error] (or anything in its cause chain) indicates the account's IMAP access is
|
||||||
|
* **disabled/not enabled**, rather than a wrong credential or other failure. Two signals, checked
|
||||||
|
* in order:
|
||||||
|
*
|
||||||
|
* 1. **Explicit server text.** The failure message names IMAP as disabled/not-enabled/turned-off —
|
||||||
|
* e.g. Gmail's `Your account is not enabled for IMAP use`, or a `IMAP access is disabled`
|
||||||
|
* variant. This is provider-independent and works regardless of [usedOAuth].
|
||||||
|
* 2. **OAuth inference.** When the connection authenticated with a **freshly obtained XOAUTH2
|
||||||
|
* token** ([usedOAuth] true) and the server still raised an [AuthenticationFailedException], the
|
||||||
|
* token itself was accepted at consent/exchange time, so an `AUTHENTICATE` rejection here almost
|
||||||
|
* always means IMAP is off for the mailbox — not a bad token. This is the Outlook case that
|
||||||
|
* motivated #390: `outlook.office365.com` returns only a generic `AUTHENTICATE failed` with no
|
||||||
|
* distinctive text, so the text check in (1) cannot catch it.
|
||||||
|
*
|
||||||
|
* A password/app-password failure ([usedOAuth] false) with no IMAP-disabled text — the ordinary
|
||||||
|
* wrong-password case — is deliberately **not** matched, so it is never misclassified.
|
||||||
|
*/
|
||||||
|
fun isImapDisabled(error: Throwable, usedOAuth: Boolean): Boolean {
|
||||||
|
val chain = causeChain(error)
|
||||||
|
if (chain.any { it.message?.let(::mentionsImapDisabled) == true }) return true
|
||||||
|
return usedOAuth && chain.any { it is AuthenticationFailedException }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The exception and its transitive causes, in order, guarding against a self-referential or cyclic
|
||||||
|
* cause chain (identity-based visited check — [Throwable] does not override `equals`).
|
||||||
|
*/
|
||||||
|
private fun causeChain(error: Throwable): List<Throwable> {
|
||||||
|
val seen = mutableListOf<Throwable>()
|
||||||
|
var current: Throwable? = error
|
||||||
|
while (current != null && seen.none { it === current }) {
|
||||||
|
seen.add(current)
|
||||||
|
current = current.cause
|
||||||
|
}
|
||||||
|
return seen
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when [message] names IMAP as disabled/not-enabled, in any of the shapes providers use. */
|
||||||
|
private fun mentionsImapDisabled(message: String): Boolean {
|
||||||
|
val text = message.lowercase()
|
||||||
|
return DISABLED_PATTERNS.any { it.containsMatchIn(text) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Case-insensitive shapes of "IMAP is off" seen across providers (patterns run against a lowercased
|
||||||
|
* message). Kept broad enough to catch wording variants, but each anchors on both "imap" and an
|
||||||
|
* explicit off/disabled/not-enabled word so an ordinary "AUTHENTICATE failed" / "Invalid
|
||||||
|
* credentials" wrong-password message never matches.
|
||||||
|
*/
|
||||||
|
private val DISABLED_PATTERNS = listOf(
|
||||||
|
// "IMAP access is disabled", "IMAP is disabled", "IMAP access is not enabled", "IMAP ... turned off".
|
||||||
|
Regex("""imap[^\n]{0,40}?(disabled|not enabled|turned off|is off)"""),
|
||||||
|
// Reversed order, e.g. Gmail's "Your account is not enabled for IMAP use".
|
||||||
|
Regex("""(disabled|not enabled|turned off)[^\n]{0,20}?for imap"""),
|
||||||
|
// "please enable IMAP", Gmail's "enable your account for IMAP access".
|
||||||
|
Regex("""enable[^\n]{0,30}?imap"""),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -169,6 +169,12 @@ fun AccountPickerScreen(
|
|||||||
CircularProgressIndicator()
|
CircularProgressIndicator()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Outlook OAuth can succeed while the IMAP AUTHENTICATE step is rejected because IMAP is
|
||||||
|
// off for the mailbox (#390); show the actionable "turn on IMAP" prompt instead of a
|
||||||
|
// generic auth-failure snackbar.
|
||||||
|
state.imapDisabledPrompt?.let { prompt ->
|
||||||
|
ImapDisabledDialog(prompt = prompt, onDismiss = viewModel::dismissImapDisabledPrompt)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import org.libremail.auth.OutlookAuthManager
|
|||||||
import org.libremail.domain.model.Account
|
import org.libremail.domain.model.Account
|
||||||
import org.libremail.domain.repository.AccountRepository
|
import org.libremail.domain.repository.AccountRepository
|
||||||
import org.libremail.reporting.AppLog
|
import org.libremail.reporting.AppLog
|
||||||
|
import org.libremail.reporting.accountLogRef
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
/** Stage of an account-setup attempt, shared by the Outlook and manual flows. */
|
/** Stage of an account-setup attempt, shared by the Outlook and manual flows. */
|
||||||
@@ -25,6 +26,11 @@ data class AccountSetupUiState(
|
|||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
/** Set alongside [SetupStatus.DONE]: the id of the account that was just added. */
|
/** Set alongside [SetupStatus.DONE]: the id of the account that was just added. */
|
||||||
val addedAccountId: String? = null,
|
val addedAccountId: String? = null,
|
||||||
|
/**
|
||||||
|
* Set instead of [error] when the failure was specifically an "IMAP is disabled" rejection (#390):
|
||||||
|
* the screen shows the actionable [ImapDisabledDialog] rather than the generic error snackbar.
|
||||||
|
*/
|
||||||
|
val imapDisabledPrompt: ImapDisabledPrompt? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
@@ -62,34 +68,53 @@ class AccountSetupViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.update { it.copy(status = SetupStatus.CONNECTING, error = null) }
|
_state.update { it.copy(status = SetupStatus.CONNECTING, error = null) }
|
||||||
|
// Captured so the failure branch can tell a token/consent failure (account still null) from
|
||||||
|
// a token-OK-but-IMAP-AUTHENTICATE-rejected one (account set), which is the #390 signal.
|
||||||
|
var account: Account? = null
|
||||||
runCatching {
|
runCatching {
|
||||||
val oauth = outlookAuthManager.exchangeToken(data)
|
val oauth = outlookAuthManager.exchangeToken(data)
|
||||||
|
val acct = Account.outlook(oauth.email)
|
||||||
|
account = acct
|
||||||
accountRepository.addOutlookAccount(oauth.email, oauth.accessToken, oauth.authStateJson).getOrThrow()
|
accountRepository.addOutlookAccount(oauth.email, oauth.accessToken, oauth.authStateJson).getOrThrow()
|
||||||
Account.outlook(oauth.email).id
|
acct.id
|
||||||
}.fold(
|
}.fold(
|
||||||
onSuccess = { accountId ->
|
onSuccess = { accountId ->
|
||||||
// No email: the account id embeds it (see accountLogRef) and must never be logged.
|
// No email: the account id embeds it (see accountLogRef) and must never be logged.
|
||||||
AppLog.i(TAG, "Outlook account added")
|
AppLog.i(TAG, "Outlook account added")
|
||||||
_state.update { it.copy(status = SetupStatus.DONE, addedAccountId = accountId) }
|
_state.update { it.copy(status = SetupStatus.DONE, addedAccountId = accountId) }
|
||||||
},
|
},
|
||||||
onFailure = { e ->
|
onFailure = { e -> onOutlookFailure(e, account) },
|
||||||
// AppLog.d's Logcat mirror is stripped from release builds by the -assumenosideeffects
|
|
||||||
// Log.d ProGuard rule (keeps any account address / token detail out of shipped
|
|
||||||
// logcat), but that rule only elides the `Log.d(...)` call inside AppLog.d — the
|
|
||||||
// buffer.record(...) line right after it is untouched, so this breadcrumb still
|
|
||||||
// reaches a submitted report. The throwable's message may carry the account
|
|
||||||
// email/token; AppLog's StackTraceScrubber redacts it before it is recorded.
|
|
||||||
AppLog.d(TAG, "Outlook sign-in failed after redirect", e)
|
|
||||||
_state.update {
|
|
||||||
it.copy(status = SetupStatus.IDLE, error = e.message ?: "Microsoft sign-in failed")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routes a failed Outlook add. When the OAuth token was obtained ([account] set) but the IMAP
|
||||||
|
* `AUTHENTICATE` step was rejected because IMAP is disabled, surfaces the actionable "turn on IMAP"
|
||||||
|
* prompt (#390); otherwise (token/consent failure, or any other error) keeps the generic message.
|
||||||
|
*/
|
||||||
|
private fun onOutlookFailure(e: Throwable, account: Account?) {
|
||||||
|
// AppLog.d's Logcat mirror is stripped from release builds by the -assumenosideeffects
|
||||||
|
// Log.d ProGuard rule (keeps any account address / token detail out of shipped logcat), but
|
||||||
|
// that rule only elides the `Log.d(...)` call inside AppLog.d — the buffer.record(...) line
|
||||||
|
// right after it is untouched, so this breadcrumb still reaches a submitted report. The
|
||||||
|
// throwable's message may carry the account email/token; AppLog's StackTraceScrubber redacts
|
||||||
|
// it before it is recorded.
|
||||||
|
AppLog.d(TAG, "Outlook sign-in failed after redirect", e)
|
||||||
|
val prompt = account?.let { imapDisabledPromptFor(e, it, usedOAuth = true) }
|
||||||
|
if (prompt != null && account != null) {
|
||||||
|
AppLog.i(TAG, "IMAP disabled on Outlook sign-in (${accountLogRef(account.id)}); prompting to enable IMAP")
|
||||||
|
_state.update { it.copy(status = SetupStatus.IDLE, imapDisabledPrompt = prompt, error = null) }
|
||||||
|
} else {
|
||||||
|
_state.update { it.copy(status = SetupStatus.IDLE, error = e.message ?: "Microsoft sign-in failed") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun consumeError() = _state.update { it.copy(error = null) }
|
fun consumeError() = _state.update { it.copy(error = null) }
|
||||||
|
|
||||||
|
/** Clears the "IMAP is disabled" prompt after the user acknowledges it (issue #390). */
|
||||||
|
fun dismissImapDisabledPrompt() = _state.update { it.copy(imapDisabledPrompt = null) }
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val TAG = "AccountSetupVM"
|
const val TAG = "AccountSetupVM"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -214,6 +214,11 @@ fun AppPasswordSetupScreen(
|
|||||||
Text(stringResource(R.string.app_password_test_and_add))
|
Text(stringResource(R.string.app_password_test_and_add))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A wrong app password and "IMAP is turned off" both fail auth, but only the latter is fixed by
|
||||||
|
// a provider toggle (e.g. Gmail's Enable IMAP); surface it as an actionable prompt (#390).
|
||||||
|
form.imapDisabledPrompt?.let { prompt ->
|
||||||
|
ImapDisabledDialog(prompt = prompt, onDismiss = viewModel::dismissImapDisabledPrompt)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import kotlinx.coroutines.flow.update
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import org.libremail.domain.model.MailProvider
|
import org.libremail.domain.model.MailProvider
|
||||||
import org.libremail.domain.repository.AccountRepository
|
import org.libremail.domain.repository.AccountRepository
|
||||||
|
import org.libremail.reporting.AppLog
|
||||||
|
import org.libremail.reporting.accountLogRef
|
||||||
import org.libremail.ui.navigation.Routes
|
import org.libremail.ui.navigation.Routes
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
@@ -23,6 +25,11 @@ data class AppPasswordForm(
|
|||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
/** Set alongside [SetupStatus.DONE]: the id of the account that was just added. */
|
/** Set alongside [SetupStatus.DONE]: the id of the account that was just added. */
|
||||||
val addedAccountId: String? = null,
|
val addedAccountId: String? = null,
|
||||||
|
/**
|
||||||
|
* Set instead of [error] when the failure was specifically an "IMAP is disabled" rejection (#390):
|
||||||
|
* the screen shows the actionable [ImapDisabledDialog] rather than the generic error snackbar.
|
||||||
|
*/
|
||||||
|
val imapDisabledPrompt: ImapDisabledPrompt? = null,
|
||||||
) {
|
) {
|
||||||
val isValid: Boolean get() = email.isNotBlank() && appPassword.isNotBlank()
|
val isValid: Boolean get() = email.isNotBlank() && appPassword.isNotBlank()
|
||||||
}
|
}
|
||||||
@@ -53,6 +60,9 @@ class AppPasswordViewModel @Inject constructor(
|
|||||||
fun toggleAdvanced() = _form.update { it.copy(advancedExpanded = !it.advancedExpanded) }
|
fun toggleAdvanced() = _form.update { it.copy(advancedExpanded = !it.advancedExpanded) }
|
||||||
fun consumeError() = _form.update { it.copy(error = null) }
|
fun consumeError() = _form.update { it.copy(error = null) }
|
||||||
|
|
||||||
|
/** Clears the "IMAP is disabled" prompt after the user acknowledges it (issue #390). */
|
||||||
|
fun dismissImapDisabledPrompt() = _form.update { it.copy(imapDisabledPrompt = null) }
|
||||||
|
|
||||||
fun testAndSave() {
|
fun testAndSave() {
|
||||||
val provider = provider
|
val provider = provider
|
||||||
if (provider == null) {
|
if (provider == null) {
|
||||||
@@ -72,14 +82,28 @@ class AppPasswordViewModel @Inject constructor(
|
|||||||
_form.update { it.copy(status = SetupStatus.DONE, addedAccountId = account.id) }
|
_form.update { it.copy(status = SetupStatus.DONE, addedAccountId = account.id) }
|
||||||
},
|
},
|
||||||
onFailure = { e ->
|
onFailure = { e ->
|
||||||
_form.update {
|
// App passwords are never XOAUTH2, so IMAP-disabled is inferred only from the
|
||||||
it.copy(
|
// server's message text (e.g. Gmail's "not enabled for IMAP use"), not a token
|
||||||
status = SetupStatus.IDLE,
|
// signal (#390).
|
||||||
error = e.message ?: "Could not connect to the server",
|
val prompt = imapDisabledPromptFor(e, account, usedOAuth = false)
|
||||||
|
if (prompt != null) {
|
||||||
|
AppLog.i(
|
||||||
|
TAG,
|
||||||
|
"IMAP disabled on app-password setup (${accountLogRef(account.id)}); " +
|
||||||
|
"prompting to enable IMAP",
|
||||||
)
|
)
|
||||||
|
_form.update { it.copy(status = SetupStatus.IDLE, imapDisabledPrompt = prompt, error = null) }
|
||||||
|
} else {
|
||||||
|
_form.update {
|
||||||
|
it.copy(status = SetupStatus.IDLE, error = e.message ?: "Could not connect to the server")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val TAG = "AppPasswordVM"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
package org.libremail.ui.accountsetup
|
||||||
|
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Email
|
||||||
|
import androidx.compose.material3.AlertDialog
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.TextButton
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import org.libremail.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Actionable dialog shown when adding an account fails specifically because IMAP is switched off for it
|
||||||
|
* (issue #390) — the reactive complement to the pre-auth Outlook notice (#411). Instead of the generic
|
||||||
|
* "authentication failed" snackbar, it explains that sign-in succeeded but the server rejected IMAP,
|
||||||
|
* and (when [ImapDisabledPrompt.helpUrl] is known) links the provider's page for turning IMAP on.
|
||||||
|
*
|
||||||
|
* A shared surface reused by every setup screen (the Outlook picker, the app-password form, manual
|
||||||
|
* setup) so the message and link stay consistent wherever the failure occurs. The help link is placed
|
||||||
|
* as the dialog's *dismiss* action (leading) and "Got it" as the *confirm* action (trailing), so the
|
||||||
|
* confirm button — the last control — stays the stable click target for E2E.
|
||||||
|
*
|
||||||
|
* @param prompt the provider brand + help URL to render.
|
||||||
|
* @param onDismiss clears the prompt from state (also fired on outside-tap / back).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ImapDisabledDialog(prompt: ImapDisabledPrompt, onDismiss: () -> Unit) {
|
||||||
|
val uriHandler = LocalUriHandler.current
|
||||||
|
val helpUrl = prompt.helpUrl
|
||||||
|
val message = prompt.brand?.let { stringResource(R.string.imap_disabled_message, it) }
|
||||||
|
?: stringResource(R.string.imap_disabled_message_generic)
|
||||||
|
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
icon = { Icon(Icons.Filled.Email, contentDescription = null) },
|
||||||
|
title = { Text(stringResource(R.string.imap_disabled_title)) },
|
||||||
|
text = { Text(message) },
|
||||||
|
// The help link is only offered for providers with a known enable-IMAP page. openUri throws
|
||||||
|
// when no browser is installed; swallow it (a rare case) — the primary "Got it" action, which
|
||||||
|
// dismisses so the user can fix the toggle and retry, always works. Opening the link
|
||||||
|
// deliberately leaves the dialog up so it is still there when the user returns from the browser.
|
||||||
|
dismissButton = {
|
||||||
|
if (helpUrl != null) {
|
||||||
|
TextButton(onClick = { runCatching { uriHandler.openUri(helpUrl) } }) {
|
||||||
|
Text(stringResource(R.string.imap_disabled_help))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.imap_disabled_dismiss)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
package org.libremail.ui.accountsetup
|
||||||
|
|
||||||
|
import org.libremail.domain.model.Account
|
||||||
|
import org.libremail.domain.model.MailProvider
|
||||||
|
import org.libremail.mail.ImapAuthError
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The data an "IMAP is turned off" prompt needs (issue #390): the provider [brand] for the message
|
||||||
|
* copy (null when the host maps to no known brand — a generic message is shown), and the [helpUrl] to
|
||||||
|
* the provider's enable-IMAP page (null when we have no provider-specific page, so no help link is
|
||||||
|
* offered). Built by [imapDisabledPromptFor] from a classified auth failure.
|
||||||
|
*/
|
||||||
|
data class ImapDisabledPrompt(val brand: String?, val helpUrl: String?)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the actionable prompt to show when [error] — the failure from adding [account] — is an
|
||||||
|
* "IMAP access is disabled" auth rejection ([ImapAuthError.isImapDisabled]), or null for any other
|
||||||
|
* failure (which keeps the existing generic error). [usedOAuth] is true only on the Outlook XOAUTH2
|
||||||
|
* path, where a valid-token `AUTHENTICATE` rejection is itself the IMAP-disabled signal.
|
||||||
|
*
|
||||||
|
* Provider awareness comes from [account]: [MailProvider.brandFor] names the brand (Outlook by auth
|
||||||
|
* type/host, the app-password vendors by IMAP host — so even a manually-configured Gmail account is
|
||||||
|
* recognised), and [enableImapHelpUrl] maps it to the provider's help page.
|
||||||
|
*/
|
||||||
|
fun imapDisabledPromptFor(error: Throwable, account: Account, usedOAuth: Boolean): ImapDisabledPrompt? {
|
||||||
|
if (!ImapAuthError.isImapDisabled(error, usedOAuth)) return null
|
||||||
|
val brand = MailProvider.brandFor(account)
|
||||||
|
return ImapDisabledPrompt(brand = brand, helpUrl = enableImapHelpUrl(brand))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The provider's own "how to turn IMAP on" help page for a recognised [brand], or null for brands with
|
||||||
|
* no user-facing IMAP toggle we can link (Yahoo/iCloud/AOL gate access through app passwords, not an
|
||||||
|
* IMAP switch) or an unrecognised host — those get the generic message with no link.
|
||||||
|
*/
|
||||||
|
private fun enableImapHelpUrl(brand: String?): String? = when (brand) {
|
||||||
|
MailProvider.OUTLOOK_BRAND -> OUTLOOK_IMAP_HELP_URL
|
||||||
|
MailProvider.GMAIL.displayName -> GMAIL_IMAP_HELP_URL
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Microsoft's canonical "POP, IMAP, and SMTP settings for Outlook.com" support article — the
|
||||||
|
// authoritative walkthrough for the "Let devices and apps use POP/IMAP" toggle (verified 2026-07,
|
||||||
|
// issue #390). Matches the pre-auth notice in #411 so both directions point users to one page.
|
||||||
|
private const val OUTLOOK_IMAP_HELP_URL =
|
||||||
|
"https://support.microsoft.com/en-us/office/pop-imap-and-smtp-settings-for-outlook-com-" +
|
||||||
|
"d088b986-291d-42b8-9564-9c414e2aa040"
|
||||||
|
|
||||||
|
// Google's "Check Gmail through other email platforms" article, which documents Settings -> See all
|
||||||
|
// settings -> Forwarding and POP/IMAP -> Enable IMAP (verified 2026-07, issue #390).
|
||||||
|
private const val GMAIL_IMAP_HELP_URL = "https://support.google.com/mail/answer/7126229"
|
||||||
@@ -185,6 +185,11 @@ fun ManualSetupScreen(
|
|||||||
Text(stringResource(R.string.manual_test_and_add))
|
Text(stringResource(R.string.manual_test_and_add))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// "IMAP is turned off" is one of the auth failures a manual account can hit; when the host maps
|
||||||
|
// to a known brand it even links that provider's enable-IMAP page (#390).
|
||||||
|
form.imapDisabledPrompt?.let { prompt ->
|
||||||
|
ImapDisabledDialog(prompt = prompt, onDismiss = viewModel::dismissImapDisabledPrompt)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import org.libremail.domain.model.MailSecurity
|
|||||||
import org.libremail.domain.model.ServerConfig
|
import org.libremail.domain.model.ServerConfig
|
||||||
import org.libremail.domain.model.normalizeEmailForAccountId
|
import org.libremail.domain.model.normalizeEmailForAccountId
|
||||||
import org.libremail.domain.repository.AccountRepository
|
import org.libremail.domain.repository.AccountRepository
|
||||||
|
import org.libremail.reporting.AppLog
|
||||||
|
import org.libremail.reporting.accountLogRef
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
data class ManualSetupForm(
|
data class ManualSetupForm(
|
||||||
@@ -31,6 +33,11 @@ data class ManualSetupForm(
|
|||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
/** Set alongside [SetupStatus.DONE]: the id of the account that was just added. */
|
/** Set alongside [SetupStatus.DONE]: the id of the account that was just added. */
|
||||||
val addedAccountId: String? = null,
|
val addedAccountId: String? = null,
|
||||||
|
/**
|
||||||
|
* Set instead of [error] when the failure was specifically an "IMAP is disabled" rejection (#390):
|
||||||
|
* the screen shows the actionable [ImapDisabledDialog] rather than the generic error snackbar.
|
||||||
|
*/
|
||||||
|
val imapDisabledPrompt: ImapDisabledPrompt? = null,
|
||||||
) {
|
) {
|
||||||
val isValid: Boolean
|
val isValid: Boolean
|
||||||
get() = email.isNotBlank() && password.isNotBlank() && imapHost.isNotBlank() && smtpHost.isNotBlank()
|
get() = email.isNotBlank() && password.isNotBlank() && imapHost.isNotBlank() && smtpHost.isNotBlank()
|
||||||
@@ -41,6 +48,7 @@ class ManualSetupViewModel @Inject constructor(private val accountRepository: Ac
|
|||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val MAX_PORT_DIGITS = 5
|
const val MAX_PORT_DIGITS = 5
|
||||||
|
const val TAG = "ManualSetupVM"
|
||||||
}
|
}
|
||||||
|
|
||||||
private val _form = MutableStateFlow(ManualSetupForm())
|
private val _form = MutableStateFlow(ManualSetupForm())
|
||||||
@@ -59,6 +67,9 @@ class ManualSetupViewModel @Inject constructor(private val accountRepository: Ac
|
|||||||
fun toggleAdvanced() = _form.update { it.copy(advancedExpanded = !it.advancedExpanded) }
|
fun toggleAdvanced() = _form.update { it.copy(advancedExpanded = !it.advancedExpanded) }
|
||||||
fun consumeError() = _form.update { it.copy(error = null) }
|
fun consumeError() = _form.update { it.copy(error = null) }
|
||||||
|
|
||||||
|
/** Clears the "IMAP is disabled" prompt after the user acknowledges it (issue #390). */
|
||||||
|
fun dismissImapDisabledPrompt() = _form.update { it.copy(imapDisabledPrompt = null) }
|
||||||
|
|
||||||
fun testAndSave() {
|
fun testAndSave() {
|
||||||
val f = _form.value
|
val f = _form.value
|
||||||
if (!f.isValid) {
|
if (!f.isValid) {
|
||||||
@@ -79,16 +90,24 @@ class ManualSetupViewModel @Inject constructor(private val accountRepository: Ac
|
|||||||
_form.update { it.copy(status = SetupStatus.CONNECTING, error = null) }
|
_form.update { it.copy(status = SetupStatus.CONNECTING, error = null) }
|
||||||
accountRepository.addImapAccount(account, f.password).fold(
|
accountRepository.addImapAccount(account, f.password).fold(
|
||||||
onSuccess = { _form.update { it.copy(status = SetupStatus.DONE, addedAccountId = account.id) } },
|
onSuccess = { _form.update { it.copy(status = SetupStatus.DONE, addedAccountId = account.id) } },
|
||||||
onFailure = { e ->
|
onFailure = { e -> onAddFailure(e, account) },
|
||||||
_form.update {
|
|
||||||
it.copy(
|
|
||||||
status = SetupStatus.IDLE,
|
|
||||||
error =
|
|
||||||
e.message ?: "Could not connect to the server",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routes a failed manual add: an "IMAP is disabled" rejection (recognised by server text; #390)
|
||||||
|
* gets the actionable prompt — provider-aware when the host maps to a known brand, e.g. a
|
||||||
|
* manually-configured Gmail account still links Gmail's enable-IMAP page — otherwise the generic
|
||||||
|
* error is kept.
|
||||||
|
*/
|
||||||
|
private fun onAddFailure(e: Throwable, account: Account) {
|
||||||
|
val prompt = imapDisabledPromptFor(e, account, usedOAuth = false)
|
||||||
|
if (prompt != null) {
|
||||||
|
AppLog.i(TAG, "IMAP disabled on manual setup (${accountLogRef(account.id)}); prompting to enable IMAP")
|
||||||
|
_form.update { it.copy(status = SetupStatus.IDLE, imapDisabledPrompt = prompt, error = null) }
|
||||||
|
} else {
|
||||||
|
_form.update { it.copy(status = SetupStatus.IDLE, error = e.message ?: "Could not connect to the server") }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -205,6 +205,14 @@
|
|||||||
<string name="account_setup_other">Other (IMAP/SMTP)</string>
|
<string name="account_setup_other">Other (IMAP/SMTP)</string>
|
||||||
<string name="account_setup_subtitle">Choose your email provider to get started.</string>
|
<string name="account_setup_subtitle">Choose your email provider to get started.</string>
|
||||||
|
|
||||||
|
<!-- Reactive "IMAP is turned off" prompt shown when sign-in succeeds but the IMAP AUTHENTICATE
|
||||||
|
step is rejected because IMAP access is disabled on the account (#390) -->
|
||||||
|
<string name="imap_disabled_title">Turn on IMAP to continue</string>
|
||||||
|
<string name="imap_disabled_message">You signed in successfully, but %1$s rejected IMAP access for this account. Make sure IMAP is turned on in your account settings, then try again.</string>
|
||||||
|
<string name="imap_disabled_message_generic">You signed in successfully, but the mail server rejected IMAP access for this account. Make sure IMAP is turned on in your account settings, then try again.</string>
|
||||||
|
<string name="imap_disabled_help">How to turn on IMAP</string>
|
||||||
|
<string name="imap_disabled_dismiss">Got it</string>
|
||||||
|
|
||||||
<!-- Onboarding: pre-auth Outlook IMAP-enablement notice, shown before Microsoft sign-in (#411) -->
|
<!-- 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_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_question">Have you enabled IMAP for your Outlook account?</string>
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
package org.libremail.mail
|
||||||
|
|
||||||
|
import com.icegreen.greenmail.util.GreenMail
|
||||||
|
import com.icegreen.greenmail.util.ServerSetupTest
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockkStatic
|
||||||
|
import io.mockk.unmockkAll
|
||||||
|
import jakarta.mail.AuthenticationFailedException
|
||||||
|
import jakarta.mail.MessagingException
|
||||||
|
import kotlinx.coroutines.test.runTest
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import org.libremail.domain.model.ImapConnectionParams
|
||||||
|
import org.libremail.domain.model.MailSecurity
|
||||||
|
import java.io.IOException
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for the #390 error classifier: representative provider "IMAP is disabled" `AUTHENTICATE`
|
||||||
|
* rejections map to the distinct IMAP-disabled outcome, while ordinary wrong-password / expired-token /
|
||||||
|
* network failures do not (so the actionable "turn on IMAP" prompt never hijacks a real credential
|
||||||
|
* error). The provider-text cases use constructed exceptions carrying the real server wording; the
|
||||||
|
* "wrong password is not misclassified" case is proven end to end against a real GreenMail IMAP server.
|
||||||
|
*/
|
||||||
|
class ImapAuthErrorTest {
|
||||||
|
|
||||||
|
// --- Provider "IMAP disabled" server text (works regardless of auth mechanism) ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Gmail not-enabled-for-IMAP text is classified as disabled`() {
|
||||||
|
// Gmail's verbatim rejection when IMAP is off in its settings.
|
||||||
|
val error = AuthenticationFailedException(
|
||||||
|
"[ALERT] Your account is not enabled for IMAP use. Please visit your Gmail settings page " +
|
||||||
|
"and enable your account for IMAP access. (Failure)",
|
||||||
|
)
|
||||||
|
assertTrue(ImapAuthError.isImapDisabled(error, usedOAuth = false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `explicit IMAP access is disabled text is classified as disabled`() {
|
||||||
|
assertTrue(
|
||||||
|
ImapAuthError.isImapDisabled(
|
||||||
|
AuthenticationFailedException("IMAP access is disabled for your account."),
|
||||||
|
usedOAuth = false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `IMAP is disabled text is classified as disabled`() {
|
||||||
|
assertTrue(ImapAuthError.isImapDisabled(MessagingException("IMAP is disabled"), usedOAuth = false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `IMAP access not enabled text is classified as disabled`() {
|
||||||
|
assertTrue(
|
||||||
|
ImapAuthError.isImapDisabled(
|
||||||
|
AuthenticationFailedException("IMAP access is not enabled for this account"),
|
||||||
|
usedOAuth = false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `please enable IMAP text is classified as disabled`() {
|
||||||
|
assertTrue(
|
||||||
|
ImapAuthError.isImapDisabled(
|
||||||
|
AuthenticationFailedException("Login failed. Please enable IMAP for your mailbox."),
|
||||||
|
usedOAuth = false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `disabled text in a wrapped cause is still classified as disabled`() {
|
||||||
|
val wrapped = RuntimeException("Adding account failed", AuthenticationFailedException("IMAP is disabled"))
|
||||||
|
assertTrue(ImapAuthError.isImapDisabled(wrapped, usedOAuth = false))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Outlook OAuth inference: valid token + generic AUTHENTICATE rejection == IMAP off ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a generic AUTHENTICATE failure on the XOAUTH2 path is inferred as disabled`() {
|
||||||
|
// outlook.office365.com returns only "AUTHENTICATE failed" with no distinctive text; the fresh
|
||||||
|
// token was already accepted at exchange, so this means IMAP is off (issue #390).
|
||||||
|
val error = AuthenticationFailedException("AUTHENTICATE failed")
|
||||||
|
assertTrue(ImapAuthError.isImapDisabled(error, usedOAuth = true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a wrapped AUTHENTICATE failure on the XOAUTH2 path is inferred as disabled`() {
|
||||||
|
val wrapped = MessagingException("connect failed", AuthenticationFailedException("AUTHENTICATE failed"))
|
||||||
|
assertTrue(ImapAuthError.isImapDisabled(wrapped, usedOAuth = true))
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Negatives: ordinary auth/other failures must NOT be misclassified ---
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a generic AUTHENTICATE failure without OAuth is not classified as disabled`() {
|
||||||
|
// The password/app-password path: a plain "AUTHENTICATE failed" is a wrong password, not
|
||||||
|
// IMAP-off — misclassifying it would send the user to the wrong fix.
|
||||||
|
val error = AuthenticationFailedException("AUTHENTICATE failed")
|
||||||
|
assertFalse(ImapAuthError.isImapDisabled(error, usedOAuth = false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an invalid-credentials failure is not classified as disabled`() {
|
||||||
|
assertFalse(
|
||||||
|
ImapAuthError.isImapDisabled(
|
||||||
|
AuthenticationFailedException("[AUTHENTICATIONFAILED] Invalid credentials (Failure)"),
|
||||||
|
usedOAuth = false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
// Even on the OAuth path, an explicit invalid-credentials message is a token problem, not IMAP.
|
||||||
|
assertFalse(
|
||||||
|
ImapAuthError.isImapDisabled(
|
||||||
|
MessagingException("Invalid credentials, please re-authenticate"),
|
||||||
|
usedOAuth = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a token-exchange failure on the OAuth path is not classified as disabled`() {
|
||||||
|
// A token/consent failure is not a jakarta.mail AuthenticationFailedException, so the inference
|
||||||
|
// must not fire even with usedOAuth = true (the fix there is re-auth, not enabling IMAP).
|
||||||
|
assertFalse(ImapAuthError.isImapDisabled(IllegalStateException("Token exchange failed"), usedOAuth = true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a plain AUTHENTICATE-failed string that is not an auth exception is not inferred`() {
|
||||||
|
// The OAuth inference keys on the AuthenticationFailedException type, not the words
|
||||||
|
// "AUTHENTICATE failed" appearing in some other exception's message.
|
||||||
|
assertFalse(ImapAuthError.isImapDisabled(IOException("AUTHENTICATE failed"), usedOAuth = true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a network failure on the OAuth path is not classified as disabled`() {
|
||||||
|
assertFalse(ImapAuthError.isImapDisabled(IOException("Connection reset"), usedOAuth = true))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an IMAP host name in the message alone is not classified as disabled`() {
|
||||||
|
// "imap.gmail.com" contains "imap" but no disabled/off wording, so it must not match.
|
||||||
|
assertFalse(
|
||||||
|
ImapAuthError.isImapDisabled(
|
||||||
|
AuthenticationFailedException("login to imap.gmail.com failed"),
|
||||||
|
usedOAuth = false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- End-to-end: a real GreenMail wrong-password rejection is not misclassified ---
|
||||||
|
|
||||||
|
private lateinit var greenMail: GreenMail
|
||||||
|
private val client = ImapClient(reuseConnections = false)
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
greenMail = GreenMail(ServerSetupTest.SMTP_IMAP)
|
||||||
|
greenMail.start()
|
||||||
|
greenMail.setUser("alice@example.org", "secret")
|
||||||
|
// listFolders breadcrumbs via AppLog -> android.util.Log, a throwing stub under plain JVM tests;
|
||||||
|
// mock it fully-qualified so this file never imports android.util.Log (detekt ForbiddenImport).
|
||||||
|
mockkStatic(android.util.Log::class)
|
||||||
|
every { android.util.Log.d(any(), any()) } returns 0
|
||||||
|
every { android.util.Log.i(any(), any()) } returns 0
|
||||||
|
every { android.util.Log.w(any<String>(), any<String>()) } returns 0
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
greenMail.stop()
|
||||||
|
unmockkAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a real wrong-password IMAP rejection is not classified as disabled`() = runTest {
|
||||||
|
val params = ImapConnectionParams(
|
||||||
|
host = "127.0.0.1",
|
||||||
|
port = greenMail.imap.port,
|
||||||
|
security = MailSecurity.NONE,
|
||||||
|
username = "alice@example.org",
|
||||||
|
secret = "wrong-password",
|
||||||
|
useXoauth2 = false,
|
||||||
|
)
|
||||||
|
val error = runCatching { client.listFolders(params) }.exceptionOrNull()
|
||||||
|
assertTrue(error != null, "GreenMail should reject the wrong password")
|
||||||
|
assertFalse(ImapAuthError.isImapDisabled(error, usedOAuth = false))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -229,4 +229,17 @@ class AccountPickerScreenJvmTest {
|
|||||||
|
|
||||||
composeTestRule.onNode(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate)).assertIsDisplayed()
|
composeTestRule.onNode(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate)).assertIsDisplayed()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun imapDisabledPrompt_showsTheDialog_andGotItDismisses() {
|
||||||
|
// Outlook OAuth can succeed while IMAP is off, surfacing the actionable dialog (#390) instead
|
||||||
|
// of the generic error snackbar; "Got it" clears the prompt via the view-model.
|
||||||
|
val vm = viewModel(AccountSetupUiState(imapDisabledPrompt = ImapDisabledPrompt("Outlook", helpUrl = null)))
|
||||||
|
setContent(vm)
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText(string(R.string.imap_disabled_title)).assertIsDisplayed()
|
||||||
|
composeTestRule.onNodeWithText(string(R.string.imap_disabled_dismiss)).performClick()
|
||||||
|
|
||||||
|
verify { vm.dismissImapDisabledPrompt() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import io.mockk.every
|
|||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
import io.mockk.mockkStatic
|
import io.mockk.mockkStatic
|
||||||
import io.mockk.unmockkAll
|
import io.mockk.unmockkAll
|
||||||
|
import jakarta.mail.AuthenticationFailedException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
@@ -191,6 +192,54 @@ class AccountSetupViewModelTest {
|
|||||||
assertEquals("IMAP verification failed", vm.state.value.error)
|
assertEquals("IMAP verification failed", vm.state.value.error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a token-OK but IMAP-disabled AUTHENTICATE failure surfaces the enable-IMAP prompt`() = runTest(dispatcher) {
|
||||||
|
// OAuth succeeds, then the IMAP AUTHENTICATE step is rejected (IMAP off for the mailbox):
|
||||||
|
// the actionable prompt replaces the generic error, and no account is marked added (#390).
|
||||||
|
val manager = mockk<OutlookAuthManager>(relaxed = true)
|
||||||
|
coEvery { manager.exchangeToken(any()) } returns
|
||||||
|
OAuthResult(email = "me@outlook.com", accessToken = "tok", authStateJson = "{}")
|
||||||
|
val accounts = mockk<AccountRepository>(relaxed = true)
|
||||||
|
coEvery { accounts.addOutlookAccount(any(), any(), any()) } returns
|
||||||
|
Result.failure(AuthenticationFailedException("AUTHENTICATE failed"))
|
||||||
|
val vm = viewModel(outlookAuthManager = manager, accountRepository = accounts)
|
||||||
|
|
||||||
|
vm.onOutlookResult(mockk<Intent>(relaxed = true))
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val prompt = vm.state.value.imapDisabledPrompt
|
||||||
|
assertEquals("Outlook", prompt?.brand)
|
||||||
|
assertTrue(prompt?.helpUrl?.startsWith("https://support.microsoft.com/") == true)
|
||||||
|
assertEquals(SetupStatus.IDLE, vm.state.value.status)
|
||||||
|
assertNull(vm.state.value.error)
|
||||||
|
assertNull(vm.state.value.addedAccountId)
|
||||||
|
// PII-free breadcrumb: the account is referenced by its hashed log ref, never the email.
|
||||||
|
val disabledLine = logBuffer.snapshot().single { it.message.contains("IMAP disabled on Outlook sign-in") }
|
||||||
|
assertEquals('I', disabledLine.level)
|
||||||
|
assertFalse(disabledLine.message.contains("@"), disabledLine.message)
|
||||||
|
|
||||||
|
vm.dismissImapDisabledPrompt()
|
||||||
|
assertNull(vm.state.value.imapDisabledPrompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a wrong-password style AUTHENTICATE failure keeps the generic error, not the prompt`() = runTest(dispatcher) {
|
||||||
|
// A plain OAuth-path failure with no IMAP-disabled signal stays a generic error.
|
||||||
|
val manager = mockk<OutlookAuthManager>(relaxed = true)
|
||||||
|
coEvery { manager.exchangeToken(any()) } returns
|
||||||
|
OAuthResult(email = "me@outlook.com", accessToken = "tok", authStateJson = "{}")
|
||||||
|
val accounts = mockk<AccountRepository>(relaxed = true)
|
||||||
|
coEvery { accounts.addOutlookAccount(any(), any(), any()) } returns
|
||||||
|
Result.failure(RuntimeException("Could not reach the server"))
|
||||||
|
val vm = viewModel(outlookAuthManager = manager, accountRepository = accounts)
|
||||||
|
|
||||||
|
vm.onOutlookResult(mockk<Intent>(relaxed = true))
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertNull(vm.state.value.imapDisabledPrompt)
|
||||||
|
assertEquals("Could not reach the server", vm.state.value.error)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `a failure whose message carries the account email is scrubbed before it reaches the buffer`() =
|
fun `a failure whose message carries the account email is scrubbed before it reaches the buffer`() =
|
||||||
runTest(dispatcher) {
|
runTest(dispatcher) {
|
||||||
|
|||||||
@@ -232,4 +232,21 @@ class AppPasswordSetupScreenJvmTest {
|
|||||||
assertTrue(openedUrls.contains(MailProvider.GMAIL.appPasswordHelpUrl))
|
assertTrue(openedUrls.contains(MailProvider.GMAIL.appPasswordHelpUrl))
|
||||||
assertTrue(openedUrls.contains(MailProvider.GMAIL.twoFactorHelpUrl))
|
assertTrue(openedUrls.contains(MailProvider.GMAIL.twoFactorHelpUrl))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun imapDisabledPrompt_showsTheDialog_andGotItDismisses() {
|
||||||
|
// A wrong app password and "IMAP is off" both fail auth; the latter surfaces the actionable
|
||||||
|
// dialog (#390) instead of the generic snackbar, and "Got it" clears it via the view-model.
|
||||||
|
val vm = viewModel(
|
||||||
|
MailProvider.GMAIL,
|
||||||
|
AppPasswordForm(imapDisabledPrompt = ImapDisabledPrompt(brand = "Gmail", helpUrl = null)),
|
||||||
|
)
|
||||||
|
setContent(vm)
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText(string(R.string.imap_disabled_title)).assertIsDisplayed()
|
||||||
|
composeTestRule.onNodeWithText(string(R.string.imap_disabled_message, "Gmail")).assertIsDisplayed()
|
||||||
|
composeTestRule.onNodeWithText(string(R.string.imap_disabled_dismiss)).performClick()
|
||||||
|
|
||||||
|
verify { vm.dismissImapDisabledPrompt() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,12 @@ package org.libremail.ui.accountsetup
|
|||||||
import androidx.lifecycle.SavedStateHandle
|
import androidx.lifecycle.SavedStateHandle
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.coVerify
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
|
import io.mockk.mockkStatic
|
||||||
import io.mockk.slot
|
import io.mockk.slot
|
||||||
|
import io.mockk.unmockkAll
|
||||||
|
import jakarta.mail.AuthenticationFailedException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
@@ -30,10 +34,22 @@ class AppPasswordViewModelTest {
|
|||||||
private val testDispatcher = UnconfinedTestDispatcher()
|
private val testDispatcher = UnconfinedTestDispatcher()
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() = Dispatchers.setMain(testDispatcher)
|
fun setUp() {
|
||||||
|
Dispatchers.setMain(testDispatcher)
|
||||||
|
// The IMAP-disabled branch breadcrumbs via AppLog -> android.util.Log, a throwing stub under
|
||||||
|
// plain JVM tests. Mock it fully-qualified so this file never imports android.util.Log (which
|
||||||
|
// detekt's ForbiddenImport guard would flag).
|
||||||
|
mockkStatic(android.util.Log::class)
|
||||||
|
every { android.util.Log.i(any(), any()) } returns 0
|
||||||
|
every { android.util.Log.d(any(), any()) } returns 0
|
||||||
|
every { android.util.Log.w(any<String>(), any<String>()) } returns 0
|
||||||
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
fun tearDown() = Dispatchers.resetMain()
|
fun tearDown() {
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
unmockkAll()
|
||||||
|
}
|
||||||
|
|
||||||
private fun viewModel(repo: AccountRepository, providerKey: String = MailProvider.GMAIL.key) = AppPasswordViewModel(
|
private fun viewModel(repo: AccountRepository, providerKey: String = MailProvider.GMAIL.key) = AppPasswordViewModel(
|
||||||
SavedStateHandle(mapOf(Routes.APP_PASSWORD_ARG_PROVIDER to providerKey)),
|
SavedStateHandle(mapOf(Routes.APP_PASSWORD_ARG_PROVIDER to providerKey)),
|
||||||
@@ -101,6 +117,29 @@ class AppPasswordViewModelTest {
|
|||||||
assertNull(vm.form.value.addedAccountId)
|
assertNull(vm.form.value.addedAccountId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an IMAP-disabled failure surfaces the enable-IMAP prompt instead of a generic error`() =
|
||||||
|
runTest(testDispatcher) {
|
||||||
|
val repo = mockk<AccountRepository>()
|
||||||
|
coEvery { repo.addImapAccount(any(), any()) } returns
|
||||||
|
Result.failure(AuthenticationFailedException("Your account is not enabled for IMAP use"))
|
||||||
|
val vm = viewModel(repo) // Gmail preset
|
||||||
|
|
||||||
|
vm.onEmail("user@gmail.com")
|
||||||
|
vm.onAppPassword("app-pass")
|
||||||
|
vm.testAndSave()
|
||||||
|
|
||||||
|
val prompt = vm.form.value.imapDisabledPrompt
|
||||||
|
assertEquals(MailProvider.GMAIL.displayName, prompt?.brand)
|
||||||
|
assertTrue(prompt?.helpUrl?.startsWith("https://support.google.com/") == true)
|
||||||
|
assertNull(vm.form.value.error)
|
||||||
|
assertEquals(SetupStatus.IDLE, vm.form.value.status)
|
||||||
|
assertNull(vm.form.value.addedAccountId)
|
||||||
|
|
||||||
|
vm.dismissImapDisabledPrompt()
|
||||||
|
assertNull(vm.form.value.imapDisabledPrompt)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `an unknown provider key surfaces an error and never contacts the server`() = runTest(testDispatcher) {
|
fun `an unknown provider key surfaces an error and never contacts the server`() = runTest(testDispatcher) {
|
||||||
val repo = mockk<AccountRepository>(relaxed = true)
|
val repo = mockk<AccountRepository>(relaxed = true)
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
package org.libremail.ui.accountsetup
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
|
import androidx.compose.ui.platform.UriHandler
|
||||||
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.v2.createComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performClick
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
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.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 reactive "IMAP is disabled" dialog (#390). Drives the real
|
||||||
|
* [ImapDisabledDialog] on the JVM via the v2 `createComposeRule()` under [RobolectricTestRunner] — no
|
||||||
|
* emulator — so the dialog counts toward JaCoCo's JVM-testable surface. A recording [UriHandler]
|
||||||
|
* captures the help-link launch instead of opening a real browser; the instrumented
|
||||||
|
* [org.libremail.ui.accountsetup.AppPasswordSetupScreenTest] covers the end-to-end error state on
|
||||||
|
* device.
|
||||||
|
*/
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||||
|
@Config(sdk = [36], qualifiers = "+w411dp-h2000dp")
|
||||||
|
class ImapDisabledDialogJvmTest {
|
||||||
|
|
||||||
|
@get:Rule
|
||||||
|
val composeTestRule = createComposeRule()
|
||||||
|
|
||||||
|
private val context: Context get() = RuntimeEnvironment.getApplication()
|
||||||
|
|
||||||
|
private fun string(resId: Int, vararg args: Any): String = context.getString(resId, *args)
|
||||||
|
|
||||||
|
private val openedUrls = mutableListOf<String>()
|
||||||
|
private val recordingUriHandler = object : UriHandler {
|
||||||
|
override fun openUri(uri: String) {
|
||||||
|
openedUrls.add(uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private val outlookPrompt = ImapDisabledPrompt(brand = "Outlook", helpUrl = "https://support.microsoft.com/imap")
|
||||||
|
|
||||||
|
private fun setContent(prompt: ImapDisabledPrompt, onDismiss: () -> Unit = {}) {
|
||||||
|
composeTestRule.setContent {
|
||||||
|
CompositionLocalProvider(LocalUriHandler provides recordingUriHandler) {
|
||||||
|
LibreMailTheme(darkTheme = false, dynamicColor = false) {
|
||||||
|
ImapDisabledDialog(prompt = prompt, onDismiss = onDismiss)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun brandedPrompt_showsTitle_brandMessage_help_andDismiss() {
|
||||||
|
setContent(outlookPrompt)
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText(string(R.string.imap_disabled_title)).assertIsDisplayed()
|
||||||
|
composeTestRule.onNodeWithText(string(R.string.imap_disabled_message, "Outlook")).assertIsDisplayed()
|
||||||
|
composeTestRule.onNodeWithText(string(R.string.imap_disabled_help)).assertIsDisplayed()
|
||||||
|
composeTestRule.onNodeWithText(string(R.string.imap_disabled_dismiss)).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun genericPrompt_showsGenericMessage_andNoHelpLink() {
|
||||||
|
setContent(ImapDisabledPrompt(brand = null, helpUrl = null))
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText(string(R.string.imap_disabled_message_generic)).assertIsDisplayed()
|
||||||
|
// No provider page to link, so the help button is absent — only "Got it" remains.
|
||||||
|
composeTestRule.onNodeWithText(string(R.string.imap_disabled_help)).assertDoesNotExist()
|
||||||
|
composeTestRule.onNodeWithText(string(R.string.imap_disabled_dismiss)).assertIsDisplayed()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tappingHelp_opensTheProviderPage_withoutDismissing() {
|
||||||
|
var dismissed = false
|
||||||
|
setContent(outlookPrompt, onDismiss = { dismissed = true })
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText(string(R.string.imap_disabled_help)).performClick()
|
||||||
|
|
||||||
|
assertEquals(listOf("https://support.microsoft.com/imap"), openedUrls)
|
||||||
|
// Opening the link leaves the dialog up so it is still there when the user returns.
|
||||||
|
assertFalse("Opening the help link must not dismiss the dialog", dismissed)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun tappingGotIt_dismisses() {
|
||||||
|
var dismissed = false
|
||||||
|
setContent(outlookPrompt, onDismiss = { dismissed = true })
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText(string(R.string.imap_disabled_dismiss)).performClick()
|
||||||
|
|
||||||
|
assertTrue(dismissed)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
package org.libremail.ui.accountsetup
|
||||||
|
|
||||||
|
import jakarta.mail.AuthenticationFailedException
|
||||||
|
import org.junit.Test
|
||||||
|
import org.libremail.domain.model.Account
|
||||||
|
import org.libremail.domain.model.AuthType
|
||||||
|
import org.libremail.domain.model.MailProvider
|
||||||
|
import org.libremail.domain.model.MailSecurity
|
||||||
|
import org.libremail.domain.model.ServerConfig
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for [imapDisabledPromptFor]: an "IMAP disabled" failure is turned into a provider-aware
|
||||||
|
* prompt (brand copy + the provider's enable-IMAP help link when one is known), while any other failure
|
||||||
|
* yields null so the caller keeps its generic error. Classification itself is covered by
|
||||||
|
* [org.libremail.mail.ImapAuthErrorTest]; this pins the brand/URL resolution.
|
||||||
|
*/
|
||||||
|
class ImapDisabledPromptTest {
|
||||||
|
|
||||||
|
private fun manualAccount(host: String) = Account(
|
||||||
|
id = "imap:user@$host",
|
||||||
|
email = "user@$host",
|
||||||
|
displayName = "user@$host",
|
||||||
|
authType = AuthType.PASSWORD_IMAP,
|
||||||
|
imap = ServerConfig(host, 993, MailSecurity.SSL_TLS),
|
||||||
|
smtp = ServerConfig(host, 465, MailSecurity.SSL_TLS),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `outlook OAuth failure resolves to the Outlook brand and Microsoft help link`() {
|
||||||
|
val prompt = imapDisabledPromptFor(
|
||||||
|
AuthenticationFailedException("AUTHENTICATE failed"),
|
||||||
|
Account.outlook("me@outlook.com"),
|
||||||
|
usedOAuth = true,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(MailProvider.OUTLOOK_BRAND, prompt?.brand)
|
||||||
|
assertTrue(prompt?.helpUrl?.startsWith("https://support.microsoft.com/") == true, prompt?.helpUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `gmail app-password failure resolves to the Gmail brand and Google help link`() {
|
||||||
|
val prompt = imapDisabledPromptFor(
|
||||||
|
AuthenticationFailedException("Your account is not enabled for IMAP use"),
|
||||||
|
MailProvider.GMAIL.createAccount("me@gmail.com"),
|
||||||
|
usedOAuth = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(MailProvider.GMAIL.displayName, prompt?.brand)
|
||||||
|
assertTrue(prompt?.helpUrl?.startsWith("https://support.google.com/") == true, prompt?.helpUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a manually-configured Gmail host is still recognised as Gmail`() {
|
||||||
|
// brandFor resolves the brand from the IMAP host, so a manual account on imap.gmail.com gets
|
||||||
|
// the Gmail copy + link even though it was set up through the generic path.
|
||||||
|
val prompt = imapDisabledPromptFor(
|
||||||
|
AuthenticationFailedException("IMAP is disabled"),
|
||||||
|
manualAccount("imap.gmail.com"),
|
||||||
|
usedOAuth = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(MailProvider.GMAIL.displayName, prompt?.brand)
|
||||||
|
assertTrue(prompt?.helpUrl?.startsWith("https://support.google.com/") == true, prompt?.helpUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a Yahoo failure keeps the brand but offers no link`() {
|
||||||
|
// Yahoo/iCloud/AOL gate access via app passwords, not a user-facing IMAP toggle we can deep-link.
|
||||||
|
val prompt = imapDisabledPromptFor(
|
||||||
|
AuthenticationFailedException("IMAP access is disabled"),
|
||||||
|
MailProvider.YAHOO.createAccount("me@yahoo.com"),
|
||||||
|
usedOAuth = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(MailProvider.YAHOO.displayName, prompt?.brand)
|
||||||
|
assertNull(prompt?.helpUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `an unknown host yields a generic prompt with no brand and no link`() {
|
||||||
|
val prompt = imapDisabledPromptFor(
|
||||||
|
AuthenticationFailedException("IMAP access is disabled"),
|
||||||
|
manualAccount("mail.example.org"),
|
||||||
|
usedOAuth = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNull(prompt?.brand)
|
||||||
|
assertNull(prompt?.helpUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `a wrong-password failure yields no prompt`() {
|
||||||
|
val prompt = imapDisabledPromptFor(
|
||||||
|
AuthenticationFailedException("Invalid credentials"),
|
||||||
|
MailProvider.GMAIL.createAccount("me@gmail.com"),
|
||||||
|
usedOAuth = false,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNull(prompt)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `ImapDisabledPrompt value semantics`() {
|
||||||
|
val prompt = ImapDisabledPrompt(brand = "Outlook", helpUrl = "https://example.test/imap")
|
||||||
|
assertEquals(prompt, prompt.copy())
|
||||||
|
assertEquals(prompt.hashCode(), prompt.copy().hashCode())
|
||||||
|
assertTrue(prompt.toString().contains("Outlook"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -212,4 +212,18 @@ class ManualSetupScreenJvmTest {
|
|||||||
composeTestRule.onAllNodesWithText("STARTTLS")[1].performClick()
|
composeTestRule.onAllNodesWithText("STARTTLS")[1].performClick()
|
||||||
verify { vm.onSmtpSecurity(MailSecurity.STARTTLS) }
|
verify { vm.onSmtpSecurity(MailSecurity.STARTTLS) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun imapDisabledPrompt_showsTheGenericDialog_andGotItDismisses() {
|
||||||
|
// A manual account on an unknown host still gets the actionable "IMAP is off" dialog (#390),
|
||||||
|
// with the generic (brandless) message and no help link; "Got it" clears it via the view-model.
|
||||||
|
val vm = viewModel(ManualSetupForm(imapDisabledPrompt = ImapDisabledPrompt(brand = null, helpUrl = null)))
|
||||||
|
setContent(vm)
|
||||||
|
|
||||||
|
composeTestRule.onNodeWithText(string(R.string.imap_disabled_title)).assertIsDisplayed()
|
||||||
|
composeTestRule.onNodeWithText(string(R.string.imap_disabled_message_generic)).assertIsDisplayed()
|
||||||
|
composeTestRule.onNodeWithText(string(R.string.imap_disabled_dismiss)).performClick()
|
||||||
|
|
||||||
|
verify { vm.dismissImapDisabledPrompt() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,12 @@ package org.libremail.ui.accountsetup
|
|||||||
|
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
import io.mockk.coVerify
|
import io.mockk.coVerify
|
||||||
|
import io.mockk.every
|
||||||
import io.mockk.mockk
|
import io.mockk.mockk
|
||||||
|
import io.mockk.mockkStatic
|
||||||
import io.mockk.slot
|
import io.mockk.slot
|
||||||
|
import io.mockk.unmockkAll
|
||||||
|
import jakarta.mail.AuthenticationFailedException
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
@@ -29,10 +33,22 @@ class ManualSetupViewModelTest {
|
|||||||
private val dispatcher = UnconfinedTestDispatcher()
|
private val dispatcher = UnconfinedTestDispatcher()
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
fun setUp() = Dispatchers.setMain(dispatcher)
|
fun setUp() {
|
||||||
|
Dispatchers.setMain(dispatcher)
|
||||||
|
// The IMAP-disabled branch breadcrumbs via AppLog -> android.util.Log, a throwing stub under
|
||||||
|
// plain JVM tests. Mock it fully-qualified so this file never imports android.util.Log (which
|
||||||
|
// detekt's ForbiddenImport guard would flag).
|
||||||
|
mockkStatic(android.util.Log::class)
|
||||||
|
every { android.util.Log.i(any(), any()) } returns 0
|
||||||
|
every { android.util.Log.d(any(), any()) } returns 0
|
||||||
|
every { android.util.Log.w(any<String>(), any<String>()) } returns 0
|
||||||
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
fun tearDown() = Dispatchers.resetMain()
|
fun tearDown() {
|
||||||
|
Dispatchers.resetMain()
|
||||||
|
unmockkAll()
|
||||||
|
}
|
||||||
|
|
||||||
private fun filled(vm: ManualSetupViewModel) {
|
private fun filled(vm: ManualSetupViewModel) {
|
||||||
vm.onEmail(" user@example.org ")
|
vm.onEmail(" user@example.org ")
|
||||||
@@ -184,6 +200,28 @@ class ManualSetupViewModelTest {
|
|||||||
assertNull(vm.form.value.addedAccountId)
|
assertNull(vm.form.value.addedAccountId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `testAndSave surfaces the enable-IMAP prompt for an IMAP-disabled rejection`() = runTest(dispatcher) {
|
||||||
|
val repo = mockk<AccountRepository>()
|
||||||
|
coEvery { repo.addImapAccount(any(), any()) } returns
|
||||||
|
Result.failure(AuthenticationFailedException("IMAP access is disabled for this account"))
|
||||||
|
val vm = ManualSetupViewModel(repo)
|
||||||
|
filled(vm) // imap.example.org: a generic host with no known brand
|
||||||
|
|
||||||
|
vm.testAndSave()
|
||||||
|
|
||||||
|
val prompt = vm.form.value.imapDisabledPrompt
|
||||||
|
assertNotEquals(null, prompt)
|
||||||
|
assertNull(prompt?.brand) // unknown host -> generic message, no help link
|
||||||
|
assertNull(prompt?.helpUrl)
|
||||||
|
assertNull(vm.form.value.error)
|
||||||
|
assertEquals(SetupStatus.IDLE, vm.form.value.status)
|
||||||
|
assertNull(vm.form.value.addedAccountId)
|
||||||
|
|
||||||
|
vm.dismissImapDisabledPrompt()
|
||||||
|
assertNull(vm.form.value.imapDisabledPrompt)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `testAndSave uses a generic message when the failure has none`() = runTest(dispatcher) {
|
fun `testAndSave uses a generic message when the failure has none`() = runTest(dispatcher) {
|
||||||
val repo = mockk<AccountRepository>()
|
val repo = mockk<AccountRepository>()
|
||||||
|
|||||||
Reference in New Issue
Block a user