Merge branch 'main' into feat-screen-unlock
# Conflicts: # app/src/main/kotlin/org/libremail/push/IdleService.kt
This commit is contained in:
@@ -29,9 +29,14 @@ internal val Context.settingsDataStore: DataStore<Preferences> by preferencesDat
|
||||
|
||||
/**
|
||||
* How aggressively the app downloads message content during sync.
|
||||
* - [ALWAYS]: fetch full bodies and all attachments on any connection (default).
|
||||
* - [ALWAYS]: fetch full bodies and all attachments on any connection.
|
||||
* - [WIFI_ONLY]: fetch full content only on an unmetered network; otherwise behave like [ON_DEMAND].
|
||||
* The default (#88): with the mailbox's full-history backfill, defaulting to [ALWAYS] would
|
||||
* silently download every message and attachment over cellular on a fresh install.
|
||||
* - [ON_DEMAND]: sync headers only; fetch a message's body/attachments lazily when it's opened.
|
||||
*
|
||||
* Regardless of policy, the prefetch also pauses at low battery — a runtime gate, not a setting
|
||||
* (see [org.libremail.data.sync.SyncResourcePolicy]).
|
||||
*/
|
||||
enum class FetchPolicy { ALWAYS, WIFI_ONLY, ON_DEMAND }
|
||||
|
||||
@@ -45,7 +50,7 @@ data class AppSettings(
|
||||
val encryptCache: Boolean = false,
|
||||
val appLock: Boolean = false,
|
||||
val includeInBackup: Boolean = false,
|
||||
val fetchPolicy: FetchPolicy = FetchPolicy.ALWAYS,
|
||||
val fetchPolicy: FetchPolicy = FetchPolicy.WIFI_ONLY,
|
||||
/**
|
||||
* Global device-only retention defaults (issue #13), applied to accounts that don't override them.
|
||||
* `0` means "keep everything" (the default), matching the fetch-all history behaviour of #12.
|
||||
@@ -82,8 +87,11 @@ internal fun Preferences.toAppSettings(): AppSettings = AppSettings(
|
||||
encryptCache = this[Keys.ENCRYPT_CACHE] ?: false,
|
||||
appLock = this[Keys.APP_LOCK] ?: false,
|
||||
includeInBackup = this[Keys.INCLUDE_IN_BACKUP] ?: false,
|
||||
// The fallback here (not just the AppSettings default) must be WIFI_ONLY so existing installs
|
||||
// that never touched the setting also pick up the safer default (#88). An explicitly chosen
|
||||
// policy is stored and always wins.
|
||||
fetchPolicy = this[Keys.FETCH_POLICY]?.let { runCatching { FetchPolicy.valueOf(it) }.getOrNull() }
|
||||
?: FetchPolicy.ALWAYS,
|
||||
?: FetchPolicy.WIFI_ONLY,
|
||||
retentionCount = this[Keys.RETENTION_COUNT] ?: 0,
|
||||
retentionMonths = this[Keys.RETENTION_MONTHS] ?: 0,
|
||||
)
|
||||
|
||||
@@ -17,7 +17,6 @@ import org.libremail.data.local.entity.MessageEntity
|
||||
import org.libremail.data.local.toDomain
|
||||
import org.libremail.data.local.toEntity
|
||||
import org.libremail.data.settings.AccountSettingsRepository
|
||||
import org.libremail.data.settings.FetchPolicy
|
||||
import org.libremail.data.settings.RetentionPolicy
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import org.libremail.data.settings.effectiveRetention
|
||||
@@ -25,6 +24,7 @@ import org.libremail.domain.model.Account
|
||||
import org.libremail.domain.model.ImapConnectionParams
|
||||
import org.libremail.domain.repository.MailRepository
|
||||
import org.libremail.mail.ImapClient
|
||||
import org.libremail.power.BatteryStatusProvider
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -50,6 +50,7 @@ class MailBackfiller @Inject constructor(
|
||||
private val connectionFactory: MailConnectionFactory,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val accountSettingsRepository: AccountSettingsRepository,
|
||||
private val batteryStatusProvider: BatteryStatusProvider,
|
||||
private val mailRepository: MailRepository,
|
||||
private val maintenanceGate: MailMaintenanceGate,
|
||||
) {
|
||||
@@ -169,16 +170,20 @@ class MailBackfiller @Inject constructor(
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-caches each backfilled message's body/attachments per the [FetchPolicy] (headers first,
|
||||
* bodies per policy — issue #12). Best-effort and cancellable between messages so an interruption
|
||||
* stops promptly; anything not fetched is filled in lazily when the message is opened.
|
||||
* Pre-caches each backfilled message's body/attachments per the user's fetch policy (headers
|
||||
* first, bodies per policy — issue #12), pausing at low battery regardless of policy — the same
|
||||
* [SyncResourcePolicy.shouldPrefetchContent] gate as [MailSyncer]'s prefetch, so the foreground
|
||||
* and backfill content paths can never disagree (#88/#89). Header paging above is not gated here:
|
||||
* WorkManager's battery-not-low constraint on the backfill work is the scheduler-level control.
|
||||
* Best-effort and cancellable between messages so an interruption stops promptly; anything not
|
||||
* fetched is filled in lazily when the message is opened.
|
||||
*/
|
||||
private suspend fun prefetchIfEnabled(ids: List<String>) {
|
||||
val shouldPrefetch = when (settingsRepository.fetchPolicy()) {
|
||||
FetchPolicy.ALWAYS -> true
|
||||
FetchPolicy.WIFI_ONLY -> context.isActiveNetworkUnmetered()
|
||||
FetchPolicy.ON_DEMAND -> false
|
||||
}
|
||||
val shouldPrefetch = SyncResourcePolicy.shouldPrefetchContent(
|
||||
policy = settingsRepository.fetchPolicy(),
|
||||
unmetered = { context.isActiveNetworkUnmetered() },
|
||||
battery = batteryStatusProvider.current(),
|
||||
)
|
||||
if (!shouldPrefetch) return
|
||||
for (id in ids) {
|
||||
currentCoroutineContext().ensureActive()
|
||||
|
||||
@@ -14,13 +14,13 @@ import org.libremail.data.local.dao.MessageDao
|
||||
import org.libremail.data.local.toDomain
|
||||
import org.libremail.data.local.toEntity
|
||||
import org.libremail.data.settings.AccountSettingsRepository
|
||||
import org.libremail.data.settings.FetchPolicy
|
||||
import org.libremail.data.settings.SettingsRepository
|
||||
import org.libremail.data.settings.effectiveRetention
|
||||
import org.libremail.domain.model.Account
|
||||
import org.libremail.domain.repository.MailRepository
|
||||
import org.libremail.mail.ImapClient
|
||||
import org.libremail.notifications.MailNotifier
|
||||
import org.libremail.power.BatteryStatusProvider
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@@ -34,6 +34,7 @@ class MailSyncer @Inject constructor(
|
||||
private val connectionFactory: MailConnectionFactory,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val accountSettingsRepository: AccountSettingsRepository,
|
||||
private val batteryStatusProvider: BatteryStatusProvider,
|
||||
private val notifier: MailNotifier,
|
||||
private val mailRepository: MailRepository,
|
||||
) : Syncer {
|
||||
@@ -158,15 +159,18 @@ class MailSyncer @Inject constructor(
|
||||
|
||||
/**
|
||||
* Aggressively pre-caches each not-yet-fetched message's full content (body + attachments) per the
|
||||
* user's [FetchPolicy]. Runs outside [syncMutex] so these downloads don't block pull-to-refresh or
|
||||
* other syncs, and is cancellable between messages so an IDLE renewal stops it promptly.
|
||||
* user's fetch policy, pausing at low battery regardless of policy — see
|
||||
* [SyncResourcePolicy.shouldPrefetchContent] (#89). The header sync above is never gated: mail
|
||||
* keeps arriving, and a skipped prefetch is simply retried on the next sync once battery/network
|
||||
* recover. Runs outside [syncMutex] so these downloads don't block pull-to-refresh or other syncs,
|
||||
* and is cancellable between messages so an IDLE renewal stops it promptly.
|
||||
*/
|
||||
private suspend fun prefetchIfEnabled(account: Account, folder: String) {
|
||||
val shouldPrefetch = when (settingsRepository.fetchPolicy()) {
|
||||
FetchPolicy.ALWAYS -> true
|
||||
FetchPolicy.WIFI_ONLY -> context.isActiveNetworkUnmetered()
|
||||
FetchPolicy.ON_DEMAND -> false
|
||||
}
|
||||
val shouldPrefetch = SyncResourcePolicy.shouldPrefetchContent(
|
||||
policy = settingsRepository.fetchPolicy(),
|
||||
unmetered = { context.isActiveNetworkUnmetered() },
|
||||
battery = batteryStatusProvider.current(),
|
||||
)
|
||||
if (!shouldPrefetch) return
|
||||
for (id in messageDao.getUnfetchedIds(account.id, folder)) {
|
||||
currentCoroutineContext().ensureActive()
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.sync
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.scan
|
||||
import org.libremail.data.settings.FetchPolicy
|
||||
import org.libremail.power.BatteryStatus
|
||||
|
||||
/** How new mail is watched for: a live IMAP IDLE connection, or the 15-minute periodic WorkManager sync. */
|
||||
enum class PushMode { IDLE, POLLING }
|
||||
|
||||
/**
|
||||
* Pure decisions for how sync adapts to device resource constraints (battery level, charging state,
|
||||
* network metering) — shared by the content-prefetch paths (#88, #89: [MailSyncer]'s recent-window
|
||||
* prefetch and [MailBackfiller]'s full-history prefetch) and the IMAP IDLE push service (#90).
|
||||
* Deliberately free of Android types so it is exhaustively unit-testable; the live inputs come from
|
||||
* [org.libremail.power.BatteryStatusProvider] and the shared [isActiveNetworkUnmetered] check.
|
||||
*
|
||||
* These are runtime gates, not settings writes: nothing here mutates a preference, so every effect
|
||||
* self-reverts as soon as the device leaves the constrained state.
|
||||
*
|
||||
* Charging exempts from every battery gate (#89's open question, resolved): a device on power is not
|
||||
* under battery pressure, so a plugged-in phone at 15% keeps prefetching and keeps IDLE alive.
|
||||
*/
|
||||
object SyncResourcePolicy {
|
||||
|
||||
/** At or below this charge (and not charging), battery-sensitive work pauses (#89, #90). */
|
||||
const val LOW_BATTERY_PERCENT = 20
|
||||
|
||||
/**
|
||||
* Once push has fallen back to polling, IDLE resumes only at or above this charge (or on the
|
||||
* charger). Strictly above [LOW_BATTERY_PERCENT] so a battery hovering at the threshold cannot
|
||||
* flap push on and off (hysteresis).
|
||||
*/
|
||||
const val PUSH_RESUME_PERCENT = 25
|
||||
|
||||
/** True when the battery is at or below [LOW_BATTERY_PERCENT] and the device is not charging. */
|
||||
fun isBatteryLow(battery: BatteryStatus): Boolean = !battery.isCharging && battery.percent <= LOW_BATTERY_PERCENT
|
||||
|
||||
/**
|
||||
* Whether to aggressively pre-download full message content (bodies + attachments) after a header
|
||||
* sync: the user's [FetchPolicy] decides, except at low battery, where the prefetch pauses for
|
||||
* every policy (#89). [unmetered] is a supplier because only [FetchPolicy.WIFI_ONLY] needs the
|
||||
* network state — the other policies (and any low-battery pause) never query it. Header sync is
|
||||
* never gated here — new mail still arrives; a paused prefetch merely defers content caching to
|
||||
* the next healthy sync (or to on-demand fetch when a message is opened).
|
||||
*/
|
||||
fun shouldPrefetchContent(policy: FetchPolicy, unmetered: () -> Boolean, battery: BatteryStatus): Boolean {
|
||||
if (isBatteryLow(battery)) return false
|
||||
return when (policy) {
|
||||
FetchPolicy.ALWAYS -> true
|
||||
FetchPolicy.WIFI_ONLY -> unmetered()
|
||||
FetchPolicy.ON_DEMAND -> false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Next push mode given the latest battery snapshot and the [previous] mode: drop to
|
||||
* [PushMode.POLLING] at low battery; return to [PushMode.IDLE] when charging or once charge
|
||||
* recovers to [PUSH_RESUME_PERCENT]. Inside the band between the two thresholds the previous mode
|
||||
* is kept (hysteresis), so jitter around the cutoff can't flap connections.
|
||||
*/
|
||||
fun pushMode(battery: BatteryStatus, previous: PushMode): PushMode = when {
|
||||
battery.isCharging -> PushMode.IDLE
|
||||
battery.percent <= LOW_BATTERY_PERCENT -> PushMode.POLLING
|
||||
previous == PushMode.POLLING && battery.percent < PUSH_RESUME_PERCENT -> PushMode.POLLING
|
||||
else -> PushMode.IDLE
|
||||
}
|
||||
|
||||
/**
|
||||
* Folds a stream of battery snapshots into the push mode to run, starting from [initial]
|
||||
* (assessed with no history, so when collection starts on an already-low battery the very first
|
||||
* mode is [PushMode.POLLING]). Deduplicated: changes inside the hysteresis band emit nothing.
|
||||
*/
|
||||
fun pushModes(initial: BatteryStatus, updates: Flow<BatteryStatus>): Flow<PushMode> = updates
|
||||
.scan(pushMode(initial, PushMode.IDLE)) { previous, battery -> pushMode(battery, previous) }
|
||||
.distinctUntilChanged()
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.di
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import org.libremail.power.AndroidBatteryStatusProvider
|
||||
import org.libremail.power.BatteryStatusProvider
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** Bindings for device power/battery state sources. */
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class PowerModule {
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindBatteryStatusProvider(impl: AndroidBatteryStatusProvider): BatteryStatusProvider
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.power
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.BatteryManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.channels.awaitClose
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.callbackFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Reads battery state from [BatteryManager] (one-shot) and from the sticky
|
||||
* [Intent.ACTION_BATTERY_CHANGED] broadcast (stream). Needs no permission. Anything unreadable
|
||||
* degrades to [BatteryStatus.ASSUME_OK], so battery gating only ever engages on a definitively low
|
||||
* battery, never on missing data.
|
||||
*/
|
||||
@Singleton
|
||||
class AndroidBatteryStatusProvider @Inject constructor(@ApplicationContext private val context: Context) :
|
||||
BatteryStatusProvider {
|
||||
|
||||
override fun current(): BatteryStatus {
|
||||
val manager = context.getSystemService(BatteryManager::class.java) ?: return BatteryStatus.ASSUME_OK
|
||||
val percent = manager.getIntProperty(BatteryManager.BATTERY_PROPERTY_CAPACITY)
|
||||
return BatteryStatus(
|
||||
percent = if (percent in 0..PERCENT_SCALE) percent else BatteryStatus.ASSUME_OK.percent,
|
||||
isCharging = manager.isCharging,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* [Intent.ACTION_BATTERY_CHANGED] can only be received by a runtime-registered receiver, so the
|
||||
* registration lives for exactly as long as the flow is collected. The broadcast is sticky:
|
||||
* registration returns the latest snapshot, which is emitted immediately. RECEIVER_NOT_EXPORTED
|
||||
* is correct for a system broadcast — the system is always allowed to deliver to it.
|
||||
*/
|
||||
override fun status(): Flow<BatteryStatus> = callbackFlow {
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
trySend(intent.toBatteryStatus())
|
||||
}
|
||||
}
|
||||
val sticky = ContextCompat.registerReceiver(
|
||||
context,
|
||||
receiver,
|
||||
IntentFilter(Intent.ACTION_BATTERY_CHANGED),
|
||||
ContextCompat.RECEIVER_NOT_EXPORTED,
|
||||
)
|
||||
trySend(sticky?.toBatteryStatus() ?: current())
|
||||
awaitClose { context.unregisterReceiver(receiver) }
|
||||
}.distinctUntilChanged()
|
||||
|
||||
private fun Intent.toBatteryStatus(): BatteryStatus {
|
||||
val level = getIntExtra(BatteryManager.EXTRA_LEVEL, -1)
|
||||
val scale = getIntExtra(BatteryManager.EXTRA_SCALE, -1)
|
||||
val status = getIntExtra(BatteryManager.EXTRA_STATUS, -1)
|
||||
val charging = status == BatteryManager.BATTERY_STATUS_CHARGING || status == BatteryManager.BATTERY_STATUS_FULL
|
||||
return BatteryStatus(
|
||||
percent = if (level >= 0 && scale > 0) level * PERCENT_SCALE / scale else BatteryStatus.ASSUME_OK.percent,
|
||||
isCharging = charging,
|
||||
)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val PERCENT_SCALE = 100
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.power
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
/** Point-in-time battery snapshot: charge [percent] (0–100) and whether the device is on power. */
|
||||
data class BatteryStatus(val percent: Int, val isCharging: Boolean) {
|
||||
companion object {
|
||||
/**
|
||||
* Fallback when the platform can't report battery state (missing service or extras): a full,
|
||||
* discharging battery, so every battery gate fails open to normal behaviour — bad data can
|
||||
* never pause sync or push.
|
||||
*/
|
||||
val ASSUME_OK = BatteryStatus(percent = 100, isCharging = false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Battery-state source for the resource-aware sync decisions in
|
||||
* [org.libremail.data.sync.SyncResourcePolicy]. An interface so those call sites stay unit-testable;
|
||||
* the live Android implementation is [AndroidBatteryStatusProvider].
|
||||
*/
|
||||
interface BatteryStatusProvider {
|
||||
/** One-shot snapshot, for point-in-time decisions (e.g. "prefetch content after this sync?"). */
|
||||
fun current(): BatteryStatus
|
||||
|
||||
/**
|
||||
* Stream of snapshots for long-lived consumers (the IDLE push service). Emits the current state
|
||||
* immediately on collection, then on every battery change, deduplicated.
|
||||
*/
|
||||
fun status(): Flow<BatteryStatus>
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
@@ -30,14 +31,27 @@ import org.libremail.data.local.toDomain
|
||||
import org.libremail.data.security.EncryptedCacheGuard
|
||||
import org.libremail.data.sync.MailConnectionFactory
|
||||
import org.libremail.data.sync.MailSyncer
|
||||
import org.libremail.data.sync.PushMode
|
||||
import org.libremail.data.sync.SyncResourcePolicy
|
||||
import org.libremail.data.sync.SyncScheduler
|
||||
import org.libremail.domain.model.Account
|
||||
import org.libremail.mail.ImapClient
|
||||
import org.libremail.power.BatteryStatusProvider
|
||||
import org.libremail.reporting.AppLog
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
* Foreground service that holds a long-lived IMAP IDLE connection per account so the server can
|
||||
* push new mail to us instantly — no polling, and no third-party push service. When IDLE reports
|
||||
* activity we run a normal sync, which writes to Room and fires the new-mail notification.
|
||||
*
|
||||
* At low battery (#90) the service stays up but proactively closes every IDLE connection and drops
|
||||
* to [PushMode.POLLING]: mail then arrives via the always-scheduled 15-minute periodic sync, and the
|
||||
* persistent notification says so instead of pretending push is live. Once battery recovers (with
|
||||
* hysteresis — see [SyncResourcePolicy.pushMode]) or the device is plugged in, IDLE resumes and its
|
||||
* on-connect sync catches up anything that arrived in between. A deliberate, visible fallback beats
|
||||
* the previous emergent one, where the OS throttled the low-battery socket and the reconnect loop
|
||||
* burned battery retrying while the notification still claimed instant delivery.
|
||||
*/
|
||||
@AndroidEntryPoint
|
||||
class IdleService : Service() {
|
||||
@@ -55,14 +69,22 @@ class IdleService : Service() {
|
||||
|
||||
@Inject lateinit var cacheGuard: EncryptedCacheGuard
|
||||
|
||||
@Inject lateinit var batteryStatusProvider: BatteryStatusProvider
|
||||
|
||||
@Inject lateinit var syncScheduler: SyncScheduler
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var watching = false
|
||||
|
||||
/** The push mode the foreground notification currently reflects. */
|
||||
@Volatile
|
||||
private var shownMode = PushMode.IDLE
|
||||
|
||||
/** Active IDLE watcher per account id, so we can start/stop them as accounts change. */
|
||||
private val watchers = mutableMapOf<String, Job>()
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
startAsForeground()
|
||||
startAsForeground(shownMode)
|
||||
if (!watching) {
|
||||
watching = true
|
||||
scope.launch {
|
||||
@@ -80,17 +102,26 @@ class IdleService : Service() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Observes the account list and keeps one IDLE watcher per account: a watcher is started for a
|
||||
* newly-added account and cancelled when its account is removed (which promptly closes that
|
||||
* account's IDLE connection). The service is started/stopped by the app based on whether any
|
||||
* accounts exist, so reaching zero here is just a transient state.
|
||||
* Observes the account list and the battery-derived [PushMode], and keeps one IDLE watcher per
|
||||
* account while in [PushMode.IDLE]: a watcher is started for a newly-added account and cancelled
|
||||
* when its account is removed (which promptly closes that account's IDLE connection). In
|
||||
* [PushMode.POLLING] no watcher is wanted, so entering it cancels them all — cleanly closing the
|
||||
* IDLE connections — and leaving it starts them again. The service is started/stopped by the app
|
||||
* based on whether any accounts exist, so reaching zero here is just a transient state.
|
||||
*/
|
||||
private suspend fun reconcileWatchers() {
|
||||
accountDao.get().observeAll().collect { entities ->
|
||||
val accounts = entities.map { it.toDomain() }
|
||||
val currentIds = accounts.mapTo(mutableSetOf()) { it.id }
|
||||
(watchers.keys - currentIds).forEach { id -> watchers.remove(id)?.cancel() }
|
||||
accounts.forEach { account ->
|
||||
val pushModes = SyncResourcePolicy.pushModes(batteryStatusProvider.current(), batteryStatusProvider.status())
|
||||
combine(accountDao.get().observeAll(), pushModes) { entities, mode ->
|
||||
entities.map { it.toDomain() } to mode
|
||||
}.collect { (accounts, mode) ->
|
||||
if (mode != shownMode) {
|
||||
shownMode = mode
|
||||
onPushModeChanged(mode)
|
||||
}
|
||||
val wanted = if (mode == PushMode.POLLING) emptyList() else accounts
|
||||
val wantedIds = wanted.mapTo(mutableSetOf()) { it.id }
|
||||
(watchers.keys - wantedIds).forEach { id -> watchers.remove(id)?.cancel() }
|
||||
wanted.forEach { account ->
|
||||
if (account.id !in watchers) {
|
||||
watchers[account.id] = scope.launch { watchAccount(account) }
|
||||
}
|
||||
@@ -98,6 +129,19 @@ class IdleService : Service() {
|
||||
}
|
||||
}
|
||||
|
||||
/** Makes a push-mode change visible (#90): updates the persistent notification and the app log. */
|
||||
private fun onPushModeChanged(mode: PushMode) {
|
||||
startAsForeground(mode)
|
||||
if (mode == PushMode.POLLING) {
|
||||
AppLog.i(TAG, "Battery low: pausing IMAP IDLE; mail arrives via 15-minute periodic sync")
|
||||
// The periodic fallback is scheduled at every app start with KEEP, so this is normally a
|
||||
// no-op — re-asserted here so the fallback provably exists whenever push is paused.
|
||||
syncScheduler.schedulePeriodicSync()
|
||||
} else {
|
||||
AppLog.i(TAG, "Battery recovered: resuming IMAP IDLE push")
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Holds IDLE for one account, reconnecting with exponential backoff whenever it drops.
|
||||
* Each IDLE session is bounded by [IDLE_RENEWAL_MS]: when it elapses, [withTimeoutOrNull]
|
||||
@@ -133,7 +177,11 @@ class IdleService : Service() {
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
private fun startAsForeground() {
|
||||
/**
|
||||
* Starts (or, on later calls, updates) the foreground notification. The text tells the truth per
|
||||
* [mode]: "connected for instant delivery" versus the low-battery 15-minute polling fallback.
|
||||
*/
|
||||
private fun startAsForeground(mode: PushMode) {
|
||||
NotificationManagerCompat.from(this).createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
@@ -141,10 +189,15 @@ class IdleService : Service() {
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
),
|
||||
)
|
||||
val text = if (mode == PushMode.POLLING) {
|
||||
getString(R.string.notif_push_status_text_low_battery)
|
||||
} else {
|
||||
getString(R.string.notif_push_status_text)
|
||||
}
|
||||
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_launcher_monochrome)
|
||||
.setContentTitle(getString(R.string.notif_push_status_title))
|
||||
.setContentText(getString(R.string.notif_push_status_text))
|
||||
.setContentText(text)
|
||||
.setOngoing(true)
|
||||
.setShowWhen(false)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
|
||||
@@ -180,6 +180,7 @@
|
||||
<string name="notif_channel_push_status">Push (IMAP IDLE)</string>
|
||||
<string name="notif_push_status_title">Watching for new mail</string>
|
||||
<string name="notif_push_status_text">Connected for instant delivery</string>
|
||||
<string name="notif_push_status_text_low_battery">Battery low — checking every 15 minutes until it recovers</string>
|
||||
|
||||
<!-- Settings -->
|
||||
<string name="settings_accounts">Accounts</string>
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.settings
|
||||
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import androidx.datastore.preferences.core.preferencesOf
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import org.junit.Test
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
/**
|
||||
* Full-content prefetch must default to Wi-Fi-only (#88) — both for fresh installs (the [AppSettings]
|
||||
* default) and for existing installs that never touched the setting (the DataStore-read fallback in
|
||||
* [toAppSettings]); an explicit user choice always wins.
|
||||
*/
|
||||
class AppSettingsTest {
|
||||
|
||||
private val fetchPolicyKey = stringPreferencesKey("fetch_policy")
|
||||
|
||||
@Test
|
||||
fun `in-memory default fetch policy is WIFI_ONLY`() {
|
||||
assertEquals(FetchPolicy.WIFI_ONLY, AppSettings().fetchPolicy)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `DataStore fallback for a never-set fetch policy is WIFI_ONLY`() {
|
||||
assertEquals(FetchPolicy.WIFI_ONLY, emptyPreferences().toAppSettings().fetchPolicy)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an explicitly stored fetch policy is respected over the default`() {
|
||||
val prefs = preferencesOf(fetchPolicyKey to FetchPolicy.ALWAYS.name)
|
||||
assertEquals(FetchPolicy.ALWAYS, prefs.toAppSettings().fetchPolicy)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `an unrecognized stored fetch policy falls back to WIFI_ONLY`() {
|
||||
val prefs = preferencesOf(fetchPolicyKey to "NOT_A_POLICY")
|
||||
assertEquals(FetchPolicy.WIFI_ONLY, prefs.toAppSettings().fetchPolicy)
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,8 @@ import org.libremail.domain.model.ImapConnectionParams
|
||||
import org.libremail.domain.model.MailSecurity
|
||||
import org.libremail.domain.repository.MailRepository
|
||||
import org.libremail.mail.ImapClient
|
||||
import org.libremail.power.BatteryStatus
|
||||
import org.libremail.power.BatteryStatusProvider
|
||||
import java.util.Date
|
||||
import java.util.Properties
|
||||
import kotlin.test.assertEquals
|
||||
@@ -189,8 +191,42 @@ class MailBackfillerTest {
|
||||
assertNoDeletes()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `low battery pauses the backfill content prefetch even for ALWAYS, but not header paging`() = runTest {
|
||||
appendMessages(60)
|
||||
seedForegroundWindow()
|
||||
|
||||
backfiller(
|
||||
AccountSettings("acct"),
|
||||
fetchPolicy = FetchPolicy.ALWAYS,
|
||||
battery = BatteryStatus(percent = 15, isCharging = false),
|
||||
).runBackfill()
|
||||
|
||||
// #89 gates only the aggressive body/attachment prefetch; history headers keep paging in.
|
||||
assertEquals(60, distinctCachedUids().size, "header paging itself is not battery-gated")
|
||||
coVerify(exactly = 0) { requireNotNull(lastMailRepository).prefetchMessage(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `charging at low percent keeps the backfill content prefetch running`() = runTest {
|
||||
appendMessages(60)
|
||||
seedForegroundWindow()
|
||||
|
||||
backfiller(
|
||||
AccountSettings("acct"),
|
||||
fetchPolicy = FetchPolicy.ALWAYS,
|
||||
battery = BatteryStatus(percent = 15, isCharging = true),
|
||||
).runBackfill()
|
||||
|
||||
coVerify(atLeast = 1) { requireNotNull(lastMailRepository).prefetchMessage(any()) }
|
||||
}
|
||||
|
||||
/** Builds a backfiller wired to GreenMail with the in-memory fakes and the given account settings. */
|
||||
private fun backfiller(accountSettings: AccountSettings): MailBackfiller {
|
||||
private fun backfiller(
|
||||
accountSettings: AccountSettings,
|
||||
fetchPolicy: FetchPolicy = FetchPolicy.ON_DEMAND,
|
||||
battery: BatteryStatus = BatteryStatus(percent = 100, isCharging = false),
|
||||
): MailBackfiller {
|
||||
val accountDao = mockk<AccountDao>()
|
||||
coEvery { accountDao.getAll() } returns listOf(accountEntity)
|
||||
|
||||
@@ -227,12 +263,15 @@ class MailBackfillerTest {
|
||||
coEvery { connectionFactory.imapParamsFor(any()) } returns params()
|
||||
|
||||
val settingsRepository = mockk<SettingsRepository>()
|
||||
coEvery { settingsRepository.fetchPolicy() } returns FetchPolicy.ON_DEMAND
|
||||
coEvery { settingsRepository.fetchPolicy() } returns fetchPolicy
|
||||
every { settingsRepository.settings } returns flowOf(AppSettings())
|
||||
|
||||
val accountSettingsRepository = mockk<AccountSettingsRepository>()
|
||||
coEvery { accountSettingsRepository.get("acct") } returns accountSettings
|
||||
|
||||
val batteryStatusProvider = mockk<BatteryStatusProvider> { every { current() } returns battery }
|
||||
val mailRepository = mockk<MailRepository>(relaxed = true)
|
||||
|
||||
return MailBackfiller(
|
||||
context = mockk<Context>(relaxed = true),
|
||||
accountDao = accountDao,
|
||||
@@ -242,12 +281,17 @@ class MailBackfillerTest {
|
||||
connectionFactory = connectionFactory,
|
||||
settingsRepository = settingsRepository,
|
||||
accountSettingsRepository = accountSettingsRepository,
|
||||
mailRepository = mockk<MailRepository>(relaxed = true),
|
||||
batteryStatusProvider = batteryStatusProvider,
|
||||
mailRepository = mailRepository,
|
||||
maintenanceGate = MailMaintenanceGate(),
|
||||
).also { lastMessageDao = messageDao }
|
||||
).also {
|
||||
lastMessageDao = messageDao
|
||||
lastMailRepository = mailRepository
|
||||
}
|
||||
}
|
||||
|
||||
private var lastMessageDao: MessageDao? = null
|
||||
private var lastMailRepository: MailRepository? = null
|
||||
|
||||
/** Seeds the in-memory cache with the newest [WINDOW] headers, mimicking a prior foreground sync. */
|
||||
private suspend fun seedForegroundWindow() {
|
||||
|
||||
@@ -26,6 +26,8 @@ import org.libremail.domain.model.ImapConnectionParams
|
||||
import org.libremail.domain.model.MailSecurity
|
||||
import org.libremail.mail.FetchedMessage
|
||||
import org.libremail.mail.ImapClient
|
||||
import org.libremail.power.BatteryStatus
|
||||
import org.libremail.power.BatteryStatusProvider
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.test.assertEquals
|
||||
@@ -167,6 +169,9 @@ class MailMaintenanceGateTest {
|
||||
connectionFactory = connectionFactory,
|
||||
settingsRepository = settingsRepository,
|
||||
accountSettingsRepository = accountSettingsRepository,
|
||||
batteryStatusProvider = mockk<BatteryStatusProvider> {
|
||||
every { current() } returns BatteryStatus(percent = 100, isCharging = false)
|
||||
},
|
||||
mailRepository = mockk(relaxed = true),
|
||||
maintenanceGate = gate,
|
||||
)
|
||||
|
||||
@@ -26,6 +26,9 @@ import org.libremail.domain.repository.MailRepository
|
||||
import org.libremail.mail.FetchedMessage
|
||||
import org.libremail.mail.ImapClient
|
||||
import org.libremail.notifications.MailNotifier
|
||||
import org.libremail.power.BatteryStatus
|
||||
import org.libremail.power.BatteryStatusProvider
|
||||
import kotlin.test.assertEquals
|
||||
|
||||
class MailSyncerTest {
|
||||
|
||||
@@ -48,6 +51,7 @@ class MailSyncerTest {
|
||||
context: Context = mockk(relaxed = true),
|
||||
accountSettings: AccountSettings = AccountSettings("acct"),
|
||||
globalSettings: AppSettings = AppSettings(),
|
||||
battery: BatteryStatus = BatteryStatus(percent = 100, isCharging = false),
|
||||
): MailSyncer {
|
||||
val accountDao = mockk<AccountDao>()
|
||||
coEvery { accountDao.getById("acct") } returns account
|
||||
@@ -72,11 +76,15 @@ class MailSyncerTest {
|
||||
connectionFactory = connectionFactory,
|
||||
settingsRepository = settingsRepository,
|
||||
accountSettingsRepository = accountSettingsRepository,
|
||||
batteryStatusProvider = batteryProvider(battery),
|
||||
notifier = mockk<MailNotifier>(relaxed = true),
|
||||
mailRepository = mailRepository,
|
||||
)
|
||||
}
|
||||
|
||||
private fun batteryProvider(battery: BatteryStatus): BatteryStatusProvider =
|
||||
mockk<BatteryStatusProvider> { every { current() } returns battery }
|
||||
|
||||
@Test
|
||||
fun `ALWAYS policy prefetches unfetched messages after the header sync`() = runTest {
|
||||
val repo = mockk<MailRepository>()
|
||||
@@ -146,6 +154,49 @@ class MailSyncerTest {
|
||||
coVerify(exactly = 0) { repo.prefetchMessage(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `low battery pauses prefetch even for ALWAYS, leaving the header sync untouched`() = runTest {
|
||||
val repo = mockk<MailRepository>(relaxed = true)
|
||||
val lowBattery = BatteryStatus(percent = 15, isCharging = false)
|
||||
|
||||
val result = syncer(FetchPolicy.ALWAYS, repo, battery = lowBattery).syncFolder("acct", "INBOX")
|
||||
|
||||
assertEquals(0, result.getOrNull()) // header sync still ran and succeeded
|
||||
coVerify(exactly = 0) { repo.prefetchMessage(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `at exactly the 20 percent threshold prefetch is paused`() = runTest {
|
||||
val repo = mockk<MailRepository>(relaxed = true)
|
||||
val threshold = BatteryStatus(percent = 20, isCharging = false)
|
||||
|
||||
syncer(FetchPolicy.ALWAYS, repo, battery = threshold).syncFolder("acct", "INBOX")
|
||||
|
||||
coVerify(exactly = 0) { repo.prefetchMessage(any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a low battery on the charger still prefetches`() = runTest {
|
||||
val repo = mockk<MailRepository>()
|
||||
coEvery { repo.prefetchMessage(any()) } returns Result.success(Unit)
|
||||
val chargingLow = BatteryStatus(percent = 15, isCharging = true)
|
||||
|
||||
syncer(FetchPolicy.ALWAYS, repo, battery = chargingLow).syncFolder("acct", "INBOX")
|
||||
|
||||
coVerify { repo.prefetchMessage("acct:INBOX:1") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prefetch resumes on the next sync once battery recovers`() = runTest {
|
||||
val repo = mockk<MailRepository>()
|
||||
coEvery { repo.prefetchMessage(any()) } returns Result.success(Unit)
|
||||
val recovered = BatteryStatus(percent = 21, isCharging = false)
|
||||
|
||||
syncer(FetchPolicy.ALWAYS, repo, battery = recovered).syncFolder("acct", "INBOX")
|
||||
|
||||
coVerify { repo.prefetchMessage("acct:INBOX:1") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `notifies for new mail when both global and per-account notifications are enabled`() = runTest {
|
||||
val notifier = mockk<MailNotifier>(relaxed = true)
|
||||
@@ -200,6 +251,7 @@ class MailSyncerTest {
|
||||
connectionFactory = connectionFactory,
|
||||
settingsRepository = settingsRepository,
|
||||
accountSettingsRepository = accountSettingsRepository,
|
||||
batteryStatusProvider = batteryProvider(BatteryStatus(percent = 100, isCharging = false)),
|
||||
notifier = notifier,
|
||||
mailRepository = mockk(relaxed = true),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
package org.libremail.data.sync
|
||||
|
||||
import app.cash.turbine.test
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.libremail.data.settings.FetchPolicy
|
||||
import org.libremail.power.BatteryStatus
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class SyncResourcePolicyTest {
|
||||
|
||||
private fun battery(percent: Int, charging: Boolean = false) = BatteryStatus(percent, charging)
|
||||
|
||||
private fun prefetch(policy: FetchPolicy, unmetered: Boolean = true, battery: BatteryStatus = battery(80)) =
|
||||
SyncResourcePolicy.shouldPrefetchContent(policy, { unmetered }, battery)
|
||||
|
||||
// ---- isBatteryLow: the shared threshold (#89/#90) ----
|
||||
|
||||
@Test
|
||||
fun `battery is low at exactly 20 percent and below, but not at 21`() {
|
||||
assertTrue(SyncResourcePolicy.isBatteryLow(battery(20)))
|
||||
assertTrue(SyncResourcePolicy.isBatteryLow(battery(1)))
|
||||
assertFalse(SyncResourcePolicy.isBatteryLow(battery(21)))
|
||||
assertFalse(SyncResourcePolicy.isBatteryLow(battery(100)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `charging exempts from the low-battery state at any percent`() {
|
||||
assertFalse(SyncResourcePolicy.isBatteryLow(battery(20, charging = true)))
|
||||
assertFalse(SyncResourcePolicy.isBatteryLow(battery(1, charging = true)))
|
||||
}
|
||||
|
||||
// ---- shouldPrefetchContent: FetchPolicy x network x battery (#88/#89) ----
|
||||
|
||||
@Test
|
||||
fun `healthy battery applies the fetch policy as-is`() {
|
||||
assertTrue(prefetch(FetchPolicy.ALWAYS, unmetered = false))
|
||||
assertTrue(prefetch(FetchPolicy.WIFI_ONLY, unmetered = true))
|
||||
assertFalse(prefetch(FetchPolicy.WIFI_ONLY, unmetered = false))
|
||||
assertFalse(prefetch(FetchPolicy.ON_DEMAND, unmetered = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `low battery pauses prefetch for every policy`() {
|
||||
val low = battery(20)
|
||||
assertFalse(prefetch(FetchPolicy.ALWAYS, unmetered = true, battery = low))
|
||||
assertFalse(prefetch(FetchPolicy.WIFI_ONLY, unmetered = true, battery = low))
|
||||
assertFalse(prefetch(FetchPolicy.ON_DEMAND, unmetered = true, battery = low))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prefetch resumes one percent above the threshold`() {
|
||||
assertTrue(prefetch(FetchPolicy.ALWAYS, battery = battery(21)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `charging at low percent still prefetches`() {
|
||||
assertTrue(prefetch(FetchPolicy.ALWAYS, battery = battery(10, charging = true)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `charging does not override the network gate`() {
|
||||
assertFalse(prefetch(FetchPolicy.WIFI_ONLY, unmetered = false, battery = battery(10, charging = true)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `network state is only consulted for WIFI_ONLY on a healthy battery`() {
|
||||
val forbidden: () -> Boolean = { error("network must not be consulted") }
|
||||
assertTrue(SyncResourcePolicy.shouldPrefetchContent(FetchPolicy.ALWAYS, forbidden, battery(80)))
|
||||
assertFalse(SyncResourcePolicy.shouldPrefetchContent(FetchPolicy.ON_DEMAND, forbidden, battery(80)))
|
||||
// The battery gate short-circuits even the WIFI_ONLY network check.
|
||||
assertFalse(SyncResourcePolicy.shouldPrefetchContent(FetchPolicy.WIFI_ONLY, forbidden, battery(10)))
|
||||
}
|
||||
|
||||
// ---- pushMode: IDLE vs POLLING with hysteresis (#90) ----
|
||||
|
||||
@Test
|
||||
fun `push drops to polling at exactly 20 percent`() {
|
||||
assertEquals(PushMode.POLLING, SyncResourcePolicy.pushMode(battery(20), previous = PushMode.IDLE))
|
||||
assertEquals(PushMode.POLLING, SyncResourcePolicy.pushMode(battery(5), previous = PushMode.IDLE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `push stays on idle above the threshold`() {
|
||||
assertEquals(PushMode.IDLE, SyncResourcePolicy.pushMode(battery(21), previous = PushMode.IDLE))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inside the hysteresis band the previous mode is kept`() {
|
||||
for (percent in 21 until SyncResourcePolicy.PUSH_RESUME_PERCENT) {
|
||||
assertEquals(PushMode.POLLING, SyncResourcePolicy.pushMode(battery(percent), previous = PushMode.POLLING))
|
||||
assertEquals(PushMode.IDLE, SyncResourcePolicy.pushMode(battery(percent), previous = PushMode.IDLE))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `push resumes idle at the recovery threshold`() {
|
||||
assertEquals(
|
||||
PushMode.IDLE,
|
||||
SyncResourcePolicy.pushMode(battery(SyncResourcePolicy.PUSH_RESUME_PERCENT), previous = PushMode.POLLING),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plugging in resumes idle immediately at any percent`() {
|
||||
val plugged = battery(5, charging = true)
|
||||
assertEquals(PushMode.IDLE, SyncResourcePolicy.pushMode(plugged, previous = PushMode.POLLING))
|
||||
}
|
||||
|
||||
// ---- pushModes: the folded stream the IDLE service consumes (#90) ----
|
||||
|
||||
@Test
|
||||
fun `stream starts in idle on a healthy battery and drops to polling at the threshold`() = runTest {
|
||||
val updates = MutableSharedFlow<BatteryStatus>()
|
||||
SyncResourcePolicy.pushModes(initial = battery(50), updates = updates).test {
|
||||
assertEquals(PushMode.IDLE, awaitItem())
|
||||
updates.emit(battery(20))
|
||||
assertEquals(PushMode.POLLING, awaitItem())
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stream starts in polling when collection begins on an already-low battery`() = runTest {
|
||||
val updates = MutableSharedFlow<BatteryStatus>()
|
||||
SyncResourcePolicy.pushModes(initial = battery(12), updates = updates).test {
|
||||
assertEquals(PushMode.POLLING, awaitItem())
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `jitter around the threshold does not flap the stream`() = runTest {
|
||||
val updates = MutableSharedFlow<BatteryStatus>()
|
||||
SyncResourcePolicy.pushModes(initial = battery(50), updates = updates).test {
|
||||
assertEquals(PushMode.IDLE, awaitItem())
|
||||
updates.emit(battery(20))
|
||||
assertEquals(PushMode.POLLING, awaitItem())
|
||||
// Bouncing 21 <-> 20 <-> 24 stays inside the hysteresis band: nothing is emitted.
|
||||
updates.emit(battery(21))
|
||||
updates.emit(battery(20))
|
||||
updates.emit(battery(24))
|
||||
expectNoEvents()
|
||||
// Only reaching the recovery threshold flips back.
|
||||
updates.emit(battery(SyncResourcePolicy.PUSH_RESUME_PERCENT))
|
||||
assertEquals(PushMode.IDLE, awaitItem())
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plugging in while polling resumes idle without waiting for the recovery threshold`() = runTest {
|
||||
val updates = MutableSharedFlow<BatteryStatus>()
|
||||
SyncResourcePolicy.pushModes(initial = battery(10), updates = updates).test {
|
||||
assertEquals(PushMode.POLLING, awaitItem())
|
||||
updates.emit(battery(10, charging = true))
|
||||
assertEquals(PushMode.IDLE, awaitItem())
|
||||
// Unplugging below the threshold drops straight back to polling.
|
||||
updates.emit(battery(10))
|
||||
assertEquals(PushMode.POLLING, awaitItem())
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user