agentleFS
PL-Coding-GmbHPL-Coding-GmbH/Skills/skills/android-background/SKILL.md

android-background

Skill18 starsChanged 4 months agoMITView on GitHub

Background work patterns for Android/KMP - WorkManager (one-time, periodic, expedited, foreground promotion), foreground services, work observation, Koin WorkerFactory, and the SyncScheduler abstraction. Use this skill whenever implementing background sync, scheduling periodic work, creating a foreground service, observing work progress, setting up WorkManager with Koin, or deciding between WorkManager and foreground services. Trigger on phrases like "WorkManager", "background work", "sync worker", "periodic sync", "foreground service", "schedule sync", "SyncScheduler", "CoroutineWorker", "work observation", "expedited work", or "background sync".

The file

We split this skill into the rules it gives an agent

7 rules. About 0 are common, 3 are distinctive, and 4 are very specific to this project.

  • ≈0

    Common · estimate

    Many files like this have a rule of this kind.

  • ≈3

    Distinctive · estimate

    This author's own choices. Worth a look.

  • 4

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Only consult this decision tree when the choice is not already obvious from context.

    line 11
  2. Very specific:

    Avoid confusion between your domain Result<D, E> and WorkManager's ListenableWorker.Result:

    line 46
  3. Very specific:

    Always use WorkerResult inside Workers.

    line 53
  4. Very specific:

    Use ExistingWorkPolicy.KEEP to avoid re-enqueueing if work is already pending.

    line 156
  5. Very specific:

    Use worker { } (not singleOf or factoryOf) for Worker definitions — Koin's worker scope matches WorkManager's lifecycle.

    line 303
  6. Distinctive:

    Use a standalone foreground service when work must run immediately, continuously, and with user visibility — not suitable for WorkManager …

    line 309
  7. Distinctive:

    Every foreground service must declare a type.

    line 357

≈ “Common” is an estimate: at least 1 in 5 of the 350,495 skill files we read have a rule of the same kind, not the same words. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Android / KMP Background Work

Choosing the Right Approach

Only consult this decision tree when the choice is not already obvious from context.

QuestionYesNo
Must survive process death?Continue belowUse viewModelScope — not this skill
Needs to run immediately and continuously?Foreground ServiceWorkManager
Deferrable / can wait for constraints?WorkManager (one-time or periodic)Foreground Service

WorkManager — deferrable persistent work: syncing data, uploading logs, periodic cleanup. Survives process death, respects battery/network constraints.

Foreground Service — long-running user-visible work: music playback, navigation, active location tracking. Requires ongoing notification.


WorkManager Architecture

Three components with clear separation of responsibilities:

  • Worker (CoroutineWorker) — thin (~15 lines). Calls the repository to do the actual work, maps the result to WorkerResult. No business logic here.
  • SyncScheduler (domain interface + data implementation) — scheduling only. Enqueues/cancels WorkManager requests. Knows nothing about sync logic.
  • Repository — owns the actual sync/business logic (fetch remote, write to Room, etc.) per android-data-layer patterns.

Module placement

ComponentModule
Workerfeature:data
SyncScheduler interfacefeature:domain
SyncScheduler implementationfeature:data
Repository (sync logic)feature:data (interface in feature:domain)

WorkerResult Typealias

Avoid confusion between your domain Result<D, E> and WorkManager's ListenableWorker.Result:

// core:data
typealias WorkerResult = androidx.work.ListenableWorker.Result

Always use WorkerResult inside Workers. Never import ListenableWorker.Result directly.


Worker Pattern

Workers are thin — delegate to the repository, map the result:

class NoteSyncWorker(
    context: Context,
    workerParams: WorkerParameters,
    private val noteRepository: NoteRepository,
) : CoroutineWorker(context, workerParams) {

    override suspend fun doWork(): WorkerResult {
        if (runAttemptCount >= 3) {
            return WorkerResult.failure()
        }
        noteRepository.syncNotes()
            .onError { error ->
                if (error == DataError.Network.UNAUTHORIZED) {
                    return WorkerResult.failure()
                }
                return WorkerResult.retry()
            }
            .onSuccess {
                return WorkerResult.success()
            }
        return WorkerResult.failure()
    }
}

Key rules:

  • Guard with runAttemptCount — fail after a reasonable number of retries.
  • Map domain errors to WorkerResult per-Worker. Each Worker decides its own retry/failure strategy.
  • Non-retryable errors (e.g., UNAUTHORIZED) return failure() immediately.

SyncScheduler Pattern

Domain interface — scheduling only, no sync logic:

interface NoteSyncScheduler {
    fun scheduleSync()
    fun cancelSync()
}

Data implementation — handles all WorkManager details:

