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:
@@ -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)
|
||||
}
|
||||
|
||||
+186
@@ -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) }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user