Merge pull request #50 from JMR-dev/feat/49-onboarding-battery-optimization

feat(onboarding): opt-in to unrestricted battery/background usage
This commit was merged in pull request #50.
This commit is contained in:
Jason Ross
2026-07-01 12:42:52 -05:00
committed by GitHub
17 changed files with 730 additions and 24 deletions
+1
View File
@@ -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)
}
@@ -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<ComponentActivity>()
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"
}
}
@@ -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()
@@ -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) {
@@ -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)
@@ -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),
)
}
@@ -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
}
@@ -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 }
}
}
@@ -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)}"
@@ -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))
}
}
}
}
}
@@ -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<Boolean?>(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<Boolean?> = _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<Boolean> = _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) }
}
}
@@ -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,
@@ -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<List<Account>> = accountRepository.observeAccounts()
@@ -33,8 +36,21 @@ class SettingsViewModel @Inject constructor(
private val _advancedExpanded = MutableStateFlow(false)
val advancedExpanded: StateFlow<Boolean> = _advancedExpanded.asStateFlow()
private val _batteryUnrestricted = MutableStateFlow(batteryOptimizationManager.isIgnoringBatteryOptimizations())
/** Whether this app is exempt from battery optimization ("Unrestricted"). */
val batteryUnrestricted: StateFlow<Boolean> = _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) }
+13
View File
@@ -123,6 +123,16 @@
<string name="onboarding_add_another_yes">Add another account</string>
<string name="onboarding_add_another_no">No, take me to my inbox</string>
<!-- Onboarding: unrestricted background/battery opt-in (#49) -->
<string name="onboarding_battery_title">Get mail the instant it arrives</string>
<string name="onboarding_battery_body">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.</string>
<string name="onboarding_battery_guidance">On the next screen, open Battery and choose “Unrestricted”.</string>
<string name="onboarding_battery_take_me">Take me there</string>
<string name="onboarding_battery_not_now">Not now</string>
<string name="onboarding_battery_done_title">You\'re all set</string>
<string name="onboarding_battery_done_body">Background usage is unrestricted — new mail will arrive instantly.</string>
<string name="onboarding_battery_continue">Continue to inbox</string>
<!-- Account setup (vendor picker) -->
<string name="account_setup_outlook">Outlook or Hotmail</string>
<string name="account_setup_other">Other (IMAP/SMTP)</string>
@@ -225,6 +235,9 @@
<string name="settings_adv_remote_images">Load remote images by default</string>
<string name="settings_adv_encrypt_cache">Encrypt local cache</string>
<string name="settings_adv_encrypt_cache_summary">Encrypt cached mail stored on this device, using a key held in the Android Keystore. Applies the next time you open LibreMail.</string>
<string name="settings_adv_battery">Background battery usage</string>
<string name="settings_adv_battery_unrestricted">Unrestricted — instant background mail is allowed.</string>
<string name="settings_adv_battery_optimized">Optimized by Android — new mail may be delayed. Tap to allow unrestricted background usage.</string>
<!-- Diagnostics / debug reporting -->
<string name="settings_diagnostics">Diagnostics</string>
@@ -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)",
)
}
}
}
}
@@ -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<BatteryOptimizationManager> {
every { isSupported } returns supported
every { isIgnoringBatteryOptimizations() } returns unrestricted
}
private fun settingsRepository(handled: Boolean = false) = mockk<SettingsRepository> {
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<BatteryOptimizationManager> {
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)
}
}
+1
View File
@@ -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" }