class WorkManagerNoteSyncScheduler(
    private val context: Context,
) : NoteSyncScheduler {

    override fun scheduleSync() {
        WorkManager.getInstance(context).enqueueUniquePeriodicWork(
            "sync_notes",
            ExistingPeriodicWorkPolicy.REPLACE,
            PeriodicWorkRequestBuilder<NoteSyncWorker>(30L, TimeUnit.MINUTES)
                .setConstraints(
                    Constraints.Builder()
                        .setRequiredNetworkType(NetworkType.CONNECTED)
                        .build()
                )
                .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1000L, TimeUnit.MILLISECONDS)
                .setInitialDelay(30L, TimeUnit.MINUTES)
                .build()
        )
    }

    override fun cancelSync() {
        WorkManager.getInstance(context).cancelUniqueWork("sync_notes")
    }
}

One-Time Work

For work that should run once (e.g., upload a file after creation):

fun enqueueUpload(noteId: String) {
    val request = OneTimeWorkRequestBuilder<NoteUploadWorker>()
        .setConstraints(
            Constraints.Builder()
                .setRequiredNetworkType(NetworkType.CONNECTED)
                .build()
        )
        .setInputData(workDataOf("NOTE_ID" to noteId))
        .build()

    WorkManager.getInstance(context)
        .enqueueUniqueWork("upload_$noteId", ExistingWorkPolicy.KEEP, request)
}

Use ExistingWorkPolicy.KEEP to avoid re-enqueueing if work is already pending.


Work Chaining

Chain sequential or parallel work:

WorkManager.getInstance(context)
    .beginWith(listOf(fetchNotesRequest, fetchTagsRequest))  // parallel
    .then(mergeAndCacheRequest)                               // sequential after both
    .enqueue()

Expedited Work and Foreground Promotion

For work that needs to start immediately (e.g., user-initiated upload):

val request = OneTimeWorkRequestBuilder<NoteUploadWorker>()
    .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
    .build()

For long-running expedited work that needs a notification (API 31+), override getForegroundInfo() in the Worker:

class NoteUploadWorker(
    context: Context,
    workerParams: WorkerParameters,
    private val noteRepository: NoteRepository,
) : CoroutineWorker(context, workerParams) {

    override suspend fun getForegroundInfo(): ForegroundInfo {
        return ForegroundInfo(
            NOTIFICATION_ID,
            createUploadNotification(),
            ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC
        )
    }

    override suspend fun doWork(): WorkerResult {
        setForeground(getForegroundInfo())
        // ... do work
    }
}

Work Observation

Expose work status through the SyncScheduler interface for the presentation layer:

// Domain interface
interface NoteSyncScheduler {
    fun scheduleSync()
    fun cancelSync()
    fun observeSyncStatus(): Flow<SyncStatus>
}

enum class SyncStatus { IDLE, SYNCING, SUCCEEDED, FAILED }
// Data implementation
override fun observeSyncStatus(): Flow<SyncStatus> {
    return WorkManager.getInstance(context)
        .getWorkInfosForUniqueWorkFlow("sync_notes")
        .map { workInfos ->
            val info = workInfos.firstOrNull()
            when (info?.state) {
                WorkInfo.State.RUNNING -> SyncStatus.SYNCING
                WorkInfo.State.SUCCEEDED -> SyncStatus.SUCCEEDED
                WorkInfo.State.FAILED -> SyncStatus.FAILED
                WorkInfo.State.ENQUEUED, WorkInfo.State.BLOCKED -> SyncStatus.IDLE
                else -> SyncStatus.IDLE
            }
        }
}

Collect in the ViewModel via collectAsStateWithLifecycle() per android-compose-architecture patterns.


DI with Koin

Disable default WorkManager initializer

In AndroidManifest.xml:

<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"
        tools:node="remove" />
</provider>

Initialize WorkManager with KoinWorkerFactory

// :app Application class
class App : Application(), KoinComponent {
    override fun onCreate() {
        super.onCreate()
        startKoin {
            androidContext(this@App)
            workManagerFactory()
            modules(
                coreDataModule,
                notesDataModule,
                notesPresentationModule,
                // ...
            )
        }

        WorkManager.initialize(
            this,
            Configuration.Builder()
                .setWorkerFactory(get<WorkerFactory>())
                .build()
        )
    }
}

Koin module for Workers

// feature:notes:data
val notesDataModule = module {
    singleOf(::WorkManagerNoteSyncScheduler) { bind<NoteSyncScheduler>() }
    worker { NoteSyncWorker(get(), get(), get()) }
}

Use worker { } (not singleOf or factoryOf) for Worker definitions — Koin's worker scope matches WorkManager's lifecycle.


