diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6f3b4bd..1440adb 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -197,6 +197,7 @@ dependencies { androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(libs.androidx.espresso.intents) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) } diff --git a/app/src/androidTest/kotlin/org/libremail/ui/onboarding/BatteryOptimizationStepTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/BatteryOptimizationStepTest.kt new file mode 100644 index 0000000..a32112a --- /dev/null +++ b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/BatteryOptimizationStepTest.kt @@ -0,0 +1,186 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.onboarding + +import android.app.Activity +import android.app.Instrumentation +import android.net.Uri +import android.provider.Settings +import androidx.activity.ComponentActivity +import androidx.compose.material3.Text +import androidx.compose.runtime.getValue +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import androidx.test.espresso.intent.Intents +import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction +import androidx.test.espresso.intent.matcher.IntentMatchers.hasData +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import kotlinx.coroutines.runBlocking +import org.hamcrest.CoreMatchers.allOf +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.libremail.R +import org.libremail.data.settings.SettingsRepository +import org.libremail.push.BatteryOptimizationManager +import org.libremail.ui.navigation.Routes +import org.libremail.ui.theme.LibreMailTheme + +/** + * End-to-end test for the onboarding battery opt-in step (#49). It drives the real + * [BatteryOptimizationScreen] + graph-scoped [OnboardingViewModel] through a NavHost that mirrors the + * production "add another? → (optional) battery → inbox" tail (see + * `LibreMailApp.onboardingFinishDestinations`). + * + * Battery status comes from the real [BatteryOptimizationManager]: a fresh emulator is never on the + * battery allowlist, so the step is offered. The "already unrestricted" skip can't be forced from a + * test (there's no API to set it) and is covered by the view-model unit tests; the "already handled" + * skip is exercised here through the real settings DataStore. + */ +@RunWith(AndroidJUnit4::class) +class BatteryOptimizationStepTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + private lateinit var settingsRepository: SettingsRepository + private lateinit var onboarding: OnboardingViewModel + + private fun string(resId: Int) = composeTestRule.activity.getString(resId) + + private fun waitForText(text: String) = composeTestRule.waitUntil(10_000) { + composeTestRule.onAllNodesWithText(text).fetchSemanticsNodes().isNotEmpty() + } + + /** + * Renders the "add another? → battery → inbox" tail with one account already added this session, + * starting on the add-another prompt. [handled] seeds the persisted "prompt handled" flag so the + * skip path can be exercised through the real repository. + */ + private fun setContent(handled: Boolean) { + val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext + settingsRepository = SettingsRepository(context) + runBlocking { settingsRepository.setBatteryPromptHandled(handled) } + onboarding = OnboardingViewModel(BatteryOptimizationManager(context), settingsRepository) + onboarding.onAccountAdded(FIRST_ACCOUNT_ID) + + composeTestRule.setContent { + LibreMailTheme(darkTheme = false, dynamicColor = false) { + val navController = rememberNavController() + NavHost(navController = navController, startDestination = Routes.ONBOARDING_ADD_ANOTHER) { + composable(Routes.ONBOARDING_ADD_ANOTHER) { + val batteryPromptNeeded by onboarding.batteryPromptNeeded.collectAsStateWithLifecycle() + AddAnotherAccountScreen( + onAddAnother = {}, + onFinish = { + if (batteryPromptNeeded == true) { + navController.navigate(Routes.ONBOARDING_BATTERY) + } else { + navController.navigate(Routes.mailboxForAccount(FIRST_ACCOUNT_ID)) { + popUpTo(Routes.ONBOARDING_ADD_ANOTHER) { inclusive = true } + } + } + }, + ) + } + composable(Routes.ONBOARDING_BATTERY) { + BatteryOptimizationScreen( + viewModel = onboarding, + onFinish = { + onboarding.markBatteryPromptHandled() + navController.navigate(Routes.mailboxForAccount(FIRST_ACCOUNT_ID)) { + popUpTo(Routes.ONBOARDING_ADD_ANOTHER) { inclusive = true } + } + }, + ) + } + composable( + route = Routes.MAILBOX_PATTERN, + arguments = listOf( + navArgument(Routes.MAILBOX_ARG_ACCOUNT) { + type = NavType.StringType + defaultValue = "" + }, + ), + ) { + Text(INBOX_MARKER) + } + } + } + } + } + + @Test + fun batteryStep_isOffered_thenNotNow_landsOnInbox() { + setContent(handled = false) + // The decision resolves asynchronously (a DataStore read); wait before driving the finish tap. + composeTestRule.waitUntil(10_000) { onboarding.batteryPromptNeeded.value == true } + + composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_no)).performClick() + + // The battery opt-in step is shown... + waitForText(string(R.string.onboarding_battery_title)) + composeTestRule.onNodeWithText(string(R.string.onboarding_battery_title)).assertIsDisplayed() + + // ...and "Not now" continues to the inbox and records the prompt as handled (so it won't nag). + composeTestRule.onNodeWithText(string(R.string.onboarding_battery_not_now)).performClick() + waitForText(INBOX_MARKER) + composeTestRule.onNodeWithText(INBOX_MARKER).assertIsDisplayed() + composeTestRule.waitUntil(5_000) { runBlocking { settingsRepository.isBatteryPromptHandled() } } + } + + @Test + fun batteryStep_takeMeThere_opensThisAppsSystemSettings() { + setContent(handled = false) + composeTestRule.waitUntil(10_000) { onboarding.batteryPromptNeeded.value == true } + composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_no)).performClick() + waitForText(string(R.string.onboarding_battery_title)) + + val packageName = InstrumentationRegistry.getInstrumentation().targetContext.packageName + Intents.init() + try { + // Stub the match so the real system settings screen never actually launches mid-test. + Intents.intending(hasAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)) + .respondWith(Instrumentation.ActivityResult(Activity.RESULT_OK, null)) + + composeTestRule.onNodeWithText(string(R.string.onboarding_battery_take_me)).performClick() + + // Deep-links to *this app's* details screen (where Battery → Unrestricted lives). + Intents.intended( + allOf( + hasAction(Settings.ACTION_APPLICATION_DETAILS_SETTINGS), + hasData(Uri.fromParts("package", packageName, null)), + ), + ) + } finally { + Intents.release() + } + } + + @Test + fun finish_skipsBatteryStep_whenAlreadyHandled() { + setContent(handled = true) + composeTestRule.waitUntil(10_000) { onboarding.batteryPromptNeeded.value == false } + + composeTestRule.onNodeWithText(string(R.string.onboarding_add_another_no)).performClick() + + // Straight to the inbox — the opt-in step is skipped entirely. + waitForText(INBOX_MARKER) + composeTestRule.onNodeWithText(INBOX_MARKER).assertIsDisplayed() + composeTestRule.onNodeWithText(string(R.string.onboarding_battery_title)).assertDoesNotExist() + } + + private companion object { + const val INBOX_MARKER = "INBOX-REACHED" + const val FIRST_ACCOUNT_ID = "imap:e2e@example.com" + } +} 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 625a328..b35a7bf 100644 --- a/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/ui/onboarding/OnboardingFlowTest.kt @@ -23,7 +23,9 @@ import org.junit.Test import org.junit.runner.RunWith import org.libremail.R import org.libremail.auth.OutlookAuthManager +import org.libremail.data.settings.SettingsRepository import org.libremail.domain.model.Message +import org.libremail.push.BatteryOptimizationManager import org.libremail.ui.FakeAccountRepository import org.libremail.ui.FakeMailRepository import org.libremail.ui.FakeMailSyncer @@ -77,7 +79,14 @@ class OnboardingFlowTest { ) private fun setOnboardingContent(accountRepo: FakeAccountRepository, mailRepo: FakeMailRepository) { - val onboarding = OnboardingViewModel() + // Real collaborators are cheap here: the manager just wraps PowerManager and the repository + // reads the on-device settings DataStore. This test drives its own nav graph (without the + // battery step), so the onboarding view model's battery decision is inert for this flow. + val appContext = composeTestRule.activity.applicationContext + val onboarding = OnboardingViewModel( + BatteryOptimizationManager(appContext), + SettingsRepository(appContext), + ) composeTestRule.setContent { LibreMailTheme(darkTheme = false, dynamicColor = false) { val navController = rememberNavController() diff --git a/app/src/androidTest/kotlin/org/libremail/ui/settings/SettingsScreenTest.kt b/app/src/androidTest/kotlin/org/libremail/ui/settings/SettingsScreenTest.kt index e3cdef4..4bdf2ce 100644 --- a/app/src/androidTest/kotlin/org/libremail/ui/settings/SettingsScreenTest.kt +++ b/app/src/androidTest/kotlin/org/libremail/ui/settings/SettingsScreenTest.kt @@ -15,6 +15,7 @@ import org.junit.runner.RunWith import org.libremail.R import org.libremail.data.settings.FetchPolicy import org.libremail.data.settings.SettingsRepository +import org.libremail.push.BatteryOptimizationManager import org.libremail.ui.FakeAccountRepository import org.libremail.ui.theme.LibreMailTheme @@ -35,7 +36,11 @@ class SettingsScreenTest { val context = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext val settingsRepository = SettingsRepository(context) runBlocking { settingsRepository.setFetchPolicy(FetchPolicy.ALWAYS) } // known starting state - val viewModel = SettingsViewModel(FakeAccountRepository(), settingsRepository) + val viewModel = SettingsViewModel( + FakeAccountRepository(), + settingsRepository, + BatteryOptimizationManager(context), + ) composeTestRule.setContent { LibreMailTheme(darkTheme = false, dynamicColor = false) { diff --git a/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt b/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt index 553ae00..2d5f0d6 100644 --- a/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt +++ b/app/src/main/kotlin/org/libremail/data/settings/SettingsRepository.kt @@ -55,6 +55,7 @@ private object Keys { val ENCRYPT_CACHE = booleanPreferencesKey("encrypt_cache") val INCLUDE_IN_BACKUP = booleanPreferencesKey("include_in_backup") val FETCH_POLICY = stringPreferencesKey("fetch_policy") + val BATTERY_PROMPT_HANDLED = booleanPreferencesKey("battery_prompt_handled") } /** @@ -83,6 +84,18 @@ class SettingsRepository @Inject constructor(@ApplicationContext private val con suspend fun fetchPolicy(): FetchPolicy = settings.first().fetchPolicy + /** + * One-time onboarding flag: whether the user has already seen/acted on the "unrestricted battery" + * opt-in, so onboarding asks at most once (see #49). Not part of [AppSettings] — it is internal + * onboarding state, not a user-facing preference. It rides along in Android Backup (the whole + * settings DataStore is one file); a restore therefore may skip the prompt on a device that isn't + * yet allowlisted — the Advanced Settings battery row is the recovery path there. + */ + suspend fun isBatteryPromptHandled(): Boolean = + context.settingsDataStore.data.map { it[Keys.BATTERY_PROMPT_HANDLED] ?: false }.first() + + suspend fun setBatteryPromptHandled(value: Boolean) = put(Keys.BATTERY_PROMPT_HANDLED, value) + suspend fun setDynamicColor(value: Boolean) = put(Keys.DYNAMIC_COLOR, value) suspend fun setNewMailNotifications(value: Boolean) = put(Keys.NEW_MAIL_NOTIFICATIONS, value) suspend fun setPushIdle(value: Boolean) = put(Keys.PUSH_IDLE, value) diff --git a/app/src/main/kotlin/org/libremail/push/BatteryOptimizationManager.kt b/app/src/main/kotlin/org/libremail/push/BatteryOptimizationManager.kt new file mode 100644 index 0000000..a694b7c --- /dev/null +++ b/app/src/main/kotlin/org/libremail/push/BatteryOptimizationManager.kt @@ -0,0 +1,53 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.push + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.PowerManager +import android.provider.Settings +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Reads and deep-links this app's system battery-optimization state, so the user can move LibreMail + * to "Unrestricted" and keep [IdleService]'s push connection (and periodic sync) from being throttled + * or torn down by Doze. + * + * We deliberately do NOT use the restricted `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` permission or its + * one-tap dialog: Google Play limits that permission to an approved set of use cases (rejection risk, + * see #17). Sending the user to the system screen instead needs no extra permission and is safe on + * both Play and F-Droid (#16). + * + * Note [isIgnoringBatteryOptimizations] reflects the Doze allowlist: it is `true` only for the + * "Unrestricted" setting and `false` for *both* "Optimized" and "Restricted", so it cannot single out + * the (most harmful) "Restricted" state on its own. + */ +@Singleton +class BatteryOptimizationManager @Inject constructor(@ApplicationContext private val context: Context) { + /** + * Whether the platform exposes battery-optimization control. The Doze allowlist has existed since + * API 23, so at our minSdk (29) this is always true; the version check keeps [BatteryPromptDecision] + * honest if the floor ever drops below API 23. + */ + val isSupported: Boolean get() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.M + + /** True when this app is currently exempt from battery optimization ("Unrestricted"). */ + fun isIgnoringBatteryOptimizations(): Boolean { + val powerManager = context.getSystemService(PowerManager::class.java) ?: return false + return powerManager.isIgnoringBatteryOptimizations(context.packageName) + } + + /** + * Intent to this app's system details screen, where the user can open **Battery** and choose + * **Unrestricted**. App-details is targeted (rather than the flat battery-optimization list) + * because it is the only route to the Unrestricted/Optimized/Restricted setting and lands + * directly on LibreMail. Always resolvable since API 9. + */ + fun settingsIntent(): Intent = Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", context.packageName, null), + ) +} diff --git a/app/src/main/kotlin/org/libremail/push/BatteryPromptDecision.kt b/app/src/main/kotlin/org/libremail/push/BatteryPromptDecision.kt new file mode 100644 index 0000000..7d3ab3d --- /dev/null +++ b/app/src/main/kotlin/org/libremail/push/BatteryPromptDecision.kt @@ -0,0 +1,22 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.push + +/** + * Pure decision for whether onboarding should surface the battery opt-in step. Deliberately free of + * Android types so it is exhaustively unit-testable; the live inputs are read by + * [BatteryOptimizationManager] and [org.libremail.data.settings.SettingsRepository]. + */ +object BatteryPromptDecision { + + /** + * Prompt only when all three hold: + * - [supported]: the platform exposes battery-optimization control (the Doze allowlist is API 23+, + * so this is always true at our minSdk — kept explicit so the rule stays correct if the floor + * ever drops). + * - not [alreadyUnrestricted]: an app already exempt from battery optimization gains nothing. + * - not [alreadyHandled]: the user has already seen and acted on (or dismissed) the prompt, so we + * don't nag on a later onboarding run. + */ + fun shouldPrompt(supported: Boolean, alreadyUnrestricted: Boolean, alreadyHandled: Boolean): Boolean = + supported && !alreadyUnrestricted && !alreadyHandled +} diff --git a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt index b3ae46a..6d44535 100644 --- a/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt +++ b/app/src/main/kotlin/org/libremail/ui/LibreMailApp.kt @@ -36,6 +36,7 @@ import org.libremail.ui.drafts.DraftsScreen import org.libremail.ui.mailbox.MailboxScreen import org.libremail.ui.navigation.Routes import org.libremail.ui.onboarding.AddAnotherAccountScreen +import org.libremail.ui.onboarding.BatteryOptimizationScreen import org.libremail.ui.onboarding.OnboardingViewModel import org.libremail.ui.onboarding.OnboardingWelcomeScreen import org.libremail.ui.outbox.OutboxScreen @@ -310,25 +311,54 @@ private fun NavGraphBuilder.onboardingGraph(navController: NavHostController) { onAccountAdded = { id -> onboarding.completeAdd(navController, id) }, ) } - composable(Routes.ONBOARDING_ADD_ANOTHER) { entry -> - val onboarding = onboardingViewModel(navController, entry) - AddAnotherAccountScreen( - onAddAnother = { - // Return to a fresh picker, clearing the prompt and the prior setup screen. - navController.navigate(Routes.ONBOARDING_PICKER) { - popUpTo(Routes.ONBOARDING_PICKER) { inclusive = true } - } - }, - onFinish = { - val firstId = onboarding.firstAddedAccountId - val dest = if (firstId != null) Routes.mailboxForAccount(firstId) else Routes.MAILBOX - navController.navigate(dest) { - // Leave onboarding entirely; the mailbox becomes the new back-stack root. - popUpTo(Routes.ONBOARDING) { inclusive = true } - } - }, - ) - } + onboardingFinishDestinations(navController) + } +} + +/** + * The tail of onboarding: the "add another?" prompt and the optional battery opt-in step. Split out of + * [onboardingGraph] so each stays a readable length; both share the graph-scoped [OnboardingViewModel]. + */ +private fun NavGraphBuilder.onboardingFinishDestinations(navController: NavHostController) { + composable(Routes.ONBOARDING_ADD_ANOTHER) { entry -> + val onboarding = onboardingViewModel(navController, entry) + val batteryPromptNeeded by onboarding.batteryPromptNeeded.collectAsStateWithLifecycle() + AddAnotherAccountScreen( + onAddAnother = { + // Return to a fresh picker, clearing the prompt and the prior setup screen. + navController.navigate(Routes.ONBOARDING_PICKER) { + popUpTo(Routes.ONBOARDING_PICKER) { inclusive = true } + } + }, + onFinish = { + // Offer the battery opt-in as a final step when it's needed; otherwise go straight to + // the inbox. A still-undecided (null) decision fails open to finishing. + if (batteryPromptNeeded == true) { + navController.navigate(Routes.ONBOARDING_BATTERY) + } else { + navController.finishOnboarding(onboarding.firstAddedAccountId) + } + }, + ) + } + composable(Routes.ONBOARDING_BATTERY) { entry -> + val onboarding = onboardingViewModel(navController, entry) + BatteryOptimizationScreen( + viewModel = onboarding, + onFinish = { + onboarding.markBatteryPromptHandled() + navController.finishOnboarding(onboarding.firstAddedAccountId) + }, + ) + } +} + +/** Leaves onboarding for the inbox — the first account added this session, or the unfiltered mailbox. */ +private fun NavController.finishOnboarding(firstAccountId: String?) { + val dest = if (firstAccountId != null) Routes.mailboxForAccount(firstAccountId) else Routes.MAILBOX + navigate(dest) { + // Leave onboarding entirely; the mailbox becomes the new back-stack root. + popUpTo(Routes.ONBOARDING) { inclusive = true } } } diff --git a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt index 1a855c1..169d8a2 100644 --- a/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt +++ b/app/src/main/kotlin/org/libremail/ui/navigation/Routes.kt @@ -35,6 +35,12 @@ object Routes { const val ONBOARDING_PICKER = "onboarding/picker" const val ONBOARDING_MANUAL = "onboarding/manual" const val ONBOARDING_ADD_ANOTHER = "onboarding/add_another" + + // Optional final onboarding step: invites the user to allow unrestricted background/battery usage + // so push (IMAP IDLE) and periodic sync aren't throttled by Doze (#49). Shown only when the app + // isn't already exempt and the user hasn't handled it before; otherwise onboarding skips straight + // to the inbox. + const val ONBOARDING_BATTERY = "onboarding/battery" const val ONBOARDING_APP_PASSWORD_PATTERN = "onboarding/app_password/{$APP_PASSWORD_ARG_PROVIDER}" fun onboardingAppPassword(provider: String) = "onboarding/app_password/${Uri.encode(provider)}" diff --git a/app/src/main/kotlin/org/libremail/ui/onboarding/BatteryOptimizationScreen.kt b/app/src/main/kotlin/org/libremail/ui/onboarding/BatteryOptimizationScreen.kt new file mode 100644 index 0000000..0314c5a --- /dev/null +++ b/app/src/main/kotlin/org/libremail/ui/onboarding/BatteryOptimizationScreen.kt @@ -0,0 +1,123 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.onboarding + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.Notifications +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +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.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.libremail.R + +/** + * Final onboarding step (shown only when needed, see [OnboardingViewModel.batteryPromptNeeded]): + * invites the user to allow unrestricted background/battery usage so push and periodic sync aren't + * throttled by Doze. **Take me there** deep-links to the system screen (no restricted permission); + * **Not now** skips. Either way [onFinish] proceeds to the inbox. On returning from Settings the + * status is re-read and, if the app is now unrestricted, the screen reflects that with a "done" state. + * + * @param viewModel the graph-scoped onboarding view model (holds live battery status + the flag). + * @param onFinish leaves onboarding for the inbox; the caller also marks the prompt handled. + */ +@Composable +fun BatteryOptimizationScreen(viewModel: OnboardingViewModel, onFinish: () -> Unit) { + val unrestricted by viewModel.batteryUnrestricted.collectAsStateWithLifecycle() + val context = LocalContext.current + + // Re-check on every resume so returning from the system settings screen reflects the new state. + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.refreshBatteryStatus() } + + Scaffold { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = if (unrestricted) Icons.Filled.CheckCircle else Icons.Filled.Notifications, + contentDescription = null, + modifier = Modifier.size(72.dp), + tint = MaterialTheme.colorScheme.primary, + ) + Spacer(Modifier.height(24.dp)) + Text( + text = stringResource( + if (unrestricted) R.string.onboarding_battery_done_title else R.string.onboarding_battery_title, + ), + style = MaterialTheme.typography.headlineSmall, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(8.dp)) + Text( + text = stringResource( + if (unrestricted) R.string.onboarding_battery_done_body else R.string.onboarding_battery_body, + ), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(32.dp)) + + if (unrestricted) { + Button( + onClick = onFinish, + modifier = Modifier.fillMaxWidth().widthIn(max = 360.dp), + ) { + Text(stringResource(R.string.onboarding_battery_continue)) + } + } else { + Text( + text = stringResource(R.string.onboarding_battery_guidance), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Spacer(Modifier.height(24.dp)) + Button( + onClick = { + // Mark handled up front: the user is leaving for Settings and might not return + // to this screen. Launching app-details always resolves; guard defensively. + viewModel.markBatteryPromptHandled() + runCatching { context.startActivity(viewModel.batterySettingsIntent()) } + }, + modifier = Modifier.fillMaxWidth().widthIn(max = 360.dp), + ) { + Text(stringResource(R.string.onboarding_battery_take_me)) + } + Spacer(Modifier.height(12.dp)) + OutlinedButton( + onClick = onFinish, + modifier = Modifier.fillMaxWidth().widthIn(max = 360.dp), + ) { + Text(stringResource(R.string.onboarding_battery_not_now)) + } + } + } + } +} diff --git a/app/src/main/kotlin/org/libremail/ui/onboarding/OnboardingViewModel.kt b/app/src/main/kotlin/org/libremail/ui/onboarding/OnboardingViewModel.kt index f9d5659..bc8706b 100644 --- a/app/src/main/kotlin/org/libremail/ui/onboarding/OnboardingViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/onboarding/OnboardingViewModel.kt @@ -1,28 +1,81 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.ui.onboarding +import android.content.Intent import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import org.libremail.data.settings.SettingsRepository +import org.libremail.push.BatteryOptimizationManager +import org.libremail.push.BatteryPromptDecision import javax.inject.Inject /** * Session state for one run of the onboarding flow. Scoped to the onboarding nav graph's back-stack * entry, so it is created when onboarding starts and cleared when the graph is popped. * - * Its only job is to remember the **first** account added during this session: when the user - * finishes ("No, don't add another"), onboarding opens that account's inbox (see #30). + * It remembers the **first** account added this session (so finishing opens that account's inbox, see + * #30) and decides whether to show the "unrestricted battery" opt-in step before finishing (see #49). */ @HiltViewModel -class OnboardingViewModel @Inject constructor() : ViewModel() { +class OnboardingViewModel @Inject constructor( + private val batteryOptimizationManager: BatteryOptimizationManager, + private val settingsRepository: SettingsRepository, +) : ViewModel() { /** The id of the first account added this session, or null if none has been added yet. */ var firstAddedAccountId: String? = null private set + private val _batteryPromptNeeded = MutableStateFlow(null) + + /** + * Whether onboarding should show the battery opt-in step before finishing. `null` until decided; + * the finish path treats `null` as "skip", so a slow read can never block the end of onboarding. + * Decided once at graph start — neither input can change until the user reaches the step itself. + */ + val batteryPromptNeeded: StateFlow = _batteryPromptNeeded.asStateFlow() + + private val _batteryUnrestricted = MutableStateFlow(false) + + /** Live "Unrestricted" status, re-read when the opt-in step resumes (e.g. back from Settings). */ + val batteryUnrestricted: StateFlow = _batteryUnrestricted.asStateFlow() + + init { + viewModelScope.launch { + val unrestricted = batteryOptimizationManager.isIgnoringBatteryOptimizations() + _batteryUnrestricted.value = unrestricted + _batteryPromptNeeded.value = BatteryPromptDecision.shouldPrompt( + supported = batteryOptimizationManager.isSupported, + alreadyUnrestricted = unrestricted, + alreadyHandled = settingsRepository.isBatteryPromptHandled(), + ) + } + } + /** Records a freshly added account. Only the first one sticks — later adds don't overwrite it. */ fun onAccountAdded(accountId: String) { if (firstAddedAccountId == null) { firstAddedAccountId = accountId } } + + /** Intent to the system screen where the user flips this app to "Unrestricted". */ + fun batterySettingsIntent(): Intent = batteryOptimizationManager.settingsIntent() + + /** Re-read the live battery status; call when the opt-in step resumes. */ + fun refreshBatteryStatus() { + viewModelScope.launch { + _batteryUnrestricted.value = batteryOptimizationManager.isIgnoringBatteryOptimizations() + } + } + + /** Record that the user has seen/acted on the battery opt-in so onboarding won't ask again. */ + fun markBatteryPromptHandled() { + viewModelScope.launch { settingsRepository.setBatteryPromptHandled(true) } + } } diff --git a/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt index c015376..4ea2d77 100644 --- a/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/org/libremail/ui/settings/SettingsScreen.kt @@ -27,9 +27,12 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.rotate +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.libremail.R import org.libremail.data.settings.FetchPolicy @@ -48,6 +51,11 @@ fun SettingsScreen( val settings by viewModel.settings.collectAsStateWithLifecycle() val accounts by viewModel.accounts.collectAsStateWithLifecycle() val advancedExpanded by viewModel.advancedExpanded.collectAsStateWithLifecycle() + val batteryUnrestricted by viewModel.batteryUnrestricted.collectAsStateWithLifecycle() + val context = LocalContext.current + + // Re-read the battery status on resume so it reflects any change made in system settings. + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { viewModel.refreshBatteryStatus() } Scaffold( topBar = { TopAppBar(title = { Text(stringResource(R.string.title_settings)) }) }, @@ -139,6 +147,17 @@ fun SettingsScreen( checked = settings.pushIdle, onCheckedChange = viewModel::setPushIdle, ) + ClickRow( + title = stringResource(R.string.settings_adv_battery), + subtitle = stringResource( + if (batteryUnrestricted) { + R.string.settings_adv_battery_unrestricted + } else { + R.string.settings_adv_battery_optimized + }, + ), + onClick = { runCatching { context.startActivity(viewModel.batterySettingsIntent()) } }, + ) SwitchRow( title = stringResource(R.string.settings_adv_starttls), checked = settings.allowStartTls, diff --git a/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt b/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt index 52da6d4..fcd9883 100644 --- a/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/org/libremail/ui/settings/SettingsViewModel.kt @@ -1,6 +1,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later package org.libremail.ui.settings +import android.content.Intent import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel @@ -16,12 +17,14 @@ import org.libremail.data.settings.FetchPolicy import org.libremail.data.settings.SettingsRepository import org.libremail.domain.model.Account import org.libremail.domain.repository.AccountRepository +import org.libremail.push.BatteryOptimizationManager import javax.inject.Inject @HiltViewModel class SettingsViewModel @Inject constructor( private val accountRepository: AccountRepository, private val settingsRepository: SettingsRepository, + private val batteryOptimizationManager: BatteryOptimizationManager, ) : ViewModel() { val accounts: StateFlow> = accountRepository.observeAccounts() @@ -33,8 +36,21 @@ class SettingsViewModel @Inject constructor( private val _advancedExpanded = MutableStateFlow(false) val advancedExpanded: StateFlow = _advancedExpanded.asStateFlow() + private val _batteryUnrestricted = MutableStateFlow(batteryOptimizationManager.isIgnoringBatteryOptimizations()) + + /** Whether this app is exempt from battery optimization ("Unrestricted"). */ + val batteryUnrestricted: StateFlow = _batteryUnrestricted.asStateFlow() + fun toggleAdvanced() = _advancedExpanded.update { !it } + /** Re-read the battery-optimization status; call when the screen resumes (e.g. back from Settings). */ + fun refreshBatteryStatus() { + _batteryUnrestricted.value = batteryOptimizationManager.isIgnoringBatteryOptimizations() + } + + /** Intent to the system screen where the user flips this app to "Unrestricted". */ + fun batterySettingsIntent(): Intent = batteryOptimizationManager.settingsIntent() + fun setDynamicColor(value: Boolean) = update { settingsRepository.setDynamicColor(value) } fun setNewMailNotifications(value: Boolean) = update { settingsRepository.setNewMailNotifications(value) } fun setPushIdle(value: Boolean) = update { settingsRepository.setPushIdle(value) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1b239fb..da43daa 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -123,6 +123,16 @@ Add another account No, take me to my inbox + + Get mail the instant it arrives + LibreMail keeps a lightweight connection open to deliver mail the moment it lands. Android\'s battery optimization can pause or close that connection, so messages may show up late — or only when you open the app. + On the next screen, open Battery and choose “Unrestricted”. + Take me there + Not now + You\'re all set + Background usage is unrestricted — new mail will arrive instantly. + Continue to inbox + Outlook or Hotmail Other (IMAP/SMTP) @@ -225,6 +235,9 @@ Load remote images by default Encrypt local cache Encrypt cached mail stored on this device, using a key held in the Android Keystore. Applies the next time you open LibreMail. + Background battery usage + Unrestricted — instant background mail is allowed. + Optimized by Android — new mail may be delayed. Tap to allow unrestricted background usage. Diagnostics diff --git a/app/src/test/kotlin/org/libremail/push/BatteryPromptDecisionTest.kt b/app/src/test/kotlin/org/libremail/push/BatteryPromptDecisionTest.kt new file mode 100644 index 0000000..d6dde8d --- /dev/null +++ b/app/src/test/kotlin/org/libremail/push/BatteryPromptDecisionTest.kt @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.push + +import org.junit.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class BatteryPromptDecisionTest { + + @Test + fun `prompts when supported, not already unrestricted, and not yet handled`() { + assertTrue( + BatteryPromptDecision.shouldPrompt( + supported = true, + alreadyUnrestricted = false, + alreadyHandled = false, + ), + ) + } + + @Test + fun `never prompts when the app is already unrestricted`() { + assertFalse( + BatteryPromptDecision.shouldPrompt(supported = true, alreadyUnrestricted = true, alreadyHandled = false), + ) + } + + @Test + fun `never prompts once the user has handled it`() { + assertFalse( + BatteryPromptDecision.shouldPrompt(supported = true, alreadyUnrestricted = false, alreadyHandled = true), + ) + } + + @Test + fun `never prompts on an unsupported platform, regardless of the other inputs`() { + for (unrestricted in listOf(false, true)) { + for (handled in listOf(false, true)) { + assertFalse( + BatteryPromptDecision.shouldPrompt( + supported = false, + alreadyUnrestricted = unrestricted, + alreadyHandled = handled, + ), + "supported=false must never prompt (unrestricted=$unrestricted, handled=$handled)", + ) + } + } + } +} diff --git a/app/src/test/kotlin/org/libremail/ui/onboarding/OnboardingViewModelTest.kt b/app/src/test/kotlin/org/libremail/ui/onboarding/OnboardingViewModelTest.kt new file mode 100644 index 0000000..4000aa5 --- /dev/null +++ b/app/src/test/kotlin/org/libremail/ui/onboarding/OnboardingViewModelTest.kt @@ -0,0 +1,106 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +package org.libremail.ui.onboarding + +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.libremail.data.settings.SettingsRepository +import org.libremail.push.BatteryOptimizationManager +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class OnboardingViewModelTest { + + private val testDispatcher = UnconfinedTestDispatcher() + + @Before + fun setUp() = Dispatchers.setMain(testDispatcher) + + @After + fun tearDown() = Dispatchers.resetMain() + + private fun batteryManager(supported: Boolean = true, unrestricted: Boolean = false) = + mockk { + every { isSupported } returns supported + every { isIgnoringBatteryOptimizations() } returns unrestricted + } + + private fun settingsRepository(handled: Boolean = false) = mockk { + coEvery { isBatteryPromptHandled() } returns handled + } + + @Test + fun `battery prompt is needed when not unrestricted and not handled`() = runTest(testDispatcher) { + val vm = OnboardingViewModel(batteryManager(unrestricted = false), settingsRepository(handled = false)) + + assertEquals(true, vm.batteryPromptNeeded.value) + assertFalse(vm.batteryUnrestricted.value) + } + + @Test + fun `battery prompt is skipped when the app is already unrestricted`() = runTest(testDispatcher) { + val vm = OnboardingViewModel(batteryManager(unrestricted = true), settingsRepository(handled = false)) + + assertEquals(false, vm.batteryPromptNeeded.value) + assertTrue(vm.batteryUnrestricted.value) + } + + @Test + fun `battery prompt is skipped once it has been handled`() = runTest(testDispatcher) { + val vm = OnboardingViewModel(batteryManager(unrestricted = false), settingsRepository(handled = true)) + + assertEquals(false, vm.batteryPromptNeeded.value) + } + + @Test + fun `only the first added account id is remembered`() = runTest(testDispatcher) { + val vm = OnboardingViewModel(batteryManager(), settingsRepository()) + + assertNull(vm.firstAddedAccountId) + vm.onAccountAdded("imap:first@example.com") + vm.onAccountAdded("imap:second@example.com") + + assertEquals("imap:first@example.com", vm.firstAddedAccountId) + } + + @Test + fun `marking the prompt handled persists the flag`() = runTest(testDispatcher) { + val repo = settingsRepository() + coEvery { repo.setBatteryPromptHandled(any()) } just Runs + val vm = OnboardingViewModel(batteryManager(), repo) + + vm.markBatteryPromptHandled() + + coVerify { repo.setBatteryPromptHandled(true) } + } + + @Test + fun `refresh re-reads the live battery status`() = runTest(testDispatcher) { + val manager = mockk { + every { isSupported } returns true + // First read (init) is not-unrestricted; the second (refresh) reflects the user's change. + every { isIgnoringBatteryOptimizations() } returnsMany listOf(false, true) + } + val vm = OnboardingViewModel(manager, settingsRepository()) + assertFalse(vm.batteryUnrestricted.value) + + vm.refreshBatteryStatus() + + assertTrue(vm.batteryUnrestricted.value) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5caf166..f3b1b1e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -86,6 +86,7 @@ mockk = { group = "io.mockk", name = "mockk", version.ref = "mockk" } greenmail = { group = "com.icegreen", name = "greenmail", version.ref = "greenmail" } 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" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }