Skip to content

Latest commit

 

History

History
918 lines (697 loc) · 25.6 KB

File metadata and controls

918 lines (697 loc) · 25.6 KB

Platform Setup Guide

Comprehensive guide for configuring KMP WorkManager on Android and iOS.

Table of Contents


Android Setup

1. Dependencies

Add to your build.gradle.kts:

kotlin {
    sourceSets {
        androidMain.dependencies {
            // KMP WorkManager (required)
            implementation("dev.brewkits:kmpworkmanager:2.5.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")
        }
    }
}

2. AndroidManifest.xml Configuration

Required Permissions

<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>

3. Application Class Setup

Create an Application class to initialize the library:

class KMPWorkManagerApp : Application() {

    override fun onCreate() {
        super.onCreate()

        // Initialize KmpWorkManager with your worker factory
        // (Uses AndroidWorkerFactoryGenerated if you use kmpworker-ksp)
        KmpWorkManager.initialize(
            context = this,
            workerFactory = AndroidWorkerFactoryGenerated()
        )
    }
}

Register in AndroidManifest.xml:

<application
    android:name=".KMPWorkManagerApp"
    ...>
</application>

4. Request Runtime Permissions (Android 13+)

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)
                }
            }
        }
    }
}

5. ProGuard Rules (if using R8/ProGuard)

Add to proguard-rules.pro:

# Keep WorkManager classes
-keep class androidx.work.** { *; }
-keep class dev.brewkits.kmpworkmanager.sample.background.** { *; }

# Keep Kotlin coroutines
-keepclassmembernames class kotlinx.** { *; }

6. Worker Implementation

Implement workers using the AndroidWorker interface. Add @Worker(name = ...) so the KSP plugin generates AndroidWorkerFactoryGenerated automatically — no manual factory boilerplate needed.

// androidMain
import dev.brewkits.kmpworkmanager.annotations.Worker
import dev.brewkits.kmpworkmanager.background.domain.AndroidWorker
import dev.brewkits.kmpworkmanager.background.domain.WorkerEnvironment
import dev.brewkits.kmpworkmanager.background.domain.WorkerResult

@Worker(name = "SyncWorker")
class SyncWorker : AndroidWorker {
    override suspend fun doWork(input: String?, env: WorkerEnvironment): WorkerResult {
        return try {
            // Your sync logic here
            WorkerResult.Success("Sync complete")
        } catch (e: Exception) {
            WorkerResult.Retry(reason = "Sync failed: ${e.message}")
        }
    }
}

@Worker(name = "UploadWorker")
class UploadWorker : AndroidWorker {
    override suspend fun doWork(input: String?, env: WorkerEnvironment): WorkerResult {
        return try {
            // Your upload logic here
            WorkerResult.Success("Upload complete")
        } catch (e: Exception) {
            WorkerResult.Retry(reason = "Upload failed: ${e.message}")
        }
    }
}

The name value must match the workerClassName passed to scheduler.enqueue(...). KSP generates AndroidWorkerFactoryGenerated containing all @Worker-annotated classes — pass it to KmpWorkManager.initialize(workerFactory = AndroidWorkerFactoryGenerated()).


7. Android-Specific Features

Heavy Tasks (Foreground Service)

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.

Expedited Work

expedited is not a Constraints field. Expediting on Android is driven by TaskRequest.priority instead — CRITICAL/HIGH map to setExpedited() (subject to delay/heavy/charging/unmetered checks); this is chain-step-only (TaskRequest), since plain enqueue() has no priority parameter:

scheduler.beginWith(
    TaskRequest(workerClassName = "SyncWorker", priority = TaskPriority.CRITICAL)
).enqueue(id = "urgent-sync")

ContentUri Triggers

Monitor MediaStore changes:

scheduler.enqueue(
    id = "media-observer",
    trigger = TaskTrigger.ContentUri(
        uriString = "content://media/external/images/media",
        triggerForDescendants = true
    ),
    workerClassName = "MediaSyncWorker"
)