Foreground Services

Use a standalone foreground service when work must run immediately, continuously, and with user visibility — not suitable for WorkManager (e.g., music playback, active navigation, ongoing location tracking).

Service lifecycle

class MusicPlaybackService : Service() {

    override fun onCreate() {
        super.onCreate()
        // Initialize resources (media player, location client, etc.)
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        val notification = createNotification()
        ServiceCompat.startForeground(
            this,
            NOTIFICATION_ID,
            notification,
            ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
        )
        // Start work
        return START_STICKY
    }

    override fun onDestroy() {
        super.onDestroy()
        // Release resources
    }

    override fun onBind(intent: Intent?): IBinder? = null

    companion object {
        private const val NOTIFICATION_ID = 1
    }
}

Manifest declaration

<service
    android:name=".MusicPlaybackService"
    android:foregroundServiceType="mediaPlayback"
    android:exported="false" />

Foreground service types (Android 14+)

Every foreground service must declare a type. Each type has specific permission requirements:

TypeUse caseKey permissions
cameraCamera access while in backgroundFOREGROUND_SERVICE_CAMERA, CAMERA
connectedDeviceBluetooth, USB, companion devicesFOREGROUND_SERVICE_CONNECTED_DEVICE
dataSyncData transfer (sync, upload, download)FOREGROUND_SERVICE_DATA_SYNC
healthFitness/health trackingFOREGROUND_SERVICE_HEALTH
locationContinuous location accessFOREGROUND_SERVICE_LOCATION, ACCESS_FINE/COARSE_LOCATION
mediaPlaybackAudio/video playbackFOREGROUND_SERVICE_MEDIA_PLAYBACK
mediaProjectionScreen capture/sharingFOREGROUND_SERVICE_MEDIA_PROJECTION
microphoneRecording audioFOREGROUND_SERVICE_MICROPHONE, RECORD_AUDIO
phoneCallOngoing callFOREGROUND_SERVICE_PHONE_CALL, MANAGE_OWN_CALLS
remoteMessagingMessaging for companion/wearable devicesFOREGROUND_SERVICE_REMOTE_MESSAGING
shortServiceQuick tasks that finish fastNone extra (limited to ~3 minutes)
specialUseDoesn't fit other categoriesFOREGROUND_SERVICE_SPECIAL_USE + Play Store justification

Android 15+ restrictions: dataSync foreground services have a 6-hour time limit. Prefer WorkManager with foreground promotion for data sync instead of standalone foreground services.

When to use foreground service vs. WorkManager foreground promotion

ScenarioApproach
Continuous user-facing work (music, navigation)Standalone foreground service
User-initiated data transfer (upload/download)WorkManager + setExpedited() + setForeground()
Background periodic syncWorkManager with constraints
Quick one-off background taskWorkManager one-time

Testing

Workers are thin — test the repository logic directly using fakes per android-testing patterns. No need for TestListenableWorkerBuilder.

For ViewModels that trigger sync, use a fake SyncScheduler:

class FakeNoteSyncScheduler : NoteSyncScheduler {
    var scheduleSyncCalled = false
        private set
    var cancelSyncCalled = false
        private set

    override fun scheduleSync() { scheduleSyncCalled = true }
    override fun cancelSync() { cancelSyncCalled = true }
    override fun observeSyncStatus(): Flow<SyncStatus> = flowOf(SyncStatus.IDLE)
}

Naming Conventions

ThingConventionExample
Worker<Feature>SyncWorkerNoteSyncWorker
SyncScheduler interface<Feature>SyncSchedulerNoteSyncScheduler
SyncScheduler implWorkManager<Feature>SyncSchedulerWorkManagerNoteSyncScheduler
Koin module<feature>DataModule (Workers go here)notesDataModule
Unique work namesnake_case string"sync_notes"
WorkerResult typealiasWorkerResult in core:datatypealias WorkerResult = ListenableWorker.Result
Sync status enumSyncStatusSyncStatus.SYNCING

Checklist: Adding Background Work for a Feature

  • Define WorkerResult typealias in core:data (if not yet present)
  • Define <Feature>SyncScheduler interface in feature:domain
  • Define sync method in repository interface in feature:domain (e.g., suspend fun syncNotes())
  • Implement sync logic in repository in feature:data — see android-data-layer
  • Implement CoroutineWorker in feature:data — thin, calls repository, maps to WorkerResult
  • Implement WorkManager<Feature>SyncScheduler in feature:data
  • Register Worker and SyncScheduler in feature's Koin data module
  • Disable default WorkManager initializer in AndroidManifest
  • Initialize WorkManager with KoinWorkerFactory in :app
  • Add work observation if UI needs sync status

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.