feat(onboarding): link Google 2FA help from Gmail app-password step

Gmail's app-passwords page rejects accounts that don't have 2-Step
Verification enabled, and the setup screen's intro text names that
prerequisite without giving the user any way to act on it. Add a
nullable MailProvider.twoFactorHelpUrl (set only for Gmail, to
Google's "Turn on 2-Step Verification" article) and surface it as a
second outlined button under the existing app-password link, reusing
the same UriHandler + snackbar failure plumbing. Yahoo and iCloud
screens are unchanged.

Closes #98

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 02:14:32 -05:00
co-authored by Claude Fable 5
parent 45edb792a9
commit a547c01ff7
5 changed files with 63 additions and 6 deletions
@@ -53,7 +53,7 @@ class OnboardingFlowTest {
@get:Rule
val composeTestRule = createAndroidComposeRule<ComponentActivity>()
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()
}
}
@@ -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.
@@ -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(
+1
View File
@@ -152,6 +152,7 @@
<string name="app_password_what_is">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.</string>
<string name="app_password_warning">Store this app password carefully — it grants full access to your email. LibreMail keeps it only on this device.</string>
<string name="app_password_open_page">Create an app password for %1$s</string>
<string name="app_password_2fa_help">How to turn on 2-Step Verification</string>
<string name="app_password_open_failed">Couldn\'t open your browser</string>
<string name="app_password_email">Email address</string>
<string name="app_password_field">App password</string>
@@ -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 ")