
A Kotlin Multiplatform library for managing background work and scheduling. It provides a unified API similar to Android Jetpack WorkManager, supporting Android, iOS, and Desktop targets.
// build.gradle.kts
commonMain.dependencies {
implementation("dev.brewkits:kmpworkmanager:2.3.9")
}class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
KmpWorkManager.initialize(this)
}
}1. Worker factory (iosMain):
class AppWorkerFactory : IosWorkerFactory {
override fun createWorker(workerClassName: String): IosWorker? = when (workerClassName) {
"SyncWorker" -> SyncWorkerIos()
"UploadWorker" -> UploadWorkerIos()
else -> null
}
}2. AppDelegate:
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
override init() {
super.init()
KoinInitializerKt.doInitKoin(platformModule: IOSModuleKt.iosModule)
}
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "kmp_chain_executor_task",
using: nil
) { task in
IosBackgroundTaskHandlerKt.handleChainExecutorTask(task as! BGProcessingTask)
}
return true
}
}3. Info.plist:
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>kmp_chain_executor_task</string>
</array>Full setup: docs/platform-setup.md
// One-time — runs as soon as constraints are met
scheduler.enqueue(
id = "nightly-sync",
trigger = TaskTrigger.OneTime(initialDelayMs = 0),
workerClassName = "SyncWorker",
constraints = Constraints(requiresNetwork = true)
)
// Periodic — every 15 minutes
scheduler.enqueue(
id = "heartbeat",
trigger = TaskTrigger.Periodic(intervalMs = 15 * 60 * 1000L),
workerClassName = "SyncWorker"
)// commonMain — shared logic
class SyncWorker : Worker {
override suspend fun doWork(input: String?, env: WorkerEnvironment): WorkerResult {
val items = api.fetchPendingItems()
database.upsert(items)
return WorkerResult.Success(
message = "Synced ${items.size} items",
data = mapOf("count" to items.size)
)
}
}// androidMain
class SyncWorkerAndroid : AndroidWorker {
override suspend fun doWork(input: String?, env: WorkerEnvironment) =
SyncWorker().doWork(input, env)
}
// iosMain
class SyncWorkerIos : IosWorker {
override suspend fun doWork(input: String?, env: WorkerEnvironment) =
SyncWorker().doWork(input, env)
}// Multi-step workflows that survive process death.
// If step 47 of 100 was running when iOS killed the app —
// the next BGTask invocation resumes at step 47, not step 0.
scheduler.beginWith(TaskRequest("DownloadWorker", inputJson = """{"url":"$fileUrl"}"""))
.then(TaskRequest("ValidateWorker"))
.then(TaskRequest("TranscodeWorker"))
.then(TaskRequest("UploadWorker", inputJson = """{"bucket":"processed"}"""))
.withId("transcode-pipeline", policy = ExistingPolicy.KEEP)
.enqueue()Most KMP libraries wrap the happy path — iOS BGTaskScheduler is not just "a different API." It has a credit system that punishes apps overrunning their time budget, an opaque scheduling policy, and no recovery mechanism for incomplete work. Getting it wrong means your tasks silently stop running.
| Android | iOS | |
|---|---|---|
| Scheduling | Deterministic via WorkManager | Opportunistic — OS decides when |
| Exact timing | ✅ AlarmManager | |
| Chain recovery | ✅ WorkContinuation | ✅ Step-level persistence |
| Time budget enforcement | — | ✅ Adaptive (reserves 15–30% safety margin) |
| Queue integrity | ✅ | ✅ CRC32-verified binary format |
| Thread-safe expiry | ✅ | ✅ AtomicInt shutdown flag |
| Trigger | Android | iOS | Notes |
|---|---|---|---|
OneTime(delayMs) |
WorkManager | BGTaskScheduler | Minimum delay may be enforced by OS |
Periodic(intervalMs) |
WorkManager | BGTaskScheduler | Min 15 min on both platforms |
Exact(epochMs) |
AlarmManager | Best-effort | iOS cannot guarantee exact timing |
Windowed(earliest, latest) |
WorkManager with delay | BGTaskScheduler | Preferred over Exact on iOS |
ContentUri(uri) |
WorkManager ContentUriTrigger | — | Android only |
Every chain execution is persisted locally. Collect, upload, clear:
lifecycleScope.launch {
val records = scheduler.getExecutionHistory(limit = 200)
if (records.isNotEmpty()) {
analyticsService.uploadBatch(records)
scheduler.clearExecutionHistory()
}
}Each ExecutionRecord carries chainId, status (SUCCESS / FAILURE / ABANDONED / SKIPPED / TIMEOUT), durationMs, step counts, error message, retry count, and platform. Up to 500 records kept; older ones pruned automatically.
Route task lifecycle events to Sentry, Crashlytics, or Datadog:
KmpWorkManagerConfig(
telemetryHook = object : TelemetryHook {
override fun onTaskFailed(event: TelemetryHook.TaskFailedEvent) {
Sentry.captureMessage("Task failed: ${event.taskName} — ${event.error}")
}
override fun onChainFailed(event: TelemetryHook.ChainFailedEvent) {
analytics.track("chain_failed", mapOf(
"chainId" to event.chainId,
"failedStep" to event.failedStep
))
}
}
)Six events: onTaskStarted, onTaskCompleted, onTaskFailed, onChainCompleted, onChainFailed, onChainSkipped. All have default no-op implementations.
LOW, NORMAL, HIGH, CRITICAL. On Android, HIGH/CRITICAL map to expedited work. On iOS, the queue is sorted by priority before each BGTask window:
scheduler.beginWith(
TaskRequest(workerClassName = "PaymentSyncWorker", priority = TaskPriority.CRITICAL)
).enqueue()KmpWorkManagerConfig(minBatteryLevelPercent = 10) // defer when < 10% and not chargingDefault 5%. Works on both platforms.
// iosMain
@Worker("SyncWorker", bgTaskId = "com.example.sync-task")
class SyncWorker : IosWorker { ... }
// kmpWorkerModule() automatically validates bgTaskId against Info.plist at startup
kmpWorkerModule(workerFactory = IosWorkerFactoryGenerated())Add to build.gradle.kts:
plugins { id("com.google.devtools.ksp") }
dependencies {
ksp("dev.brewkits:kmpworker-ksp:2.3.9")
commonMain.implementation("dev.brewkits:kmpworker-annotations:2.3.9")
}| Worker | Purpose |
|---|---|
HttpRequestWorker |
HTTP request with configurable method, headers, body |
HttpDownloadWorker |
Resumable file download to local storage |
HttpUploadWorker |
Multipart file upload |
HttpSyncWorker |
Fetch-and-persist data sync |
FileCompressionWorker |
File compression (Android: built-in · iOS: requires ZIPFoundation) |
SSRF protection — all built-in worker HTTP calls are validated before dispatch. Blocked:
169.254.169.254 AWS/GCP/Azure IMDS
fd00:ec2::254 AWS EC2 (IPv6)
100.100.100.200 Alibaba Cloud metadata
localhost, 0.0.0.0, [::1], 10.x, 172.16–31.x, 192.168.x
RFC 3986 UserInfo bypass and multi-@ authority attacks are both handled. DNS rebinding is a known limitation — use certificate pinning or an egress proxy for high-trust environments.
Input size validation — inputs exceeding WorkManager's 10 KB Data limit throw IllegalArgumentException at enqueue time.
600+ tests across commonTest, iosTest, androidInstrumentedTest
QA_PersistenceResilienceTest — 100-step chain killed at step 50, resumes at exactly step 50V236ChainExecutorTest — time budget, shutdown propagation, batch loop correctnessIosExecutionHistoryStoreTest — save/get/clear, auto-pruning, all status variantsAppendOnlyQueueTest — CRC32 corruption detection, truncation recovery, concurrent accessSecurityValidatorTest — SSRF, IPv6 compressed loopback, multi-@ UserInfo bypass| Quick Start | Running in 5 minutes |
| Platform Setup | Android & iOS configuration |
| API Reference | Full public API |
| Task Chains | Chain API and recovery semantics |
| Built-in Workers | Worker reference and input schema |
| Constraints & Triggers | All scheduling options |
| iOS Best Practices | BGTask gotchas and recommendations |
| Troubleshooting | Common issues |
| CHANGELOG | Release history |
Migration: v2.2.2 → v2.3.0 · v2.3.3 → v2.3.4
| Kotlin | 2.1.0+ |
| Android | 8.0+ (API 26) |
| iOS | 13.0+ |
| Gradle | 8.0+ |
./gradlew :kmpworker:allTests # all platforms must pass before opening a PRCommit messages follow Conventional Commits.
Apache 2.0. See LICENSE.
// build.gradle.kts
commonMain.dependencies {
implementation("dev.brewkits:kmpworkmanager:2.3.9")
}class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
KmpWorkManager.initialize(this)
}
}1. Worker factory (iosMain):
class AppWorkerFactory : IosWorkerFactory {
override fun createWorker(workerClassName: String): IosWorker? = when (workerClassName) {
"SyncWorker" -> SyncWorkerIos()
"UploadWorker" -> UploadWorkerIos()
else -> null
}
}2. AppDelegate:
@main
class AppDelegate: UIResponder, UIApplicationDelegate {
override init() {
super.init()
KoinInitializerKt.doInitKoin(platformModule: IOSModuleKt.iosModule)
}
func application(_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "kmp_chain_executor_task",
using: nil
) { task in
IosBackgroundTaskHandlerKt.handleChainExecutorTask(task as! BGProcessingTask)
}
return true
}
}3. Info.plist:
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>kmp_chain_executor_task</string>
</array>Full setup: docs/platform-setup.md
// One-time — runs as soon as constraints are met
scheduler.enqueue(
id = "nightly-sync",
trigger = TaskTrigger.OneTime(initialDelayMs = 0),
workerClassName = "SyncWorker",
constraints = Constraints(requiresNetwork = true)
)
// Periodic — every 15 minutes
scheduler.enqueue(
id = "heartbeat",
trigger = TaskTrigger.Periodic(intervalMs = 15 * 60 * 1000L),
workerClassName = "SyncWorker"
)// commonMain — shared logic
class SyncWorker : Worker {
override suspend fun doWork(input: String?, env: WorkerEnvironment): WorkerResult {
val items = api.fetchPendingItems()
database.upsert(items)
return WorkerResult.Success(
message = "Synced ${items.size} items",
data = mapOf("count" to items.size)
)
}
}// androidMain
class SyncWorkerAndroid : AndroidWorker {
override suspend fun doWork(input: String?, env: WorkerEnvironment) =
SyncWorker().doWork(input, env)
}
// iosMain
class SyncWorkerIos : IosWorker {
override suspend fun doWork(input: String?, env: WorkerEnvironment) =
SyncWorker().doWork(input, env)
}// Multi-step workflows that survive process death.
// If step 47 of 100 was running when iOS killed the app —
// the next BGTask invocation resumes at step 47, not step 0.
scheduler.beginWith(TaskRequest("DownloadWorker", inputJson = """{"url":"$fileUrl"}"""))
.then(TaskRequest("ValidateWorker"))
.then(TaskRequest("TranscodeWorker"))
.then(TaskRequest("UploadWorker", inputJson = """{"bucket":"processed"}"""))
.withId("transcode-pipeline", policy = ExistingPolicy.KEEP)
.enqueue()Most KMP libraries wrap the happy path — iOS BGTaskScheduler is not just "a different API." It has a credit system that punishes apps overrunning their time budget, an opaque scheduling policy, and no recovery mechanism for incomplete work. Getting it wrong means your tasks silently stop running.
| Android | iOS | |
|---|---|---|
| Scheduling | Deterministic via WorkManager | Opportunistic — OS decides when |
| Exact timing | ✅ AlarmManager | |
| Chain recovery | ✅ WorkContinuation | ✅ Step-level persistence |
| Time budget enforcement | — | ✅ Adaptive (reserves 15–30% safety margin) |
| Queue integrity | ✅ | ✅ CRC32-verified binary format |
| Thread-safe expiry | ✅ | ✅ AtomicInt shutdown flag |
| Trigger | Android | iOS | Notes |
|---|---|---|---|
OneTime(delayMs) |
WorkManager | BGTaskScheduler | Minimum delay may be enforced by OS |
Periodic(intervalMs) |
WorkManager | BGTaskScheduler | Min 15 min on both platforms |
Exact(epochMs) |
AlarmManager | Best-effort | iOS cannot guarantee exact timing |
Windowed(earliest, latest) |
WorkManager with delay | BGTaskScheduler | Preferred over Exact on iOS |
ContentUri(uri) |
WorkManager ContentUriTrigger | — | Android only |
Every chain execution is persisted locally. Collect, upload, clear:
lifecycleScope.launch {
val records = scheduler.getExecutionHistory(limit = 200)
if (records.isNotEmpty()) {
analyticsService.uploadBatch(records)
scheduler.clearExecutionHistory()
}
}Each ExecutionRecord carries chainId, status (SUCCESS / FAILURE / ABANDONED / SKIPPED / TIMEOUT), durationMs, step counts, error message, retry count, and platform. Up to 500 records kept; older ones pruned automatically.
Route task lifecycle events to Sentry, Crashlytics, or Datadog:
KmpWorkManagerConfig(
telemetryHook = object : TelemetryHook {
override fun onTaskFailed(event: TelemetryHook.TaskFailedEvent) {
Sentry.captureMessage("Task failed: ${event.taskName} — ${event.error}")
}
override fun onChainFailed(event: TelemetryHook.ChainFailedEvent) {
analytics.track("chain_failed", mapOf(
"chainId" to event.chainId,
"failedStep" to event.failedStep
))
}
}
)Six events: onTaskStarted, onTaskCompleted, onTaskFailed, onChainCompleted, onChainFailed, onChainSkipped. All have default no-op implementations.
LOW, NORMAL, HIGH, CRITICAL. On Android, HIGH/CRITICAL map to expedited work. On iOS, the queue is sorted by priority before each BGTask window:
scheduler.beginWith(
TaskRequest(workerClassName = "PaymentSyncWorker", priority = TaskPriority.CRITICAL)
).enqueue()KmpWorkManagerConfig(minBatteryLevelPercent = 10) // defer when < 10% and not chargingDefault 5%. Works on both platforms.
// iosMain
@Worker("SyncWorker", bgTaskId = "com.example.sync-task")
class SyncWorker : IosWorker { ... }
// kmpWorkerModule() automatically validates bgTaskId against Info.plist at startup
kmpWorkerModule(workerFactory = IosWorkerFactoryGenerated())Add to build.gradle.kts:
plugins { id("com.google.devtools.ksp") }
dependencies {
ksp("dev.brewkits:kmpworker-ksp:2.3.9")
commonMain.implementation("dev.brewkits:kmpworker-annotations:2.3.9")
}| Worker | Purpose |
|---|---|
HttpRequestWorker |
HTTP request with configurable method, headers, body |
HttpDownloadWorker |
Resumable file download to local storage |
HttpUploadWorker |
Multipart file upload |
HttpSyncWorker |
Fetch-and-persist data sync |
FileCompressionWorker |
File compression (Android: built-in · iOS: requires ZIPFoundation) |
SSRF protection — all built-in worker HTTP calls are validated before dispatch. Blocked:
169.254.169.254 AWS/GCP/Azure IMDS
fd00:ec2::254 AWS EC2 (IPv6)
100.100.100.200 Alibaba Cloud metadata
localhost, 0.0.0.0, [::1], 10.x, 172.16–31.x, 192.168.x
RFC 3986 UserInfo bypass and multi-@ authority attacks are both handled. DNS rebinding is a known limitation — use certificate pinning or an egress proxy for high-trust environments.
Input size validation — inputs exceeding WorkManager's 10 KB Data limit throw IllegalArgumentException at enqueue time.
600+ tests across commonTest, iosTest, androidInstrumentedTest
QA_PersistenceResilienceTest — 100-step chain killed at step 50, resumes at exactly step 50V236ChainExecutorTest — time budget, shutdown propagation, batch loop correctnessIosExecutionHistoryStoreTest — save/get/clear, auto-pruning, all status variantsAppendOnlyQueueTest — CRC32 corruption detection, truncation recovery, concurrent accessSecurityValidatorTest — SSRF, IPv6 compressed loopback, multi-@ UserInfo bypass| Quick Start | Running in 5 minutes |
| Platform Setup | Android & iOS configuration |
| API Reference | Full public API |
| Task Chains | Chain API and recovery semantics |
| Built-in Workers | Worker reference and input schema |
| Constraints & Triggers | All scheduling options |
| iOS Best Practices | BGTask gotchas and recommendations |
| Troubleshooting | Common issues |
| CHANGELOG | Release history |
Migration: v2.2.2 → v2.3.0 · v2.3.3 → v2.3.4
| Kotlin | 2.1.0+ |
| Android | 8.0+ (API 26) |
| iOS | 13.0+ |
| Gradle | 8.0+ |
./gradlew :kmpworker:allTests # all platforms must pass before opening a PRCommit messages follow Conventional Commits.
Apache 2.0. See LICENSE.