fix(onboarding): request notification permission after welcome screen renders

The POST_NOTIFICATIONS request fired from a MainActivity-root
NotificationPermissionEffect whose LaunchedEffect(Unit) ran on the very
first composition, so the system dialog could pop the instant the icon
was tapped — overlapping cold start/splash before any onboarding context
was on screen.

Move the effect into OnboardingWelcomeScreen so it fires once that screen
(the onboarding start destination) is composed and visible, with the
welcome content behind the dialog. Already-onboarded users launch
straight into the mailbox and never compose the welcome screen, so they
are unaffected; the API 33+ gate and the already-granted no-op are
preserved unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-02 16:53:49 -05:00
co-authored by Claude Opus 4.8
parent 31639fdacc
commit 50085d92e1
3 changed files with 34 additions and 29 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)
}
}
@@ -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)
}
}
+6 -4
View File
@@ -62,14 +62,16 @@ Nothing else. Notably **absent** (worth stating in any review exchange):
`notifications/MailNotifier.kt` (no push/cloud-messaging service; lock-screen content
redacted via `VISIBILITY_PRIVATE`); (2) the persistent low-importance status notification
Android requires while the IMAP IDLE foreground service runs (`push/IdleService.kt:120`).
- **Request flow:** once at first launch, API 33+ only (`MainActivity.kt`
`NotificationPermissionEffect`). If denied, `MailNotifier.notifyNewMail` no-ops (permission
- **Request flow:** once, when the onboarding welcome screen appears, API 33+ only
(`ui/onboarding/OnboardingWelcomeScreen.kt` `NotificationPermissionEffect`, scoped to that
screen's composition so the system dialog shows onboarding context instead of racing the
cold-start/splash transition — #151). If denied, `MailNotifier.notifyNewMail` no-ops (permission
re-checked before every post, `MailNotifier.kt:134`); mail sync itself is unaffected.
- **Play-Console justification text (if asked):**
> Notifies the user of newly received email (per-account channels, generated on the device
> from the user's own mailbox — no push service) and shows the persistent status notification
> Android requires for the optional foreground IMAP IDLE connection. Requested once at first
> launch; all app functions except notifications work if declined.
> Android requires for the optional foreground IMAP IDLE connection. Requested once, when the
> onboarding welcome screen appears; all app functions except notifications work if declined.
## `FOREGROUND_SERVICE_DATA_SYNC` (requires the Play Console FGS declaration)