Comprehensive guide for configuring KMP WorkManager on Android and iOS.
Add to your build.gradle.kts:
kotlin {
sourceSets {
androidMain.dependencies {
// KMP WorkManager (required)
implementation("dev.brewkits:kmpworkmanager:2.4.0")
// WorkManager (optional - already included transitively)
implementation("androidx.work:work-runtime-ktx:2.11.0")
// For Kotlin coroutines support
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
}
}
}<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Schedule exact alarms (Android 12+) -->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<!-- Post notifications (Android 13+) -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- Foreground service for heavy tasks -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<!-- Wake lock (for exact alarms) -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<!-- Internet (if your tasks need network) -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application
android:name=".KMPWorkManagerApp"
...>
<!-- WorkManager Worker -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup" />
</provider>
<!-- Alarm Receiver for exact alarms -->
<receiver
android:name="dev.brewkits.kmpworkmanager.sample.background.data.AlarmReceiver"
android:enabled="true"
android:exported="false" />
</application>
</manifest>Create an Application class to initialize Koin:
class KMPWorkManagerApp : Application() {
override fun onCreate() {
super.onCreate()
startKoin {
androidLogger(Level.DEBUG)
androidContext(this@KMPWorkManagerApp)
modules(kmpWorkerModule())
}
// Optional: Configure WorkManager
configureWorkManager()
}
private fun configureWorkManager() {
val config = Configuration.Builder()
.setMinimumLoggingLevel(Log.DEBUG)
.setWorkerFactory(DefaultWorkerFactory())
.build()
WorkManager.initialize(this, config)
}
}Register in AndroidManifest.xml:
<application
android:name=".KMPWorkManagerApp"
...>
</application>For Android 13+, request notification permission at runtime:
class MainActivity : ComponentActivity() {
private val notificationPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
if (isGranted) {
println("Notification permission granted")
} else {
println("Notification permission denied")
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
notificationPermissionLauncher.launch(
Manifest.permission.POST_NOTIFICATIONS
)
}
// Request exact alarm permission for Android 12+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val alarmManager = getSystemService(AlarmManager::class.java)
if (!alarmManager.canScheduleExactAlarms()) {
Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).also {
startActivity(it)
}
}
}
}
}Add to proguard-rules.pro:
# Keep WorkManager classes
-keep class androidx.work.** { *; }
-keep class dev.brewkits.kmpworkmanager.sample.background.** { *; }
# Keep Koin classes
-keep class org.koin.** { *; }
# Keep Kotlin coroutines
-keepclassmembernames class kotlinx.** { *; }
Add your worker logic to KmpWorker.kt:
class KmpWorker(
context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result {
val workerClassName = inputData.getString("workerClassName") ?: return Result.failure()
val input = inputData.getString("input")
Logger.i(LogTags.WORKER, "Executing worker: $workerClassName")
return when (workerClassName) {
"SyncWorker" -> executeSyncWorker(input)
"UploadWorker" -> executeUploadWorker(input)
// Add your workers here
else -> {
Logger.e(LogTags.WORKER, "Unknown worker: $workerClassName")
Result.failure()
}
}
}
private suspend fun executeSyncWorker(input: String?): Result {
return try {
// Your sync logic here
delay(2000)
TaskEventBus.emit(
TaskCompletionEvent("SyncWorker", true, "✅ Sync complete")
)
Result.success()
} catch (e: Exception) {
Logger.e(LogTags.WORKER, "Sync failed", e)
Result.retry()
}
}
private suspend fun executeUploadWorker(input: String?): Result {
return try {
// Your upload logic here
delay(3000)
TaskEventBus.emit(
TaskCompletionEvent("UploadWorker", true, "✅ Upload complete")
)
Result.success()
} catch (e: Exception) {
Logger.e(LogTags.WORKER, "Upload failed", e)
Result.retry()
}
}
}For tasks longer than 10 minutes, set isHeavyTask = true:
scheduler.enqueue(
id = "ml-training",
trigger = TaskTrigger.OneTime(),
workerClassName = "MLTrainingWorker",
constraints = Constraints(
isHeavyTask = true,
requiresCharging = true
)
)This uses KmpHeavyWorker which runs as a foreground service.
For high-priority tasks that need to run ASAP:
scheduler.enqueue(
id = "urgent-sync",
trigger = TaskTrigger.OneTime(),
workerClassName = "SyncWorker",
constraints = Constraints(
expedited = true
)
)Monitor MediaStore changes:
scheduler.enqueue(
id = "media-observer",
trigger = TaskTrigger.ContentUri(
uriString = "content://media/external/images/media",
triggerForDescendants = true
),
workerClassName = "MediaSyncWorker"
)Warning
Critical iOS Limitations - Read Before Implementing
iOS background tasks are fundamentally different from Android:
- Opportunistic Execution: The system decides when to run tasks. Tasks may be delayed hours or never run.
- Strict Time Limits: BGAppRefreshTask has ~30 seconds max, BGProcessingTask has ~60 seconds.
- Force-Quit Termination: All background tasks are immediately killed when user force-quits the app.
- Limited Constraints: iOS only supports network constraints. Charging, battery, and storage constraints are not available.
Do NOT use iOS background tasks for:
- Time-critical operations
- Long-running processes (> 30s)
- Operations that must complete reliably
See iOS Best Practices for detailed guidance and iOS Migration Guide for converting Android patterns to iOS.
Add background task identifiers and capabilities:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Background Task Identifiers -->
<key>BGTaskSchedulerPermittedIdentifiers</key>
<array>
<string>periodic-sync-task</string>
<string>upload-task</string>
<string>heavy-processing-task</string>
<string>kmp_chain_executor_task</string>
</array>
<!-- Background Modes -->
<key>UIBackgroundModes</key>
<array>
<string>processing</string>
<string>fetch</string>
<string>remote-notification</string>
</array>
<!-- Disable Scene-based lifecycle (if using traditional AppDelegate) -->
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
</dict>
</dict>
</plist>Open your Xcode project and verify:
-
Signing & Capabilities:
- Add "Background Modes" capability
- Enable "Background fetch" and "Background processing"
-
Build Settings:
- Set
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = NO(if using AppDelegate) - Ensure deployment target is iOS 13.0 or higher
- Set
-
General:
- Verify bundle identifier matches your configuration
Before initializing, you must create a worker factory:
// iosMain/background/MyWorkerFactory.kt
class MyWorkerFactory : IosWorkerFactory {
override fun createWorker(workerClassName: String): IosWorker? {
return when (workerClassName) {
"SyncWorker" -> SyncWorker()
"UploadWorker" -> UploadWorker()
"HeavyProcessingWorker" -> HeavyProcessingWorker()
else -> {
Logger.e(LogTags.FACTORY, "Unknown worker: $workerClassName")
null
}
}
}
}Register factory in your iOS module:
// iosMain/di/IOSModule.kt
val iosModule = module {
// Register your worker factory
factory { MyWorkerFactory() }
// Other iOS-specific dependencies
single<BackgroundTaskScheduler> { NativeTaskScheduler() }
// ...
}Create or update iOSApp.swift:
import SwiftUI
import BackgroundTasks
import composeApp // Your shared framework name
@main
struct iOSApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
class AppDelegate: NSObject, UIApplicationDelegate {
func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// Initialize Koin with your platform module
// This module should include your WorkerFactory
KoinInitializerKt.doInitKoin(platformModule: IOSModuleKt.iosModule)
// Register background tasks
registerBackgroundTasks()
// Request notification permissions
requestNotificationPermissions()
return true
}
private func registerBackgroundTasks() {
let scheduler = koinIos.getScheduler()
let executor = koinIos.getSingleTaskExecutor()
let chainExecutor = koinIos.getChainExecutor()
// Periodic sync task (BGAppRefreshTask)
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "periodic-sync-task",
using: nil
) { task in
IosBackgroundTaskHandler.shared.handleSingleTask(
task: task,
scheduler: scheduler,
executor: executor
)
}
// Heavy processing task (BGProcessingTask)
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "heavy-processing-task",
using: nil
) { task in
IosBackgroundTaskHandler.shared.handleSingleTask(
task: task,
scheduler: scheduler,
executor: executor
)
}
// Chain executor task
BGTaskScheduler.shared.register(
forTaskWithIdentifier: "kmp_chain_executor_task",
using: nil
) { task in
IosBackgroundTaskHandler.shared.handleChainExecutorTask(
task: task,
chainExecutor: chainExecutor
)
}
}
private func requestNotificationPermissions() {
UNUserNotificationCenter.current().requestAuthorization(
options: [.alert, .sound, .badge]
) { granted, error in
if granted {
print("Notification permission granted")
} else if let error = error {
print("Notification permission error: \(error)")
}
}
}
func applicationDidEnterBackground(_ application: UIApplication) {
// Background tasks can now execute
print("App entered background")
}
}Create worker classes in iosMain/background/workers/:
// SyncWorker.kt
class SyncWorker : IosWorker {
override suspend fun doWork(
input: String?,
env: WorkerEnvironment
): WorkerResult {
return try {
// IMPORTANT: Must complete within 25 seconds for BGAppRefreshTask
// or within a few minutes for BGProcessingTask
Logger.i(LogTags.WORKER, "iOS SyncWorker started")
delay(2000) // Simulate work
WorkerResult.Success("✅ iOS sync complete")
} catch (e: Exception) {
Logger.e(LogTags.WORKER, "iOS sync failed", e)
WorkerResult.Failure(e.message ?: "Unknown error")
}
}
}Important: Workers must be registered in your MyWorkerFactory (see Step 3 above).
// Light task (BGAppRefreshTask - 25 seconds max)
scheduler.enqueue(
id = "quick-sync",
trigger = TaskTrigger.Periodic(15_MINUTES),
workerClassName = "SyncWorker",
constraints = Constraints(
isHeavyTask = false // Uses BGAppRefreshTask
)
)
// Heavy task (BGProcessingTask - several minutes)
scheduler.enqueue(
id = "ml-training",
trigger = TaskTrigger.OneTime(),
workerClassName = "MLTrainingWorker",
constraints = Constraints(
isHeavyTask = true // Uses BGProcessingTask
)
)Control task priority on iOS:
scheduler.enqueue(
id = "high-priority-sync",
trigger = TaskTrigger.Periodic(15_MINUTES),
workerClassName = "SyncWorker",
constraints = Constraints(
qos = QualityOfService.HIGH // User-initiated priority
)
)For remote-triggered tasks, configure APNS:
- Enable "Remote notifications" in Background Modes
- Send silent push with
content-available: 1 - Handle in
didReceiveRemoteNotification:
func application(
_ application: UIApplication,
didReceiveRemoteNotification userInfo: [AnyHashable : Any],
fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
let koinIos = KoinIOS()
let scheduler = koinIos.getScheduler()
// Trigger background task
Task {
await scheduler.enqueue(
id: "push-triggered-sync",
trigger: TaskTriggerOneTime(initialDelayMs: 0),
workerClassName: "SyncWorker",
input: nil,
constraints: Constraints()
)
completionHandler(.newData)
}
}# View all scheduled tasks
adb shell dumpsys jobscheduler | grep KmpWorker
# Force run a task (requires WorkManager Test helpers)
adb shell am broadcast -a androidx.work.diagnostics.REQUEST_DIAGNOSTICS
# Check WorkManager database
adb shell sqlite3 /data/data/YOUR.PACKAGE.NAME/databases/androidx.work.workdatabase "SELECT * FROM WorkSpec"# Unplug device
adb shell dumpsys battery unplug
# Enter Doze mode
adb shell dumpsys deviceidle force-idle
# Exit Doze mode
adb shell dumpsys deviceidle unforce
# Reset battery
adb shell dumpsys battery reset# Check if app can schedule exact alarms
adb shell dumpsys alarm | grep YOUR.PACKAGE.NAME
# View next alarm
adb shell dumpsys alarm | grep -A 20 "Next alarm clock"In Xcode, run the app and pause at a breakpoint, then in LLDB console:
# Force execute a BGAppRefreshTask
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"periodic-sync-task"]
# Force execute a BGProcessingTask
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"heavy-processing-task"]
Add launch arguments in Xcode scheme:
- Product → Scheme → Edit Scheme
- Run → Arguments → Arguments Passed On Launch
- Add:
-BGTaskSchedulerSimulateEarlyTermination
This simulates app termination during background task execution.
# View iOS device logs
xcrun simctl spawn booted log stream --predicate 'subsystem == "com.apple.BGTaskScheduler"' --level debug
# Or use Console.app to filter by BGTaskScheduler- Connect device to Xcode
- Run app and send to background (Home button)
- Wait or trigger via LLDB (connect debugger to running app)
- Check logs in Xcode Console
Important: BGTasks only run on physical devices when app is truly in background and system decides to execute them. Testing requires patience or LLDB simulation.
Problem: Scheduled tasks never execute
Solutions:
-
Check WorkManager initialization:
val workManager = WorkManager.getInstance(context) val workInfos = workManager.getWorkInfosForUniqueWork("task-id").get() println("Work state: ${workInfos.firstOrNull()?.state}")
-
Verify constraints are met:
adb shell dumpsys battery unplug adb shell svc wifi enable -
Check for Doze mode restrictions:
adb shell dumpsys battery unplug adb shell dumpsys deviceidle whitelist +YOUR.PACKAGE.NAME
Problem: TaskTrigger.Exact doesn't fire
Solutions:
-
Check permission:
val alarmManager = getSystemService(AlarmManager::class.java) val canSchedule = alarmManager.canScheduleExactAlarms() println("Can schedule exact alarms: $canSchedule")
-
Request permission manually:
val intent = Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM) startActivity(intent)
-
Verify
AlarmReceiveris registered inAndroidManifest.xml
Problem: KmpHeavyWorker crashes with ForegroundServiceStartNotAllowedException
Solutions:
-
Add foreground service permission:
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
-
Request notification permission on Android 13+
-
Create proper notification channel:
val channel = NotificationChannel( "heavy_task_channel", "Heavy Tasks", NotificationManager.IMPORTANCE_LOW ) notificationManager.createNotificationChannel(channel)
Problem: BGTasks never run on device
Solutions:
-
Verify Info.plist configuration:
- Check
BGTaskSchedulerPermittedIdentifiersarray - Ensure task IDs match exactly (case-sensitive)
- Check
-
Check AppDelegate registration:
BGTaskScheduler.shared.register( forTaskWithIdentifier: "periodic-sync-task", using: nil ) { task in // Handler must be registered BEFORE task is scheduled }
-
App must be in background:
- Press Home button to background app
- Wait several minutes or hours (iOS decides when to run)
- Use LLDB to force execution for testing
-
Check system logs:
log stream --predicate 'subsystem == "com.apple.BGTaskScheduler"' --level debug
Problem: BGAppRefreshTask terminates after 25 seconds
Solutions:
-
Use BGProcessingTask for longer work:
constraints = Constraints(isHeavyTask = true)
-
Optimize worker to complete faster:
class SyncWorker : IosWorker { override suspend fun doWork(input: String?): Boolean { withTimeout(20_000) { // Complete within 20 seconds // Fast sync logic } return true } }
-
Split work into chains:
scheduler .beginWith(TaskRequest(workerClassName = "QuickSync1")) .then(TaskRequest(workerClassName = "QuickSync2")) .enqueue()
Problem: IosWorkerFactory returns null
Solutions:
-
Register worker in factory:
object IosWorkerFactory { fun createWorker(className: String): IosWorker? { return when (className) { "SyncWorker" -> SyncWorker() else -> null } } }
-
Check worker class name spelling (case-sensitive)
-
Verify worker implements
IosWorkerinterface
Problem: Task runs once but doesn't repeat
Solution: The library's IosBackgroundTaskHandler automatically re-schedules periodic tasks upon successful completion. Ensure you are using this handler in your AppDelegate:
IosBackgroundTaskHandler.shared.handleSingleTask(
task: task,
scheduler: koin.getScheduler(),
executor: koin.getExecutor()
)- Use
expedited = truefor urgent tasks (< 10 minutes) - Use
isHeavyTask = truefor long tasks (> 10 minutes) - Always handle
Result.retry()for transient failures - Request permissions before scheduling tasks
- Test in Doze mode to ensure tasks run when expected
- Keep BGAppRefreshTask workers under 20 seconds
- Use BGProcessingTask (
isHeavyTask = true) for heavy work - Use
IosBackgroundTaskHandlerto automate re-scheduling and metadata resolution - Register task handlers BEFORE scheduling tasks
- Test on physical devices (simulator behavior differs)
- Use LLDB commands for testing (don't wait hours for iOS to trigger)
- Metadata is persisted automatically in
Library/Application Support(IosFileStorage)
- Quick Start Guide - Get started quickly
- API Reference - Complete API documentation
- Task Chains - Build complex workflows
- Constraints & Triggers - All trigger types
Need help? Open an issue or ask in Discussions.