diff --git a/.claude/lessons/LESSONS.md b/.claude/lessons/LESSONS.md index d937000..d483d83 100644 --- a/.claude/lessons/LESSONS.md +++ b/.claude/lessons/LESSONS.md @@ -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) diff --git a/backgrounder/src/androidMain/kotlin/com/happycodelucky/backgrounder/android/AndroidConstraintsMapper.kt b/backgrounder/src/androidMain/kotlin/com/happycodelucky/backgrounder/android/AndroidConstraintsMapper.kt index 3548e4a..2371de3 100644 --- a/backgrounder/src/androidMain/kotlin/com/happycodelucky/backgrounder/android/AndroidConstraintsMapper.kt +++ b/backgrounder/src/androidMain/kotlin/com/happycodelucky/backgrounder/android/AndroidConstraintsMapper.kt @@ -11,6 +11,7 @@ internal fun WorkConstraints.toWorkManagerConstraints(): Constraints = .Builder() .setRequiredNetworkType(networkRequired.toNetworkType()) .setRequiresCharging(requiresCharging) + .setRequiresDeviceIdle(requiresDeviceIdle) .build() private fun NetworkRequirement.toNetworkType(): NetworkType = diff --git a/backgrounder/src/androidMain/kotlin/com/happycodelucky/backgrounder/android/AndroidScheduledTaskMapper.kt b/backgrounder/src/androidMain/kotlin/com/happycodelucky/backgrounder/android/AndroidScheduledTaskMapper.kt index 74330fe..b20febf 100644 --- a/backgrounder/src/androidMain/kotlin/com/happycodelucky/backgrounder/android/AndroidScheduledTaskMapper.kt +++ b/backgrounder/src/androidMain/kotlin/com/happycodelucky/backgrounder/android/AndroidScheduledTaskMapper.kt @@ -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) { @@ -170,6 +173,7 @@ internal object AndroidScheduledTaskMapper { ConstraintsView( networkType = info.constraints.requiredNetworkType, requiresCharging = info.constraints.requiresCharging(), + requiresDeviceIdle = info.constraints.requiresDeviceIdle(), ), ) } @@ -179,6 +183,7 @@ internal object AndroidScheduledTaskMapper { internal data class ConstraintsView( val networkType: NetworkType, val requiresCharging: Boolean, + val requiresDeviceIdle: Boolean, ) private fun mapState( diff --git a/backgrounder/src/commonMain/kotlin/com/happycodelucky/backgrounder/PendingPredicate.kt b/backgrounder/src/commonMain/kotlin/com/happycodelucky/backgrounder/PendingPredicate.kt index 1a5fe43..1bfd79f 100644 --- a/backgrounder/src/commonMain/kotlin/com/happycodelucky/backgrounder/PendingPredicate.kt +++ b/backgrounder/src/commonMain/kotlin/com/happycodelucky/backgrounder/PendingPredicate.kt @@ -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 diff --git a/backgrounder/src/commonMain/kotlin/com/happycodelucky/backgrounder/WorkConstraints.kt b/backgrounder/src/commonMain/kotlin/com/happycodelucky/backgrounder/WorkConstraints.kt index f1f2710..c429c47 100644 --- a/backgrounder/src/commonMain/kotlin/com/happycodelucky/backgrounder/WorkConstraints.kt +++ b/backgrounder/src/commonMain/kotlin/com/happycodelucky/backgrounder/WorkConstraints.kt @@ -7,8 +7,8 @@ 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( @@ -16,6 +16,21 @@ public data class WorkConstraints( 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, ) /** diff --git a/backgrounder/src/iosMain/kotlin/com/happycodelucky/backgrounder/ios/BGTaskBackedScheduler.kt b/backgrounder/src/iosMain/kotlin/com/happycodelucky/backgrounder/ios/BGTaskBackedScheduler.kt index 84537c4..c3c28a1 100644 --- a/backgrounder/src/iosMain/kotlin/com/happycodelucky/backgrounder/ios/BGTaskBackedScheduler.kt +++ b/backgrounder/src/iosMain/kotlin/com/happycodelucky/backgrounder/ios/BGTaskBackedScheduler.kt @@ -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. */ @@ -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( diff --git a/backgrounder/src/jvmMain/kotlin/com/happycodelucky/backgrounder/jvm/CoroutineBackedScheduler.kt b/backgrounder/src/jvmMain/kotlin/com/happycodelucky/backgrounder/jvm/CoroutineBackedScheduler.kt index 3a126b1..dbc28d9 100644 --- a/backgrounder/src/jvmMain/kotlin/com/happycodelucky/backgrounder/jvm/CoroutineBackedScheduler.kt +++ b/backgrounder/src/jvmMain/kotlin/com/happycodelucky/backgrounder/jvm/CoroutineBackedScheduler.kt @@ -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). diff --git a/backgrounder/src/macosMain/kotlin/com/happycodelucky/backgrounder/macos/NSBackgroundActivityBackedScheduler.kt b/backgrounder/src/macosMain/kotlin/com/happycodelucky/backgrounder/macos/NSBackgroundActivityBackedScheduler.kt index a48c47c..3383de9 100644 --- a/backgrounder/src/macosMain/kotlin/com/happycodelucky/backgrounder/macos/NSBackgroundActivityBackedScheduler.kt +++ b/backgrounder/src/macosMain/kotlin/com/happycodelucky/backgrounder/macos/NSBackgroundActivityBackedScheduler.kt @@ -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)) diff --git a/docs/concepts/opportunistic-dispatch.md b/docs/concepts/opportunistic-dispatch.md new file mode 100644 index 0000000..908a4e9 --- /dev/null +++ b/docs/concepts/opportunistic-dispatch.md @@ -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. diff --git a/docs/platforms/android.md b/docs/platforms/android.md index da3469e..265d80b 100644 --- a/docs/platforms/android.md +++ b/docs/platforms/android.md @@ -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/` and the thread is named `Backgrounder/` 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. diff --git a/docs/platforms/jvm.md b/docs/platforms/jvm.md index bf2663b..d21879e 100644 --- a/docs/platforms/jvm.md +++ b/docs/platforms/jvm.md @@ -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 diff --git a/docs/platforms/macos.md b/docs/platforms/macos.md index 1d2dc02..52ff2a3 100644 --- a/docs/platforms/macos.md +++ b/docs/platforms/macos.md @@ -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 diff --git a/docs/recipes/network-required.md b/docs/recipes/network-required.md index 7ae1faa..fac9b27 100644 --- a/docs/recipes/network-required.md +++ b/docs/recipes/network-required.md @@ -52,6 +52,37 @@ The gate resolves `Unmetered` against `isDataMetered == false`. An iPhone on cel On Android, `Unmetered` translates to `NetworkType.UNMETERED` in WorkManager's native gating — same effect as on Apple, just enforced by the OS. +## Require the device to be idle + +`WorkConstraints.requiresDeviceIdle = true` asks the platform to defer the work until the device is genuinely idle — not in active use, screen off, the kind of moment the OS reserves for low-priority maintenance. It pairs naturally with `requiresCharging` and `Unmetered` for "sync only when it costs the user nothing" background work. + +```kotlin +backgrounder.schedule( + WorkRequest.Periodic( + taskId = FeedSync.ID, + interval = 1.hours, + constraints = WorkConstraints( + networkRequired = NetworkRequirement.Unmetered, + requiresCharging = true, + requiresDeviceIdle = true, + ), + ), +) +``` + +Unlike `networkRequired`, this constraint has **no library-managed gate** — there is no portable "is the device idle?" signal the library could poll, and idle is a fuzzy, OS-internal notion. So it is enforced only where the OS enforces it natively: + +| Platform | `requiresDeviceIdle = true` | Mechanism | +| --- | --- | --- | +| **Android** | **Enforced.** | `Constraints.Builder.setRequiresDeviceIdle(true)` → `JobScheduler`. WorkManager holds the worker until the device enters idle / a Doze maintenance window. | +| **iOS** | **Advisory (no-op).** | `BGProcessingTaskRequest` exposes no idle property — only `requiresExternalPower` and `requiresNetworkConnectivity`. The system *already* prefers idle/charging moments for processing tasks using its own criteria. The library logs a one-line warning and otherwise ignores the flag. | +| **macOS** | **Advisory (no-op).** | `NSBackgroundActivityScheduler` has no idle primitive; it already biases toward idle via `qualityOfService = .background`. Silently ignored. | +| **JVM** | **Advisory (no-op).** | No OS idle concept at all. Silently ignored. | + +Because it's enforced only on Android, treat `requiresDeviceIdle` the same way you treat `requiresCharging`: a **battery-saving deferral**, not a timing guarantee. On the advisory platforms the work still runs — just whenever the system's normal opportunistic scheduling fires it, with no extra idle gate. See [Opportunistic dispatch](../concepts/opportunistic-dispatch.md) for why "advisory" is the honest answer rather than a missing feature. + +Where it surfaces in inspection: on Android, a task waiting on this constraint reports `PendingPredicate.RequiresDeviceIdle` from [`backgrounder.scheduled()`](inspect.md) — mirroring `PendingPredicate.RequiresCharging`. The advisory platforms never emit it (there's no gate to be blocked on). + ## Reading reachability from inside `execute()` The library doesn't proxy reachability through `WorkerContext`. If you want to inspect the current state inside a worker — for instance, to defer a large transfer on metered networks without forbidding metered entirely — read `Reachability.shared` directly: @@ -101,5 +132,5 @@ Backgrounder's public `Backgrounder.create(...)` factory has no `reachability:` ## Common pitfalls - **Don't probe the network from inside `execute()`** if you've set `networkRequired = Any`. The gate has already waited; if it timed out you'll be running with `WorkResult.Retry` not invoked at all, so the body never even ran. Trust the gate. -- **`requiresCharging` is not honoured on Apple.** `WorkConstraints.requiresCharging` only works on Android (via WorkManager). On iOS / macOS the library would need a separate power-state library to wait for charging; that's out of scope today. +- **`requiresCharging` and `requiresDeviceIdle` are Android-only.** Both only gate dispatch on Android (via WorkManager). On iOS / macOS / JVM they're advisory: `requiresCharging` would need a separate power-state library to wait on, and there's no portable idle signal at all — see [Require the device to be idle](#require-the-device-to-be-idle). The work still runs on those platforms; it just isn't held back by these two conditions. - **The gate is per-invocation, not per-schedule.** Each time the OS fires a worker, the gate runs again with that fire's budget. A flaky network produces a sequence of `Retry`s rather than one long hang. diff --git a/mkdocs.yml b/mkdocs.yml index 12d419e..ebb7281 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -109,6 +109,7 @@ nav: - Concepts: - concepts/architecture.md - concepts/worker-context-and-di.md + - concepts/opportunistic-dispatch.md - concepts/ephemeral.md - concepts/guarantees.md - Recipes: