Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .claude/lessons/LESSONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,11 @@ reader would not infer from the code. Capture the **decision** and the
**Why over the obvious alternative:** Wrapping `java.util.Timer`/`ScheduledExecutorService` adds a thread pool the coroutine runtime already provides, and loses virtual-time testability. The jvm target requires reachable ≥ the first jvm-enabled release (0.13.0 has no jvm artifact); pin note in libs.versions.toml.
**Ref:** `backgrounder/src/jvmMain/`, `CoroutineBackedSchedulerTest` (exact-timing virtual-clock suite).

### D-025 — `requiresDeviceIdle`: enforced on Android, advisory + log-once elsewhere — 2026-06-20
**Decision:** `WorkConstraints.requiresDeviceIdle` (the "device-idle" item previously parked as a v2 gap in `WorkConstraints` KDoc) ships as a real `Constraints.Builder.setRequiresDeviceIdle` gate on Android (JobScheduler / Doze), and as a **no-op** on iOS / macOS / JVM. `BGProcessingTaskRequest` has no idle property (only `requiresExternalPower` + `requiresNetworkConnectivity`); macOS/JVM have no idle primitive. iOS emits a one-time `log.w` in `applyConstraints` (mirroring the `NetworkRequirement.Unmetered` warning); macOS/JVM stay silent-with-a-comment because that's how `requiresCharging` is already treated in those files — match the file's local convention, don't invent a log it doesn't have. Inspector parity: added `PendingPredicate.RequiresDeviceIdle` + Android `WorkInfo` read-back, mirroring `RequiresCharging` at every touch-point (field → mapper → predicate → snapshot data class). This is the **advisory-constraint template** for the next cross-platform constraint: enforce where native, accept-and-document elsewhere, never imitate.
**Why over the obvious alternative:** A library-managed idle *gate* (like the reachability gate) was rejected — there is no portable "is the device idle?" signal to poll, and idle is an OS-internal fuzzy notion; faking it would be wrong more often than right. Honest advisory + the existing per-platform "check inside `execute()` and return Retry" escape hatch costs one doc paragraph and zero correctness machinery. No `minInterval`/throttle was built (owner descoped it the same session) — opportunistic dispatch already floors frequency.
**Ref:** `WorkConstraints.kt`, `AndroidConstraintsMapper.kt`, `AndroidScheduledTaskMapper.kt`, `PendingPredicate.kt`, iOS `BGTaskBackedScheduler.applyConstraints`. Docs: `docs/concepts/opportunistic-dispatch.md` (new), `docs/recipes/network-required.md` § "Require the device to be idle".

---

## NEVER DO (N)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ internal fun WorkConstraints.toWorkManagerConstraints(): Constraints =
.Builder()
.setRequiredNetworkType(networkRequired.toNetworkType())
.setRequiresCharging(requiresCharging)
.setRequiresDeviceIdle(requiresDeviceIdle)
.build()

