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/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) 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" }