iOS Setup

Warning

Critical iOS Limitations - Read Before Implementing

iOS background tasks are fundamentally different from Android:

  1. Opportunistic Execution: The system decides when to run tasks. Tasks may be delayed hours or never run.
  2. Strict Time Limits: BGAppRefreshTask has ~30 seconds max, BGProcessingTask has ~60 seconds.
  3. Force-Quit Termination: All background tasks are immediately killed when user force-quits the app.
  4. 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.

1. Info.plist Configuration

Since v2.4.1, KMP WorkManager routes every task ID you pass to scheduler.enqueue(...) through one of two internal dispatcher tasks. You only need to declare those two IDs in BGTaskSchedulerPermittedIdentifiers — no per-task entries:

<?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>kmp_master_dispatcher_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>

Optional — per-task identifiers. If you'd like a specific task ID to be scheduled directly by iOS (instead of being queued through the master dispatcher), you may add that ID to the array above and register its own handler with handleSingleTask (see §4 below). Most apps do not need this — the master dispatcher is sufficient for arbitrary dynamic task IDs.


2. Xcode Project Settings

Open your Xcode project and verify:

  1. Signing & Capabilities:

    • Add "Background Modes" capability
    • Enable "Background fetch" and "Background processing"
  2. Build Settings:

    • Set INFOPLIST_KEY_UIApplicationSceneManifest_Generation = NO (if using AppDelegate)
    • Ensure deployment target is iOS 13.0 or higher
  3. General:

    • Verify bundle identifier matches your configuration

3. Create Worker Factory (Required)

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 {
    // The library is DI-agnostic — initialize it, then expose what you need.
    KmpWorkManager.initialize(workerFactory = MyWorkerFactory())

    single<BackgroundTaskScheduler> { KmpWorkManager.getInstance().backgroundTaskScheduler }

    // Your other iOS-specific dependencies...
}

4. AppDelegate Setup

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 {

        // No DI framework required since v3.3.0. `SetupKt` is your own Kotlin file
        // exposing top-level functions to Swift — see the `composeApp` demo.
        SetupKt.initKmpWorkManager()

        // Register background tasks
        registerBackgroundTasks()

        // Request notification permissions
        requestNotificationPermissions()

        return true
    }

    private func registerBackgroundTasks() {
        let scheduler = SetupKt.kmpscheduler()
        let chainExecutor = SetupKt.kmpchainExecutor()
        let dispatcher = SetupKt.kmpdynamicTaskDispatcher()

        // 1. Master dispatcher — wakes up every dynamic task ID that is NOT
        //    declared as its own BGTask identifier in Info.plist. Required.
        BGTaskScheduler.shared.register(
            forTaskWithIdentifier: "kmp_master_dispatcher_task",
            using: nil
        ) { task in
            IosBackgroundTaskHandler.shared.handleMasterDispatcherTask(
                task: task,
                dispatcher: dispatcher,
                scheduler: scheduler
            )
        }

        // 2. Chain executor — batches task-chain execution. Required.
        BGTaskScheduler.shared.register(
            forTaskWithIdentifier: "kmp_chain_executor_task",
            using: nil
        ) { task in
            IosBackgroundTaskHandler.shared.handleChainExecutorTask(
                task: task,
                chainExecutor: chainExecutor
            )
        }

        // 3. (Optional) Per-task identifiers. Only needed if you've also added
        //    these specific IDs to `BGTaskSchedulerPermittedIdentifiers` to let
        //    iOS schedule them directly instead of via the master dispatcher.
        //    Skip this whole block unless you have a specific reason for it.
        //
        // let executor = SetupKt.kmpsingleTaskExecutor()
        // BGTaskScheduler.shared.register(
        //     forTaskWithIdentifier: "periodic-sync-task",
        //     using: nil
        // ) { task in
        //     IosBackgroundTaskHandler.shared.handleSingleTask(
        //         task: task,
        //         scheduler: scheduler,
        //         executor: executor
        //     )
        // }
    }

    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")
    }
}

