From 50085d92e11129382adee1f54a9fc7c6caed66ac Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Thu, 2 Jul 2026 16:53:49 -0500 Subject: [PATCH] fix(onboarding): request notification permission after welcome screen renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../main/kotlin/org/libremail/MainActivity.kt | 25 ----------------- .../ui/onboarding/OnboardingWelcomeScreen.kt | 28 +++++++++++++++++++ docs/play-permissions.md | 10 ++++--- 3 files changed, 34 insertions(+), 29 deletions(-) diff --git a/app/src/main/kotlin/org/libremail/MainActivity.kt b/app/src/main/kotlin/org/libremail/MainActivity.kt index 2f706b7..e480786 100644 --- a/app/src/main/kotlin/org/libremail/MainActivity.kt +++ b/app/src/main/kotlin/org/libremail/MainActivity.kt @@ -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) - } -} diff --git a/app/src/main/kotlin/org/libremail/ui/onboarding/OnboardingWelcomeScreen.kt b/app/src/main/kotlin/org/libremail/ui/onboarding/OnboardingWelcomeScreen.kt index 22e3631..1eb6d5b 100644 --- a/app/src/main/kotlin/org/libremail/ui/onboarding/OnboardingWelcomeScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/onboarding/OnboardingWelcomeScreen.kt @@ -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) + } +} diff --git a/docs/play-permissions.md b/docs/play-permissions.md index 833d2d9..3d806d5 100644 --- a/docs/play-permissions.md +++ b/docs/play-permissions.md @@ -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)