From 50085d92e11129382adee1f54a9fc7c6caed66ac Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Thu, 2 Jul 2026 16:53:49 -0500 Subject: [PATCH 1/2] 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) -- 2.47.3 From 1460a7627df9ff9671306b10346d5b16cce268c1 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Thu, 2 Jul 2026 17:15:14 -0500 Subject: [PATCH 2/2] test(onboarding): grant POST_NOTIFICATIONS in onboarding E2E to fix API 33+ flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moving the notification-permission request into OnboardingWelcomeScreen (#151) means the system POST_NOTIFICATIONS dialog now pops when that screen composes. On API 33+ (where it became a runtime permission) the dialog backgrounded the activity mid-flow, so OnboardingFlowTest failed with "No compose hierarchies found" on API 33/34/35/36/37 while API 29–32 stayed green. Pre-grant the permission via a GrantPermissionRule so the dialog never appears during the flow, guarded for API 33+ (the permission does not exist below TIRAMISU, so grant nothing there to avoid erroring on older devices). Adds the androidx.test:rules dependency that provides the rule. Co-Authored-By: Claude Opus 4.8 --- app/build.gradle.kts | 1 + .../ui/onboarding/OnboardingFlowTest.kt | 16 ++++++++++++++++ gradle/libs.versions.toml | 2 ++ 3 files changed, 19 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4f62af4..5c0e84e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -227,6 +227,7 @@ dependencies { androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.intents) + androidTestImplementation(libs.androidx.test.rules) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(libs.androidx.room.testing) 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 5308266..0b32584 100644 --- a/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt @@ -1,6 +1,8 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.ui.onboarding +import android.Manifest +import android.os.Build import androidx.activity.ComponentActivity import androidx.compose.runtime.remember import androidx.compose.ui.platform.LocalContext @@ -18,6 +20,7 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.rule.GrantPermissionRule import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -54,6 +57,19 @@ class OnboardingFlowTest { @get:Rule val composeTestRule = createAndroidComposeRule() + // OnboardingWelcomeScreen requests POST_NOTIFICATIONS when it first composes (#151). On API 33+ + // that runtime dialog would pop over the test, backgrounding the activity and leaving the compose + // rule with "No compose hierarchies found". Pre-grant it so the flow runs uninterrupted; the + // permission only exists on API 33+, so below TIRAMISU grant nothing (granting a nonexistent + // permission errors on older devices). + @get:Rule + val notificationPermission: GrantPermissionRule = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS) + } else { + GrantPermissionRule.grant() + } + 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 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ac5b242..20e92fc 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -26,6 +26,7 @@ angusMail = "2.0.5" junit = "4.13.2" androidxJunit = "1.2.1" espresso = "3.7.0" +androidxTestRules = "1.7.0" turbine = "1.2.1" mockk = "1.14.11" greenmail = "2.1.9" @@ -110,6 +111,7 @@ greenmail = { group = "com.icegreen", name = "greenmail", version.ref = "greenma androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" } androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso" } androidx-espresso-intents = { group = "androidx.test.espresso", name = "espresso-intents", version.ref = "espresso" } +androidx-test-rules = { group = "androidx.test", name = "rules", version.ref = "androidxTestRules" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } -- 2.47.3