diff --git a/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt index b35a7bf..580543f 100644 --- a/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt @@ -53,7 +53,7 @@ class OnboardingFlowTest { @get:Rule val composeTestRule = createAndroidComposeRule() - private fun string(resId: Int) = composeTestRule.activity.getString(resId) + private fun string(resId: Int, vararg args: Any) = composeTestRule.activity.getString(resId, *args) // Generous cap for the slow, animation-disabled CI matrix emulators; waitUntil returns as soon // as the text appears, so the happy path is unaffected. @@ -206,6 +206,10 @@ class OnboardingFlowTest { // add" button sit below the fold of this scrolling screen, and a positional click on an // off-screen button is a silent no-op (which is why this passed only on API 37's taller AVD). waitForText(string(R.string.app_password_email)) + // Gmail requires 2-Step Verification before app passwords, so its screen (and only its + // screen — see yahooSetup_hasNoTwoFactorHelpLink) links Google's setup article (issue #98). + composeTestRule.onNodeWithText(string(R.string.app_password_2fa_help)) + .performScrollTo().assertIsDisplayed() composeTestRule.onNodeWithText(string(R.string.app_password_email)) .performScrollTo().performTextInput("e2e@gmail.com") composeTestRule.onNodeWithText(string(R.string.app_password_field)) @@ -221,4 +225,18 @@ class OnboardingFlowTest { waitForText("E2E first message") composeTestRule.onNodeWithText("E2E first message").assertIsDisplayed() } + + @Test + fun yahooSetup_hasNoTwoFactorHelpLink() { + setOnboardingContent(FakeAccountRepository(), FakeMailRepository()) + + composeTestRule.onNodeWithText(string(R.string.onboarding_add_account)).performClick() + waitForText("Yahoo Mail") + composeTestRule.onNodeWithText("Yahoo Mail").performClick() + + // Yahoo's setup screen keeps its app-password link… + waitForText(string(R.string.app_password_open_page, "Yahoo Mail")) + // …but gains no 2-Step Verification link: that prerequisite is Gmail-specific (issue #98). + composeTestRule.onNodeWithText(string(R.string.app_password_2fa_help)).assertDoesNotExist() + } } diff --git a/app/src/main/kotlin/org/libremail/domain/model/MailProvider.kt b/app/src/main/kotlin/org/libremail/domain/model/MailProvider.kt index 927b32e..20f25a8 100644 --- a/app/src/main/kotlin/org/libremail/domain/model/MailProvider.kt +++ b/app/src/main/kotlin/org/libremail/domain/model/MailProvider.kt @@ -29,6 +29,12 @@ enum class MailProvider( val displayName: String, /** The page where the user creates an app password for this provider. */ val appPasswordHelpUrl: String, + /** + * Setup instructions for the provider's two-factor prerequisite, or null when there isn't one. + * Only Gmail refuses to create app passwords until 2-Step Verification is on, so only Gmail + * links its setup article; Yahoo and iCloud gate nothing on it. + */ + val twoFactorHelpUrl: String? = null, private val imapHost: String, private val smtpHost: String, private val smtpPort: Int, @@ -38,6 +44,9 @@ enum class MailProvider( key = "gmail", displayName = "Gmail", appPasswordHelpUrl = "https://myaccount.google.com/apppasswords", + // Google's "Turn on 2-Step Verification" article — the app-passwords page above bounces + // accounts that haven't enabled it yet, so the setup screen offers this as a way out. + twoFactorHelpUrl = "https://support.google.com/accounts/answer/185839", imapHost = "imap.gmail.com", smtpHost = "smtp.gmail.com", // Google documents smtp.gmail.com:587 with STARTTLS as the standard submission endpoint. diff --git a/app/src/main/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreen.kt b/app/src/main/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreen.kt index ff910e9..1a640ba 100644 --- a/app/src/main/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/accountsetup/AppPasswordSetupScreen.kt @@ -78,6 +78,12 @@ fun AppPasswordSetupScreen( val scope = rememberCoroutineScope() // Resolved up front so the failure handler (a non-composable lambda) can use it. val openFailedMessage = stringResource(R.string.app_password_open_failed) + // Shared by every outbound link on this screen: openUri throws if no browser/handler is + // installed, so surface that as an inline snackbar instead of crashing. + val openUrl: (String) -> Unit = { url -> + runCatching { uriHandler.openUri(url) } + .onFailure { scope.launch { snackbarHostState.showSnackbar(openFailedMessage) } } + } LaunchedEffect(form.status, form.addedAccountId) { if (form.status == SetupStatus.DONE) { @@ -146,15 +152,23 @@ fun AppPasswordSetupScreen( Spacer(Modifier.height(12.dp)) OutlinedButton( - onClick = { - // openUri throws if no browser/handler is installed; surface it instead of crashing. - runCatching { uriHandler.openUri(provider.appPasswordHelpUrl) } - .onFailure { scope.launch { snackbarHostState.showSnackbar(openFailedMessage) } } - }, + onClick = { openUrl(provider.appPasswordHelpUrl) }, modifier = Modifier.fillMaxWidth(), ) { Text(stringResource(R.string.app_password_open_page, provider.displayName)) } + // Only Gmail has a two-factor prerequisite (see MailProvider.twoFactorHelpUrl): its + // app-passwords page rejects accounts without 2-Step Verification, so give those users + // a way to set it up instead of a dead end. + provider.twoFactorHelpUrl?.let { twoFactorHelpUrl -> + Spacer(Modifier.height(8.dp)) + OutlinedButton( + onClick = { openUrl(twoFactorHelpUrl) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.app_password_2fa_help)) + } + } Spacer(Modifier.height(20.dp)) OutlinedTextField( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 75f6c41..fcc4e8d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -159,6 +159,7 @@ An app password is a one-off password that lets an app sign in to your account without your main password or a two-factor code. Store this app password carefully — it grants full access to your email. LibreMail keeps it only on this device. Create an app password for %1$s + How to turn on 2-Step Verification Couldn\'t open your browser Email address App password diff --git a/app/src/test/kotlin/org/libremail/domain/model/MailProviderTest.kt b/app/src/test/kotlin/org/libremail/domain/model/MailProviderTest.kt index 52a8519..e8f868e 100644 --- a/app/src/test/kotlin/org/libremail/domain/model/MailProviderTest.kt +++ b/app/src/test/kotlin/org/libremail/domain/model/MailProviderTest.kt @@ -80,6 +80,21 @@ class MailProviderTest { } } + @Test + fun `only gmail links two-factor setup help, over https`() { + // Gmail's app-passwords page rejects accounts without 2-Step Verification, so its setup + // screen must offer the setup article as a way out (issue #98). + val gmailUrl = assertNotNull( + MailProvider.GMAIL.twoFactorHelpUrl, + "gmail must expose a 2-Step Verification help URL", + ) + assertTrue(gmailUrl.startsWith("https://"), "gmail 2FA help URL must be https") + + // Yahoo and iCloud gate nothing on two-factor, so they must not grow the extra link. + assertNull(MailProvider.YAHOO.twoFactorHelpUrl) + assertNull(MailProvider.ICLOUD.twoFactorHelpUrl) + } + @Test fun `createAccount trims the email and derives a stable id and display name`() { val account = MailProvider.GMAIL.createAccount(" User@Gmail.com ")