private fun NetworkRequirement.toNetworkType(): NetworkType =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ internal object AndroidScheduledTaskMapper {
if (c.requiresCharging) {
result.add(PendingPredicate.RequiresCharging)
}
if (c.requiresDeviceIdle) {
result.add(PendingPredicate.RequiresDeviceIdle)
}
}
if (nextRunHint != null && nextRunHint > Clock.System.now()) {
when (state) {
Expand Down Expand Up @@ -170,6 +173,7 @@ internal object AndroidScheduledTaskMapper {
ConstraintsView(
networkType = info.constraints.requiredNetworkType,
requiresCharging = info.constraints.requiresCharging(),
requiresDeviceIdle = info.constraints.requiresDeviceIdle(),
),
)
}
Expand All @@ -179,6 +183,7 @@ internal object AndroidScheduledTaskMapper {
internal data class ConstraintsView(
val networkType: NetworkType,
val requiresCharging: Boolean,
val requiresDeviceIdle: Boolean,
)

private fun mapState(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,18 @@ public sealed interface PendingPredicate {
*/
public data object RequiresCharging : PendingPredicate

/**
* The configured [WorkConstraints.requiresDeviceIdle] is set and the device
* is not currently idle (not in a Doze maintenance window).
*
* **Platform support.** Android honours this via `WorkInfo.constraints`
* read-back — the OS enforces idle-state gating at the `JobScheduler` level.
* iOS has no `BGProcessingTaskRequest` idle knob; this predicate is never
* surfaced on iOS. macOS and JVM have no idle primitive and do not surface
* this predicate.
*/
public data object RequiresDeviceIdle : PendingPredicate

/**
* The task is in a backoff window — the library is delaying the next
* attempt to honour the configured [BackoffPolicy]. [until] is the
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,30 @@ import kotlinx.serialization.Serializable
* dispatch it.
*
* v1 exposes the lowest-common-denominator across Android and iOS. Android-only
* constraints (storage-not-low, battery-not-low, device-idle, content URI
* triggers) land in v2 via a `WorkConstraints.Android` extension.
* constraints (storage-not-low, battery-not-low, content URI triggers) land in
* v2 via a `WorkConstraints.Android` extension.
*/
@Serializable
public data class WorkConstraints(
/** Network connectivity level required before the scheduler dispatches this request. */
val networkRequired: NetworkRequirement = NetworkRequirement.None,
/** If `true`, the device must be connected to external power before dispatching. */
val requiresCharging: Boolean = false,
/**
* If `true`, the device must be idle (in Doze mode or a maintenance window on
* Android) before dispatching.
*
* **Platform support.**
* - **Android:** enforced via `WorkManager`'s `Constraints.setRequiresDeviceIdle(true)`,
* which maps to `JobScheduler` idle scheduling (API 23+). The OS will only
* dispatch the task when the device enters Doze or a maintenance window.
* - **iOS:** `BGProcessingTaskRequest` has no device-idle knob. The flag is
* accepted and logged once as advisory; the OS already prefers idle moments
* when it fires background tasks.
* - **macOS / JVM:** `NSBackgroundActivityScheduler` and the coroutine scheduler
* have no idle primitive. The flag is accepted but not enforced.
*/
val requiresDeviceIdle: Boolean = false,
)

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,17 @@ internal class BGTaskBackedScheduler(
}
}
requiresExternalPower = constraints.requiresCharging
// BGProcessingTaskRequest has no device-idle knob — the OS already prefers
// idle moments when it fires background tasks. Log once so consumers know
// the constraint was accepted but is not OS-enforced on iOS.
if (constraints.requiresDeviceIdle) {
log.w {
"WorkConstraints.requiresDeviceIdle is not honored on iOS; ignored. " +
"BGProcessingTaskRequest has no device-idle field — the OS schedules " +
"background tasks at opportune (typically idle) moments regardless. " +
"(hint=$hint)"
}
}
}

/** Helper so BGAppRefreshTaskRequest can also call applyConstraints() — it's a no-op. */
Expand All @@ -460,6 +471,7 @@ internal class BGTaskBackedScheduler(
@Suppress("UNUSED_PARAMETER") hint: ExecutionHint,
) {
// BGAppRefreshTaskRequest doesn't expose constraints. Documented in plan.
// requiresDeviceIdle is likewise unsupported on this request type.
}

private fun BGTaskRequest.applyConstraints(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ import kotlin.time.Instant
* with, so tasks fire at the interval boundary.
* - `WorkConstraints.requiresCharging` is not enforced (no portable JVM power
* API) — same caveat as macOS; workers should check inside `execute()`.
* - `WorkConstraints.requiresDeviceIdle` is not enforced (no JVM idle primitive)
* — same caveat as macOS.
*
* Like macOS, nothing here survives the process: see [JVM_GUARANTEES] and the
* process-death contract (LESSONS.md D-020 / D-022).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,8 @@ internal class NSBackgroundActivityBackedScheduler(
// the NSBackgroundActivityScheduler is built — the only
// dispatch-blocking condition we can observe is the
// backoff window when a previous attempt returned Retry.
// requiresCharging and requiresDeviceIdle are not enforced:
// NSBackgroundActivityScheduler has no power or idle primitives.
pendingPredicates =
if (state0 == ScheduledTask.State.Backoff) {
listOf(PendingPredicate.WaitingForBackoff(until = null))
Expand Down
80 changes: 80 additions & 0 deletions docs/concepts/opportunistic-dispatch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Opportunistic dispatch

Background work in Backgrounder is **opportunistic**, not scheduled-to-the-minute. You describe *what* should run and *under what conditions*; the OS decides *when* — based on battery, connectivity, recent app usage, thermal state, and its own system-wide budget. This is the single most-misunderstood thing about background work on every platform, so read this before you reason about timing.

!!! tip "The one-sentence model"
An interval is a **floor, not a promise.** "Every 30 minutes" means *"no more often than every 30 minutes, whenever the system next decides this app deserves a turn"* — which might be 30 minutes, might be 6 hours, might be never until the user opens the app.

## "Do this when the OS gives you space"

iOS apps that refresh feeds, sync mailboxes, or pre-download content have always worked this way. The classic `UIApplication.setMinimumBackgroundFetchInterval` API (now `BGAppRefreshTaskRequest`) never meant "run every N minutes" — it meant "I'd like a turn roughly this often; wake me when conditions are good." Backgrounder keeps that contract and makes it explicit across all platforms.

What "good conditions" means, per platform:

| Platform | Who decides the moment | Primary signal |
| --- | --- | --- |
| **iOS** | `BGTaskScheduler` | App-usage prediction — the system learns *when* you tend to open the app and wakes it shortly before. Plus battery, network, Low Power Mode. |
| **Android** | `WorkManager` → `JobScheduler` | Batches deferrable work into system maintenance windows; respects Doze and App Standby buckets. |
| **macOS** | `NSBackgroundActivityScheduler` | Picks an idle moment within the interval's tolerance window; biased by `qualityOfService`. |
| **JVM** | Library coroutines | In-process; fires on its own timer with no OS gatekeeper (see caveat below). |

The library does **not** add a "run opportunistically" flag — opportunistic *is* the default and the only mode for `WorkRequest.OneTime` / `WorkRequest.Periodic`. There is no "run exactly now on a wall clock" mode, because no mobile OS offers one for deferrable background work. If you need exact wall-clock execution, you want a user-visible alarm/notification (`UNUserNotificationCenter`, `AlarmManager.setExactAndAllowWhileIdle`), which is a different product surface and out of scope for this library.

## Nudging toward idle moments

You can bias dispatch toward genuinely-idle device states with [`WorkConstraints`](../recipes/network-required.md):

```kotlin
WorkRequest.Periodic(
taskId = FeedSync.ID,
interval = 1.hours,
constraints = WorkConstraints(
networkRequired = NetworkRequirement.Unmetered, // Wi-Fi / Ethernet
requiresCharging = true, // on power
requiresDeviceIdle = true, // device not in active use
),
)
```

These are **hints the OS honours to varying degrees** — see the per-platform enforcement table in [`requiresDeviceIdle`](../recipes/network-required.md#require-the-device-to-be-idle). `requiresDeviceIdle` is real OS enforcement on Android (Doze / maintenance windows); on iOS, macOS, and JVM it's advisory — those systems already prefer idle moments themselves and expose no per-request idle knob. Adding constraints makes dispatch *less frequent and later*, never more frequent: every constraint is one more condition the system waits to satisfy.

## What can go wrong

These are the symptoms that send people to the issue tracker. Almost all of them are the opportunistic contract working as designed.

### "My periodic didn't run on time"

It was never going to run "on time." The interval is a floor. The system fired it when it decided your app deserved a turn — which, for an app the user rarely opens, can be many intervals late. **This is correct behaviour.** If you need the work to *catch up* after a long gap, compute the gap inside your worker from your own persisted `lastSyncedAt` — the scheduler fires a late cycle **once**, never N times back-to-back. See [missed cycles](../recipes/periodic.md).

### "It ran fine in development, then stopped on a real device"

App Standby buckets (Android) and usage-prediction (iOS) throttle apps the user has stopped opening. A test device you actively poke keeps the app in a "frequent" bucket; a real user's device demotes a rarely-opened app to "rare," and background dispatch slows to a trickle. Nothing is broken — the OS deprioritised an app the user isn't using.

### "Nothing fires at all on iOS"

Three usual causes, in order of likelihood:

1. **The user force-quit the app.** iOS then refuses *all* background dispatch until the user manually relaunches. This is unfixable by design — see [Force-quit caveat](../platforms/force-quit.md). Surface it in your UX.
2. **Low Power Mode** suspends background refresh entirely.
3. **Missing `Info.plist` entry** or `start()` not called before `didFinishLaunchingWithOptions` returns — see the [iOS launch sequence](../platforms/ios.md).

### "Timing differs between iOS and Android"

Expected. The two systems make independent decisions from different signals. Don't write code that assumes a shared cadence; write workers that are correct *whenever* they run, however far apart.

### "On JVM it fires exactly on schedule — why don't the others?"

The JVM target has **no OS gatekeeper**: it's library-owned coroutines firing on a `delay`-driven timer in your own process ([JVM platform notes](../platforms/jvm.md)). That makes it the odd one out — precise and eager, but only alive while your process is. Don't calibrate your expectations for the mobile targets against JVM behaviour; if anything, JVM is the "exact wall-clock" escape hatch the mobile platforms deliberately withhold.

## How to design for it

- **Make workers idempotent and gap-tolerant.** Assume any run may be the first in hours. Compute "what changed since last time" from your own persisted state, not from the assumption that the previous cycle ran on schedule.
- **Set user expectations in copy, not in code.** "Synced throughout the day," not "synced every 30 minutes."
- **Add constraints to save battery, not to control timing.** `requiresDeviceIdle` / `requiresCharging` / `Unmetered` make the OS *defer* to better conditions — they push dispatch later, which is usually what you want for non-urgent sync.
- **For "soon and small," use [`ExecutionHint.Expedited`](../recipes/retry-backoff.md).** It asks the OS for a sooner, shorter window (iOS App Refresh ~30 s; Android expedited quota). It is still opportunistic — just higher-priority within the system's budget.

## See also

- [Guarantees](guarantees.md) — the per-platform durability / timing / cancellation matrix.
- [Schedule a periodic](../recipes/periodic.md) — interval floors, missed-cycle coalescing, the iOS two-feed model.
- [Force-quit caveat (iOS)](../platforms/force-quit.md) — the one limitation the library genuinely cannot work around.
6 changes: 6 additions & 0 deletions docs/platforms/android.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ In practice this means: **construct `backgrounder` before `super.onCreate()` ret
- The worker's `execute()` runs on a coroutine that inherits that dispatcher; switch with `withContext(Dispatchers.IO)` for blocking IO.
- Logs from inside the worker are tagged `Backgrounder/<taskId>` and the thread is named `Backgrounder/<taskId>` for the duration of `execute()`. This is the mitigation for the single-bridge-worker design — every log includes the task id even though the Worker class is the same for every task.

## Constraints are native

Android is the platform where `WorkConstraints` carries real OS weight. `networkRequired`, `requiresCharging`, and `requiresDeviceIdle` all map straight onto `androidx.work.Constraints` and are enforced by `JobScheduler` — WorkManager will not dispatch the worker until every constraint holds, holding it indefinitely if needed.

`requiresDeviceIdle = true` in particular has no equivalent on the other targets: it maps to `Constraints.Builder.setRequiresDeviceIdle(true)`, so the work runs only when the device enters idle / a Doze maintenance window. A task waiting on it reports `PendingPredicate.RequiresDeviceIdle` from `backgrounder.scheduled()`. On iOS / macOS / JVM the same flag is advisory and ignored — see [Opportunistic dispatch](../concepts/opportunistic-dispatch.md) and [Require the device to be idle](../recipes/network-required.md#require-the-device-to-be-idle).

## Multi-process apps

`Backgrounder.create(application)` must be called in `Application.onCreate` (which runs in *every* process — main and `:remote`), not from an `androidx.startup` initializer (which doesn't run in non-main processes). The factory closure pattern works the same in every process — each process holds its own `Backgrounder` instance, but they share the same `WorkManager` database, so scheduled work is consistent across processes.
2 changes: 1 addition & 1 deletion docs/platforms/jvm.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ fun main() {

Same story as macOS: there is no OS constraint concept, so the library inserts a pre-execution **reachability gate** (powered by [reachable](https://github.com/happycodelucky/reachable)) that waits a bounded window for `WorkConstraints.networkRequired` to be satisfied. On timeout the worker is short-circuited to `WorkResult.Retry` and rescheduled per the request's `BackoffPolicy`. See [Recipes → Require a network connection](../recipes/network-required.md).

`Unmetered` is honoured against `ReachabilityStatus.isDataMetered == false`. Power constraints (`requiresCharging`) are not enforced — there is no portable JVM power API; workers that need charging should check inside `execute()` and return `Retry`.
`Unmetered` is honoured against `ReachabilityStatus.isDataMetered == false`. Power and idle constraints (`requiresCharging`, `requiresDeviceIdle`) are not enforced — there is no portable JVM power or device-idle API; workers that need either precondition should check inside `execute()` and return `Retry`.

## Periodic is a coroutine loop

Expand Down
2 changes: 1 addition & 1 deletion docs/platforms/macos.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate {

The library inserts a pre-execution **reachability gate** that waits up to `min(5 s, ctx.capabilities.maxExecutionTime / 4)` (collapses to ≈5 s under the conservative 5-minute macOS budget) for the requirement to become true. On timeout the worker is short-circuited to `WorkResult.Retry`; `handleOneShotRetry` reschedules a fresh activity with `interval = backoff.delayFor(attempt)`. See [Recipes → Require a network connection](../recipes/network-required.md).

`Unmetered` is honoured against `ReachabilityStatus.isDataMetered == false`. Power constraints (`requiresCharging`) are still not implemented on macOS — workers that need charging should check inside `execute()` and return `Retry`.
`Unmetered` is honoured against `ReachabilityStatus.isDataMetered == false`. Power and idle constraints (`requiresCharging`, `requiresDeviceIdle`) are not enforced on macOS — `NSBackgroundActivityScheduler` exposes neither, though it already biases toward idle moments via `qualityOfService = .background`. Workers needing a charging or idle precondition should check inside `execute()` and return `Retry`.

## Periodic is native

Expand Down
Loading