Skip to content

Latest commit

 

History

History
933 lines (706 loc) · 23.5 KB

File metadata and controls

933 lines (706 loc) · 23.5 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.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")
        }
    }
}

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

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 Koin classes
-keep class org.koin.** { *; }

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

6. Worker Implementation

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

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

For high-priority tasks that need to run ASAP:

scheduler.enqueue(
    id = "urgent-sync",
    trigger = TaskTrigger.OneTime(),
    workerClassName = "SyncWorker",
    constraints = Constraints(
        expedited = true
    )
)

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

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>

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 {
    // Register your worker factory
    factory { MyWorkerFactory() }

    // Other iOS-specific dependencies
    single<BackgroundTaskScheduler> { NativeTaskScheduler() }
    // ...
}

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 {

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

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)

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

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

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

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

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


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:

    • Check BGTaskSchedulerPermittedIdentifiers array
    • Ensure task IDs match exactly (case-sensitive)
  2. Check AppDelegate registration:

    BGTaskScheduler.shared.register(
        forTaskWithIdentifier: "periodic-sync-task",
        using: nil
    ) { task in
        // Handler must be registered BEFORE task is scheduled
    }
  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. Ensure you are using this handler in your AppDelegate:

IosBackgroundTaskHandler.shared.handleSingleTask(
    task: task,
    scheduler: koin.getScheduler(),
    executor: koin.getExecutor()
)

Best Practices

Android

  1. Use expedited = true for urgent tasks (< 10 minutes)
  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.