5. Worker Implementation

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).


6. iOS-Specific Features

BGAppRefreshTask vs BGProcessingTask

// 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
    )
)

Quality of Service (QoS)

Constraints.qos accepts an iOS priority hint, but is currently a no-op on both platforms — nothing reads it to influence scheduling yet (see docs/constraints-triggers.md). Use TaskRequest.priority/isHeavyTask for real effect:

scheduler.enqueue(
    id = "high-priority-sync",
    trigger = TaskTrigger.Periodic(15_MINUTES),
    workerClassName = "SyncWorker",
    constraints = Constraints(
        qos = Qos.UserInitiated // Accepted, but currently has no effect
    )
)

Silent Push Notifications

For remote-triggered tasks, configure APNS:

  1. Enable "Remote notifications" in Background Modes
  2. Send silent push with content-available: 1
  3. Handle in didReceiveRemoteNotification:
func application(
    _ application: UIApplication,
    didReceiveRemoteNotification userInfo: [AnyHashable : Any],
    fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
) {
    let scheduler = SetupKt.kmpScheduler()

    // Trigger background task
    Task {
        await scheduler.enqueue(
            id: "push-triggered-sync",
            trigger: TaskTriggerOneTime(initialDelayMs: 0),
            workerClassName: "SyncWorker",
            input: nil,
            constraints: Constraints()
        )
        completionHandler(.newData)
    }
}

Testing Background Tasks

Android Testing

1. Force Run WorkManager Task

# 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"

2. Test Doze Mode

# 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

3. Test Exact Alarms

# 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"

iOS Testing

1. Simulator Testing with LLDB

In Xcode, run the app and pause at a breakpoint, then in LLDB console:

# Force execute a task via the Master Dispatcher
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"kmp_master_dispatcher_task"]

# Force execute the Chain Executor
e -l objc -- (void)[[BGTaskScheduler sharedScheduler] _simulateLaunchForTaskWithIdentifier:@"kmp_chain_executor_task"]

2. Scheme Arguments

Add launch arguments in Xcode scheme:

  1. Product → Scheme → Edit Scheme
  2. Run → Arguments → Arguments Passed On Launch
  3. Add: -BGTaskSchedulerSimulateEarlyTermination

This simulates app termination during background task execution.

3. Monitor Console Logs

# 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

4. Physical Device Testing

  1. Connect device to Xcode
  2. Run app and send to background (Home button)
  3. Wait or trigger via LLDB (connect debugger to running app)
  4. 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.


Troubleshooting

Android Issues

Tasks Not Running

Problem: Scheduled tasks never execute

Solutions:

  1. Check WorkManager initialization:

    val workManager = WorkManager.getInstance(context)
    val workInfos = workManager.getWorkInfosForUniqueWork("task-id").get()
    println("Work state: ${workInfos.firstOrNull()?.state}")
  2. Verify constraints are met:

    adb shell dumpsys battery unplug
    adb shell svc wifi enable
  3. Check for Doze mode restrictions:

    adb shell dumpsys battery unplug
    adb shell dumpsys deviceidle whitelist +YOUR.PACKAGE.NAME

Exact Alarms Not Triggering

Problem: TaskTrigger.Exact doesn't fire (Android)

Solutions:

  1. Check permission:

    val alarmManager = getSystemService(AlarmManager::class.java)
    val canSchedule = alarmManager.canScheduleExactAlarms()
    println("Can schedule exact alarms: $canSchedule")
  2. Request permission manually:

    val intent = Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM)
    startActivity(intent)
  3. Verify AlarmReceiver is registered in AndroidManifest.xml

