From 59c9f9d27e8fba74c86bf95aa506b2acfa391946 Mon Sep 17 00:00:00 2001 From: Jason Ross Date: Wed, 1 Jul 2026 12:31:54 -0500 Subject: [PATCH] feat(onboarding): opt-in to unrestricted battery/background usage Add a guided, F-Droid-safe onboarding step and an Advanced Settings recovery row that let users move LibreMail to "Unrestricted" battery usage, so IMAP IDLE push (IdleService) and periodic WorkManager sync aren't throttled or killed by Doze. Deep-links to the system app-details screen rather than the restricted REQUEST_IGNORE_BATTERY_OPTIMIZATIONS dialog, so it needs no new permission and is safe on Play (#17) and F-Droid (#16). - BatteryPromptDecision: pure, unit-tested gate (supported && !unrestricted && !handled) - BatteryOptimizationManager: reads isIgnoringBatteryOptimizations, builds the deep-link intent - Onboarding step shown after the first account is added; skipped when already unrestricted or already handled; re-checks status on resume - Advanced Settings row shows current status and re-opens the system screen - battery_prompt_handled flag persisted in the settings DataStore (kept out of AppSettings) - Unit tests for the decision + view model; Espresso E2E for the step Closes #49 Co-Authored-By: Claude Opus 4.8 --- app/build.gradle.kts | 1 + .../onboarding/BatteryOptimizationStepTest.kt | 186 ++++++++++++++++++ .../ui/onboarding/OnboardingFlowTest.kt | 11 +- .../ui/settings/SettingsScreenTest.kt | 7 +- .../data/settings/SettingsRepository.kt | 13 ++ .../push/BatteryOptimizationManager.kt | 53 +++++ .../libremail/push/BatteryPromptDecision.kt | 22 +++ .../kotlin/org/libremail/ui/LibreMailApp.kt | 68 +++++-- .../org/libremail/ui/navigation/Routes.kt | 6 + .../onboarding/BatteryOptimizationScreen.kt | 123 ++++++++++++ .../ui/onboarding/OnboardingViewModel.kt | 59 +++++- .../libremail/ui/settings/SettingsScreen.kt | 19 ++ .../ui/settings/SettingsViewModel.kt | 16 ++ app/src/main/res/values/strings.xml | 13 ++ .../push/BatteryPromptDecisionTest.kt | 50 +++++ .../ui/onboarding/OnboardingViewModelTest.kt | 106 ++++++++++ gradle/libs.versions.toml | 1 + 17 files changed, 730 insertions(+), 24 deletions(-) create mode 100644 app/src/androidTest/kotlin/org/libremail/ui/onboarding/BatteryOptimizationStepTest.kt create mode 100644 app/src/main/kotlin/org/libremail/push/BatteryOptimizationManager.kt create mode 100644 app/src/main/kotlin/org/libremail/push/BatteryPromptDecision.kt create mode 100644 app/src/main/kotlin/org/libremail/ui/onboarding/BatteryOptimizationScreen.kt create mode 100644 app/src/test/kotlin/org/libremail/push/BatteryPromptDecisionTest.kt create mode 100644 app/src/test/kotlin/org/libremail/ui/onboarding/OnboardingViewModelTest.kt 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" } -- 2.47.3