Merge main into feat-163-default-account

This commit is contained in:
Jason Ross
2026-07-02 19:09:04 -05:00
committed by GitHub
15 changed files with 519 additions and 64 deletions
@@ -1,22 +1,13 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.platform.LocalContext
import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
@@ -84,7 +75,6 @@ class MainActivity : FragmentActivity() {
setContent {
val dynamicColor by settingsRepository.dynamicColor.collectAsStateWithLifecycle(initialValue = true)
LibreMailTheme(dynamicColor = dynamicColor) {
NotificationPermissionEffect()
// Gate the whole app behind the screen-lock when app-lock is enabled. When it is off
// the gate resolves straight to the content, so this is a no-op for most users.
AppLockGateHost {
@@ -106,18 +96,3 @@ class MainActivity : FragmentActivity() {
NotificationIntents.messageId(intent)?.let { pendingOpenMessageId.value = it }
}
}
/** Requests POST_NOTIFICATIONS once on first launch (no-op if already granted). */
@Composable
private fun NotificationPermissionEffect() {
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {}
LaunchedEffect(Unit) {
// POST_NOTIFICATIONS is a runtime permission only on Android 13 (API 33)+. On older
// versions notifications are enabled by default, so there's nothing to request.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return@LaunchedEffect
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
if (!granted) launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
@@ -31,8 +31,8 @@ enum class MailProvider(
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.
* Gmail and iCloud both refuse to issue app passwords until two-factor auth is on, so both
* link their own setup article; Yahoo gates nothing on it.
*/
val twoFactorHelpUrl: String? = null,
private val imapHost: String,
@@ -74,7 +74,12 @@ enum class MailProvider(
ICLOUD(
key = "icloud",
displayName = "iCloud Mail",
appPasswordHelpUrl = "https://appleid.apple.com",
// Apple's own app-specific-password instructions, not just the generic Apple ID sign-in
// page — this article also states the two-factor prerequisite below.
appPasswordHelpUrl = "https://support.apple.com/en-us/102654",
// Like Gmail, Apple won't issue an app-specific password until two-factor authentication
// is on, so link Apple's dedicated setup article too (issue #153).
twoFactorHelpUrl = "https://support.apple.com/en-us/102660",
imapHost = "imap.mail.me.com",
smtpHost = "smtp.mail.me.com",
// Apple documents smtp.mail.me.com:587 with STARTTLS for iCloud Mail.
@@ -151,24 +151,25 @@ fun AppPasswordSetupScreen(
)
Spacer(Modifier.height(12.dp))
// Gmail and iCloud both have a two-factor prerequisite (see
// MailProvider.twoFactorHelpUrl): neither will issue an app password until it's on, so
// point users there first instead of sending them to the app-passwords page's dead
// end. Yahoo has no twoFactorHelpUrl, so this renders nothing for it.
provider.twoFactorHelpUrl?.let { twoFactorHelpUrl ->
OutlinedButton(
onClick = { openUrl(twoFactorHelpUrl) },
modifier = Modifier.fillMaxWidth(),
) {
Text(stringResource(twoFactorHelpLabel(provider)))
}
Spacer(Modifier.height(8.dp))
}
OutlinedButton(
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(
@@ -269,6 +270,16 @@ private fun providerIntro(provider: MailProvider): Int = when (provider) {
MailProvider.ICLOUD -> R.string.app_password_intro_icloud
}
/**
* Button copy for the two-factor prerequisite link, in each provider's own terminology — Google
* calls it "2-Step Verification", Apple "Two-Factor Authentication". Only reached for providers
* that expose [MailProvider.twoFactorHelpUrl]; Yahoo has none, so its branch here is unused.
*/
private fun twoFactorHelpLabel(provider: MailProvider): Int = when (provider) {
MailProvider.ICLOUD -> R.string.app_password_2fa_help_icloud
MailProvider.GMAIL, MailProvider.YAHOO -> R.string.app_password_2fa_help
}
private fun MailSecurity.label(): String = when (this) {
MailSecurity.SSL_TLS -> "SSL/TLS"
MailSecurity.STARTTLS -> "STARTTLS"
@@ -78,7 +78,8 @@ internal const val IMAGE_TAG = "libremail:image"
* feels exactly like the old editor.
*
* The field is a normal Compose text field, so TalkBack, text selection, and large system fonts all
* work as usual; the toolbar buttons carry content descriptions and toggle state for accessibility.
* work as usual; each toolbar button exposes its accessible action label via `onClickLabel` on its
* [Modifier.clickable] (not a `contentDescription`), and still carries toggle state for accessibility.
*
* [resolveFont] maps a CSS font-family stack to a Compose [FontFamily] for display; the default
* resolves nothing, leaving the system font (the model still round-trips the CSS value untouched).
@@ -296,7 +297,7 @@ internal fun applyStyle(
return TextFieldValue(updated.toAnnotatedString(linkColor, resolveFont), value.selection)
}
private fun applyBlock(
internal fun applyBlock(
value: TextFieldValue,
marker: BlockMarker,
linkColor: Color,
@@ -314,7 +315,7 @@ private fun applyBlock(
)
}
private fun applyLink(
internal fun applyLink(
value: TextFieldValue,
url: String,
linkColor: Color,
@@ -1,6 +1,11 @@
// SPDX-License-Identifier: GPL-3.0-or-later
package org.libremail.ui.onboarding
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -18,11 +23,14 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import org.libremail.R
/**
@@ -31,6 +39,11 @@ import org.libremail.R
*/
@Composable
fun OnboardingWelcomeScreen(onAddAccount: () -> Unit) {
// Requested from here, rather than the Activity root, so the system permission dialog appears
// once this screen (with onboarding context behind it) is actually visible instead of racing
// the cold-start/splash transition (#151). Already-onboarded users skip onboarding entirely, so
// this composable — and the request — never runs for them.
NotificationPermissionEffect()
Scaffold { padding ->
WelcomeContent(
onAddAccount = onAddAccount,
@@ -83,3 +96,18 @@ fun WelcomeContent(onAddAccount: () -> Unit, modifier: Modifier = Modifier) {
}
}
}
/** Requests POST_NOTIFICATIONS once on first launch (no-op if already granted). */
@Composable
private fun NotificationPermissionEffect() {
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {}
LaunchedEffect(Unit) {
// POST_NOTIFICATIONS is a runtime permission only on Android 13 (API 33)+. On older
// versions notifications are enabled by default, so there's nothing to request.
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return@LaunchedEffect
val granted = ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
if (!granted) launcher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
@@ -17,6 +17,7 @@ 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.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
@@ -33,8 +34,10 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
@@ -57,6 +60,9 @@ fun ReportReviewScreen(onDone: () -> Unit, viewModel: ReportReviewViewModel = hi
val clipboard = LocalClipboardManager.current
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
// Latches once the post-submit confirmation dialog is acknowledged, so it can't reappear
// during this screen's exit transition (state.submit stays SUCCEEDED after that point).
var reportSubmittedAcknowledged by remember { mutableStateOf(false) }
val savedMessage = stringResource(R.string.report_saved)
val copiedMessage = stringResource(R.string.report_copied)
@@ -79,9 +85,16 @@ fun ReportReviewScreen(onDone: () -> Unit, viewModel: ReportReviewViewModel = hi
}
}
// Once the report has been submitted (deleted by the worker) or discarded, leave the screen.
LaunchedEffect(state.loaded, state.exists) {
if (state.loaded && !state.exists) onDone()
// Leave once the report is gone — but a successful submit deletes the row from
// ReportUploadWorker as soon as the upload finishes, which can race ahead of `state.submit`
// itself reporting SUCCEEDED. So while a submit is in flight or has just succeeded, this
// effect defers to ReportSubmittedDialog below: its acknowledgement calls onDone() instead,
// guaranteeing the confirmation is seen. Plain discard (or a submit that never enqueued
// anything, e.g. FAILED/UNAVAILABLE) is unaffected and still auto-navigates immediately.
LaunchedEffect(state.loaded, state.exists, state.submit) {
val awaitingSubmitOutcome =
state.submit == SubmitUiState.SUBMITTING || state.submit == SubmitUiState.SUCCEEDED
if (state.loaded && !state.exists && !awaitingSubmitOutcome) onDone()
}
Scaffold(
@@ -164,6 +177,17 @@ fun ReportReviewScreen(onDone: () -> Unit, viewModel: ReportReviewViewModel = hi
}
}
}
// Gates leaving the screen on the success path (see the LaunchedEffect above) so the message
// is guaranteed to be seen, not just present for an instant before an auto-navigate.
if (state.submit == SubmitUiState.SUCCEEDED && !reportSubmittedAcknowledged) {
ReportSubmittedDialog(
onAcknowledge = {
reportSubmittedAcknowledged = true
onDone()
},
)
}
}
@Composable
@@ -212,19 +236,38 @@ private fun PayloadBox(payload: String) {
@Composable
private fun SubmitStatusText(state: SubmitUiState) {
if (state == SubmitUiState.IDLE) return
// SUCCEEDED is surfaced via ReportSubmittedDialog instead: a modal is what guarantees the
// message survives the screen's auto-navigate-on-delete race (see ReportReviewScreen above).
val text = when (state) {
SubmitUiState.SUBMITTING -> stringResource(R.string.report_submitting)
SubmitUiState.SUCCEEDED -> stringResource(R.string.report_submitted)
SubmitUiState.FAILED -> stringResource(R.string.report_submit_failed)
SubmitUiState.UNAVAILABLE -> stringResource(R.string.report_submit_unavailable)
SubmitUiState.IDLE -> ""
SubmitUiState.IDLE, SubmitUiState.SUCCEEDED -> return
}
val color = when (state) {
SubmitUiState.SUCCEEDED -> MaterialTheme.colorScheme.primary
SubmitUiState.FAILED, SubmitUiState.UNAVAILABLE -> MaterialTheme.colorScheme.error
else -> MaterialTheme.colorScheme.onSurfaceVariant
val color = if (state == SubmitUiState.FAILED || state == SubmitUiState.UNAVAILABLE) {
MaterialTheme.colorScheme.error
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
Spacer(Modifier.height(8.dp))
Text(text, color = color, style = MaterialTheme.typography.bodyMedium)
}
/**
* Confirmation shown after a successful submission (#161). It — not the deleted-row auto-navigate
* — is what leaves the screen for that path, so the fuller thank-you message is guaranteed to be
* seen even though the report row (and therefore `state.exists`) can flip to gone moments after
* `SubmitUiState.SUCCEEDED`, once `ReportUploadWorker` finishes.
*/
@Composable
private fun ReportSubmittedDialog(onAcknowledge: () -> Unit) {
AlertDialog(
onDismissRequest = onAcknowledge,
text = { Text(stringResource(R.string.report_submitted)) },
confirmButton = {
TextButton(onClick = onAcknowledge) {
Text(stringResource(R.string.report_submitted_dismiss))
}
},
)
}
+5 -1
View File
@@ -184,6 +184,7 @@
<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_2fa_help_icloud">How to turn on Two-Factor Authentication</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>
@@ -339,7 +340,10 @@
<string name="report_copy">Copy</string>
<string name="report_save">Save to file</string>
<string name="report_submitting">Submitting…</string>
<string name="report_submitted">Report sent. Thank you!</string>
<!-- Shown in a confirmation dialog (not just inline) so it can't be missed if the report row
is deleted by ReportUploadWorker moments after the submit succeeds (#161). -->
<string name="report_submitted">Thank you for submitting a problem report! It\'s people like you that help make LibreMail better! Please note that submission of a problem does not guarantee a response or a fix, as this is a volunteer led project.</string>
<string name="report_submitted_dismiss">OK</string>
<string name="report_submit_failed">Couldn\'t send — it will be retried.</string>
<string name="report_submit_unavailable">Online submission isn\'t available in this build. Use Copy or Save to share the report.</string>
<string name="report_copied">Copied to clipboard</string>