On iOS this is not a permission bug to fix. iOS has no exact-alarm primitive at all — by default TaskTrigger.Exact only shows a local notification and does not run your worker code unless the user taps it. Read docs/IOS_BGTASK_LIMITS.md §5 before assuming this is something to configure your way out of.


Foreground Service Crashes

Problem: KmpHeavyWorker crashes with ForegroundServiceStartNotAllowedException

Solutions:

  1. Add foreground service permission:

    <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
  2. Request notification permission on Android 13+

  3. Create proper notification channel:

    val channel = NotificationChannel(
        "heavy_task_channel",
        "Heavy Tasks",
        NotificationManager.IMPORTANCE_LOW
    )
    notificationManager.createNotificationChannel(channel)

iOS Issues

Background Tasks Not Executing

Problem: BGTasks never run on device

Solutions:

  1. Verify Info.plist configuration:

    • BGTaskSchedulerPermittedIdentifiers must contain both kmp_master_dispatcher_task and kmp_chain_executor_task
    • IDs are case-sensitive
  2. Check AppDelegate registration: both dispatcher handlers must be registered before the first BGTask request is submitted (i.e. during application(_:didFinishLaunchingWithOptions:)). Missing either one is the most common cause of "task never fires" — see the AppDelegate snippet in §4 above.

  3. 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
  4. Check system logs:

    log stream --predicate 'subsystem == "com.apple.BGTaskScheduler"' --level debug

Tasks Timeout After 25 Seconds

Problem: BGAppRefreshTask terminates after 25 seconds

Solutions:

  1. Use BGProcessingTask for longer work:

    constraints = Constraints(isHeavyTask = true)
  2. 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
        }
    }
  3. Split work into chains:

    scheduler
        .beginWith(TaskRequest(workerClassName = "QuickSync1"))
        .then(TaskRequest(workerClassName = "QuickSync2"))
        .enqueue()

Worker Not Found

Problem: IosWorkerFactory returns null

Solutions:

  1. Register worker in factory:

    object IosWorkerFactory {
        fun createWorker(className: String): IosWorker? {
            return when (className) {
                "SyncWorker" -> SyncWorker()
                else -> null
            }
        }
    }
  2. Check worker class name spelling (case-sensitive)

  3. Verify worker implements IosWorker interface


Periodic Tasks Not Re-scheduling

Problem: Task runs once but doesn't repeat

Solution: The library's IosBackgroundTaskHandler automatically re-schedules periodic tasks upon successful completion.

  • If you use the default dispatcher setup (recommended): periodic dynamic tasks are re-enqueued by DynamicTaskDispatcher after each successful run — nothing more to do as long as handleMasterDispatcherTask is registered.
  • If you opted into a per-task identifier and registered handleSingleTask yourself, the same re-scheduling logic runs inside that handler. Make sure the call signature is correct:
IosBackgroundTaskHandler.shared.handleSingleTask(
    task: task,
    scheduler: SetupKt.kmpscheduler(),
    executor: SetupKt.kmpsingleTaskExecutor()
)

Best Practices

Android

  1. Use TaskRequest.priority = TaskPriority.CRITICAL/HIGH for urgent chain steps (< 10 minutes) — expedited is not a real Constraints field
  2. Use isHeavyTask = true for long tasks (> 10 minutes)
  3. Always handle Result.retry() for transient failures
  4. Request permissions before scheduling tasks
  5. Test in Doze mode to ensure tasks run when expected

iOS

  1. Keep BGAppRefreshTask workers under 20 seconds
  2. Use BGProcessingTask (isHeavyTask = true) for heavy work
  3. Use IosBackgroundTaskHandler to automate re-scheduling and metadata resolution
  4. Register task handlers BEFORE scheduling tasks
  5. Test on physical devices (simulator behavior differs)
  6. Use LLDB commands for testing (don't wait hours for iOS to trigger)
  7. Metadata is persisted automatically in Library/Application Support (IosFileStorage)

Next Steps


Need help? Open an issue or ask in Discussions.