Background Work: WorkManager, Services and Alarms

This content was generated with the assistance of AI and should be verified against the official documentation of Android before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

This page covers how TrailMate does work that outlives the screen that started it: periodic trail-data sync with WorkManager 2.12.0, continuous hike recording with a foreground service, timed reminders with AlarmManager, and system-wide events delivered through BroadcastReceiver. It also covers the platform restrictions that shape all of the above — Android 14+ foreground service rules, Doze mode and App Standby buckets — and the legacy IntentService/JobScheduler/Firebase JobDispatcher APIs WorkManager replaced. The @HiltWorker wiring shown below assumes the Hilt setup covered in full on Dependency Injection with Hilt, Dagger and Koin; the trail_updates notification channel RecordHikeService posts to is introduced in full on Notifications, Firebase Cloud Messaging and App Widgets (Glance) and only referenced here. Runtime permissions this page’s APIs need (POST_NOTIFICATIONS, ACCESS_FINE_LOCATION, FOREGROUND_SERVICE_LOCATION, SCHEDULE_EXACT_ALARM) are covered in depth on Permissions, Privacy and Security. Coroutines, Flow and structured concurrency, which CoroutineWorker and RecordHikeService both build on, are assumed from Coroutines Basics and Flows.

Choosing the Right API

Android has narrowed background-work choices over the years for one reason: unconstrained background execution drains battery and memory across every app on the device, so the platform increasingly decides when and how work not tied to visible UI is allowed to run, rather than letting an app decide for itself. The decision below walks through TrailMate’s own choices — TrailSyncWorker for periodic sync, RecordHikeService for the user-visible hike recording session, and AlarmManager only for the rare case that needs wall-clock precision.

flowchart TD A["Background work to schedule"] --> B{"Must the user see and\ncontrol it while it runs?"} B -- "No -- deferrable, can wait\nfor constraints/battery" --> C["WorkManager\n(OneTimeWorkRequest / PeriodicWorkRequest)"] B -- "Yes -- a long-running,\nuser-visible operation" --> D{"What kind of operation?"} D -- "Continuous sensor/media/location\ncapture the user started" --> E["Foreground service\n(e.g. RecordHikeService,\ntype=location)"] D -- "A large upload/download the\nuser explicitly kicked off" --> F["User-initiated data transfer\n(UIDT) job via JobScheduler"] D -- "Must fire at (or very near)\na precise wall-clock instant" --> G{"Is exact timing\ngenuinely required?"} G -- "Yes, e.g. an alarm clock\nor a calendar reminder" --> H["AlarmManager exact alarm\n(SCHEDULE_EXACT_ALARM)"] G -- "No, an inexact reminder\nis acceptable" --> I["WorkManager one-time work\nwith setInitialDelay"] A --> J{"Reacting to a system or\napp-broadcast event?"} J -- "Yes" --> K["BroadcastReceiver\n(manifest-registered for a few\nimplicit broadcasts, or\ncontext-registered otherwise)"] C --> L["Subject to Doze / App\nStandby batching windows"] E --> M["Exempt from background\nrestrictions while foreground"] F --> O["Not subject to App Standby\nquotas; runs while constraints hold"] H --> N["Exempt from Doze deferral\n(setExactAndAllowWhileIdle)"]
API Best for Not for

WorkManager

Deferrable, guaranteed work that should survive process death and reboot: syncing trails, uploading queued photos, periodic cleanup.

Work the user is actively watching, or that must start within milliseconds.

Foreground service

A long-running, user-visible operation the user started and can see is happening: recording a hike, playing audio, a live navigation session.

Anything that could instead be deferred to WorkManager — a foreground service is a battery and UX cost the system actively discourages when unnecessary.

User-initiated data transfer

A large upload/download the user explicitly triggered and expects to see progress on, which may keep running for a long time after the app leaves the foreground (API 34+, a JobScheduler job rather than a service).

Silent, unattended transfers — for that, use WorkManager with a network constraint instead.

AlarmManager exact alarm

The handful of cases that need a precise wall-clock instant: an alarm clock, a calendar event notification.

General deferrable work — WorkManager should be the default, since exact alarms bypass Doze/battery optimizations and Google Play policy restricts their use.

BroadcastReceiver

Reacting to a system event (BOOT_COMPLETED, connectivity change) or an app-internal event, not scheduling work itself.

Long-running work — a receiver’s onReceive() must return quickly; it typically just enqueues a WorkRequest.

WorkManager: Guaranteed, Deferrable Work

WorkManager is Jetpack’s unified API for work that must run even if the app closes or the device reboots, and that the system is free to defer, batch or retry according to power state. It sits on top of JobScheduler on API 23+, Firebase JobDispatcher (legacy fallback), and AlarmManager+BroadcastReceiver on very old devices, picking the right execution engine transparently — an app never targets any of those directly once it uses WorkManager. TrailMate uses it for TrailSyncWorker, which periodically pulls trail updates from the network into Room.

// gradle/libs.versions.toml
[versions]
workManager = "2.12.0"

[libraries]
work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = "workManager" }
work-testing = { module = "androidx.work:work-testing", version.ref = "workManager" }
hilt-work = { module = "androidx.hilt:hilt-work", version = "1.4.0" }
hilt-work-compiler = { module = "androidx.hilt:hilt-compiler", version = "1.4.0" }

CoroutineWorker and TrailSyncWorker

A Worker subclass does its work on a WorkManager-managed background thread; a CoroutineWorker instead runs doWork() as a suspend function on a coroutine WorkManager drives, which is the right choice whenever the work itself is already coroutine-based (a suspending repository call, in TrailMate’s case):

// core/data/src/main/kotlin/com/example/trailmate/core/data/TrailSyncWorker.kt
package com.example.trailmate.core.data

import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.CoroutineWorker
import androidx.work.Data
import androidx.work.WorkerParameters
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject

@HiltWorker
class TrailSyncWorker @AssistedInject constructor(
    @Assisted appContext: Context,
    @Assisted workerParams: WorkerParameters,
    private val trailRepository: TrailRepository,
) : CoroutineWorker(appContext, workerParams) {

    override suspend fun doWork(): Result {
        val region = inputData.getString(KEY_REGION)
        return try {
            val syncedCount = trailRepository.refresh(region)
            Result.success(Data.Builder().putInt(KEY_SYNCED_COUNT, syncedCount).build())
        } catch (e: java.io.IOException) {
            // A transient network failure: let WorkManager retry with backoff.
            Result.retry()
        } catch (e: Exception) {
            // Anything else is not worth retrying.
            Result.failure()
        }
    }

    companion object {
        const val KEY_REGION = "region"
        const val KEY_SYNCED_COUNT = "syncedCount"
    }
}

doWork() returns one of three ListenableWorker.Result values: Result.success() (optionally carrying output Data), Result.failure() (terminal — WorkManager will not retry), or Result.retry(), which reschedules the work according to its backoff policy (below). @HiltWorker/@AssistedInject is covered in depth on Dependency Injection with Hilt, Dagger and Koin — it lets TrailSyncWorker receive trailRepository through Hilt even though WorkManager, not Hilt, constructs the worker instance itself. HiltWorkerFactory must be registered once, which also means disabling WorkManager’s default manifest-provider-based initialization so Configuration.Provider takes over:

// app/src/main/kotlin/com/example/trailmate/TrailMateApplication.kt
package com.example.trailmate

import android.app.Application
import androidx.hilt.work.HiltWorkerFactory
import androidx.work.Configuration
import dagger.hilt.android.HiltAndroidApp
import javax.inject.Inject

@HiltAndroidApp
class TrailMateApplication : Application(), Configuration.Provider {

    @Inject lateinit var workerFactory: HiltWorkerFactory

    override val workManagerConfiguration: Configuration
        get() = Configuration.Builder().setWorkerFactory(workerFactory).build()
}
<!-- app/src/main/AndroidManifest.xml: disable WorkManager's default initializer.
     The root <manifest> element declares xmlns:tools="http://schemas.android.com/tools". -->
<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>

Constraints

A Constraints object tells WorkManager when it is even allowed to run the work, independent of any schedule — WorkManager holds the work until every constraint is satisfied, re-checking automatically as system state changes:

val syncConstraints = androidx.work.Constraints.Builder()
    .setRequiredNetworkType(androidx.work.NetworkType.CONNECTED)
    .setRequiresBatteryNotLow(true)
    .setRequiresStorageNotLow(true)
    .build()
Constraint Meaning

setRequiredNetworkType

CONNECTED, UNMETERED, NOT_ROAMING, METERED, or TEMPORARILY_UNMETERED (API 30+, e.g. a mobile carrier’s temporary data-cap relief).

setRequiresCharging

Only run while the device is plugged in and charging.

setRequiresBatteryNotLow

Skip while the battery is critically low.

setRequiresStorageNotLow

Skip while device storage is critically low.

setRequiresDeviceIdle

Only run while the device is idle (Doze-like), API 23+ — rare, mostly for maintenance-style jobs.

One-Time vs. Periodic Work

// A one-time sync, e.g. triggered right after the user pulls-to-refresh.
val oneTimeSync = androidx.work.OneTimeWorkRequestBuilder<TrailSyncWorker>()
    .setConstraints(syncConstraints)
    .setInputData(androidx.work.workDataOf(TrailSyncWorker.KEY_REGION to "pacific-northwest"))
    .build()

androidx.work.WorkManager.getInstance(context).enqueue(oneTimeSync)
// A background refresh every 6 hours, regardless of whether the app is open.
val periodicSync = androidx.work.PeriodicWorkRequestBuilder<TrailSyncWorker>(6, java.util.concurrent.TimeUnit.HOURS)
    .setConstraints(syncConstraints)
    .setBackoffCriteria(
        androidx.work.BackoffPolicy.EXPONENTIAL,
        androidx.work.WorkRequest.MIN_BACKOFF_MILLIS,
        java.util.concurrent.TimeUnit.MILLISECONDS,
    )
    .build()

androidx.work.WorkManager.getInstance(context)
    .enqueueUniquePeriodicWork(
        "trail-sync",
        androidx.work.ExistingPeriodicWorkPolicy.KEEP,
        periodicSync,
    )

A PeriodicWorkRequest’s interval has a system-enforced floor of 15 minutes (`PeriodicWorkRequest.MIN_PERIODIC_INTERVAL_MILLIS); requesting anything shorter is silently clamped up to it. Periodic work also cannot be chained (below) — chaining only applies to one-time work. See Android Developers — Create recurring work.

Backoff Policy

When doWork() returns Result.retry(), WorkManager reschedules the work after a delay controlled by setBackoffCriteria: LINEAR increases the delay by a fixed amount each retry, EXPONENTIAL (the default, and what TrailSyncWorker above uses) doubles it, both starting at initialDelay (minimum WorkRequest.MIN_BACKOFF_MILLIS, 10 seconds) and capping at WorkRequest.MAX_BACKOFF_MILLIS (5 hours). Choosing EXPONENTIAL for a network sync avoids hammering a temporarily-down API on every retry; LINEAR fits a job where a bounded, predictable retry cadence matters more.

Chaining Work

WorkManager.beginWith(…​).then(…​) builds a dependency chain: a downstream WorkRequest only starts once every upstream request in the same chain has finished successfully, and each stage’s output Data becomes the next stage’s input Data automatically.

val downloadPhotosWork = androidx.work.OneTimeWorkRequestBuilder<DownloadQueuedPhotosWorker>().build()
val compressPhotosWork = androidx.work.OneTimeWorkRequestBuilder<CompressPhotosWorker>().build()
val uploadPhotosWork = androidx.work.OneTimeWorkRequestBuilder<UploadPhotosWorker>()
    .setConstraints(androidx.work.Constraints.Builder().setRequiredNetworkType(androidx.work.NetworkType.UNMETERED).build())
    .build()

androidx.work.WorkManager.getInstance(context)
    .beginWith(downloadPhotosWork)
    .then(compressPhotosWork)
    .then(uploadPhotosWork)
    .enqueue()

If any stage returns Result.failure(), every downstream stage in the chain is marked FAILED without running — a failure never silently skips ahead. WorkContinuation.combine() (not shown) merges several independent chains into one, for a fan-in/fan-out dependency graph more complex than a straight line. See Android Developers — Chain work.

Unique Work

Unique work gives a chain (or a periodic request) a string name and a policy for what happens if work under that name is enqueued again while the previous one is still pending or running — essential for TrailSyncWorker, since re-enqueuing it on every app launch should not pile up duplicate syncs.

Policy (ExistingWorkPolicy / ExistingPeriodicWorkPolicy) Effect

KEEP

Do nothing if work with this name is already pending/running; drop the new request.

REPLACE

Cancel the existing work and enqueue the new request in its place.

APPEND

Add the new request as a dependent of the existing chain’s last stage (one-time work only).

APPEND_OR_REPLACE

Like APPEND, but if the existing work already failed or was cancelled, replace it instead (one-time work only).

UPDATE (periodic, API-level-independent since 2.9)

Replace the periodic request’s definition in place without cancelling work already in flight.

enqueueUniqueWork(name, policy, request) and enqueueUniquePeriodicWork(name, policy, request) are the entry points, both shown above. See Android Developers — Unique work and Android Developers — Manage work.

Expedited Work

Expedited work asks WorkManager to run something as soon as possible, even under Doze, for a task that is important but too short-lived to justify a foreground service — for example, uploading one just-taken hike photo right away:

val uploadPhotoNow = androidx.work.OneTimeWorkRequestBuilder<UploadPhotoWorker>()
    .setExpedited(androidx.work.OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST)
    .setInputData(androidx.work.workDataOf("photoUri" to photoUri.toString()))
    .build()

WorkManager runs expedited work through a system-managed job quota; OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST tells it to silently fall back to ordinary (non-expedited) scheduling once that quota is exhausted, rather than failing outright. On API 31+, WorkManager may briefly promote expedited work to run with a foreground-service-like guarantee on the app’s behalf; on API 26-30, it may need WorkManager’s own auto-generated foreground-service-backed notification to do the same. Because of that, expedited work should stay short — seconds, not minutes — and genuinely time-sensitive; the sync-every-6-hours case above stays a plain `PeriodicWorkRequest because nothing about it needs to run immediately. See Android Developers — Expedited work.

Observing Work: WorkInfo and Flow

A screen that shows sync progress observes the enqueued work’s WorkInfo rather than polling the repository:

// feature/settings/src/main/kotlin/com/example/trailmate/feature/settings/SettingsViewModel.kt
package com.example.trailmate.feature.settings

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import androidx.work.WorkInfo
import androidx.work.WorkManager
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject
import dagger.hilt.android.lifecycle.HiltViewModel

@HiltViewModel
class SettingsViewModel @Inject constructor(
    workManager: WorkManager,
) : ViewModel() {

    val syncState: StateFlow<WorkInfo.State?> =
        workManager.getWorkInfosForUniqueWorkFlow("trail-sync")
            .map { infos -> infos.firstOrNull()?.state }
            .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
}

getWorkInfosForUniqueWorkFlow (and getWorkInfoByIdFlow for a single request) exposes a kotlinx.coroutines.Flow directly, which is the idiomatic choice in a coroutine-first codebase like TrailMate; the equivalent LiveData-returning overloads (getWorkInfosForUniqueWorkLiveData) exist for a screen that still observes LiveData, as covered on Saving UI State: Views vs. Compose. WorkInfo.state is one of ENQUEUED, RUNNING, SUCCEEDED, FAILED, BLOCKED (waiting on an upstream chain stage) or CANCELLED; WorkInfo.outputData carries the Data a finished worker returned. See Android Developers — Observe work status and take action after work succeeds.

Testing Workers: work-testing

androidx.work:work-testing provides TestListenableWorkerBuilder to run a worker’s doWork() directly, without a real WorkManager runtime:

// core/data/src/test/kotlin/com/example/trailmate/core/data/TrailSyncWorkerTest.kt
package com.example.trailmate.core.data

import androidx.test.core.app.ApplicationProvider
import androidx.work.ListenableWorker.Result
import androidx.work.testing.TestListenableWorkerBuilder
import androidx.work.workDataOf
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner

@RunWith(RobolectricTestRunner::class)
class TrailSyncWorkerTest {

    @Test
    fun `doWork returns success when repository refresh succeeds`() = runTest {
        val worker = TestListenableWorkerBuilder<TrailSyncWorker>(
            context = ApplicationProvider.getApplicationContext(),
            inputData = workDataOf(TrailSyncWorker.KEY_REGION to "pacific-northwest"),
        ).setWorkerFactory(FakeTrailSyncWorkerFactory(FakeTrailRepository()))
            .build()

        val result = worker.doWork()

        assert(result is Result.Success)
    }
}

A separate, end-to-end style of test uses WorkManagerTestInitHelper to install a SynchronousExecutor-backed test WorkManager and then exercises real scheduling APIs (enqueueUniqueWork, getWorkInfoById) against it — useful for verifying constraints and unique-work policies rather than one worker’s doWork() logic in isolation. Unit testing conventions shared with the rest of the app (Mockito, MockK, Robolectric) are covered on Unit Testing with JUnit, Mockito, MockK and Robolectric. See Android Developers — Test WorkManager, Debug and test WorkManager.

Foreground Services

A foreground service is for work the user knows is happening and can see — it must show a persistent notification while running, and in exchange the system gives it much stronger execution guarantees than deferrable WorkManager work. TrailMate uses one for RecordHikeService, which keeps recording GPS location for the duration of a hike, including while the app is backgrounded.

Required Foreground Service Types

Since Android 14 (API 34), every foreground service must declare a foreground service type in the manifest, matching the specific system resource it uses — there is no longer a generic, type-less foreground service. Starting one whose declared type does not match what it actually needs (or omitting the type, or lacking the type’s own runtime permission) throws MissingForegroundServiceTypeException or ForegroundServiceStartNotAllowedException at startup.

Type Typical use

location

Continuous location tracking the user is aware of — RecordHikeService.

camera / microphone

Recording video/audio while the app may not be in the foreground.

mediaPlayback

Playing audio/video, e.g. a music or podcast player.

dataSync

Short-lived data upload/download the app kicked off itself — restricted like every type by the Android 14 background-start rules, and limited to six hours per 24 hours for apps targeting Android 15 (API 35)+; prefer WorkManager expedited work or UIDT instead.

connectedDevice

Interacting with an external Bluetooth/USB/companion device.

health, remoteMessaging, systemExempted, shortService, specialUse

Narrower cases — a fitness tracker with the HEALTH_CONNECT permission, SMS/RCS apps, system-privileged apps, a very short (under ~3-minute) service, or anything not covered by another type (requires justifying the use case to Google Play).

<!-- app/src/main/AndroidManifest.xml -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

<service
    android:name=".RecordHikeService"
    android:foregroundServiceType="location"
    android:exported="false" />

Each type requires its own FOREGROUND_SERVICE_<TYPE> manifest permission (FOREGROUND_SERVICE_LOCATION above) in addition to the general FOREGROUND_SERVICE permission, and a matching dangerous/special runtime permission where one applies (ACCESS_FINE_LOCATION for location). See Android Developers — Foreground service types and Android Developers — Foreground service types are now required.

RecordHikeService

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/RecordHikeService.kt
package com.example.trailmate.feature.trails

import android.app.Service
import android.content.Intent
import android.content.pm.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import com.example.trailmate.R
import com.google.android.gms.location.FusedLocationProviderClient
import com.google.android.gms.location.LocationCallback
import com.google.android.gms.location.LocationRequest
import com.google.android.gms.location.LocationResult
import com.google.android.gms.location.LocationServices
import com.example.trailmate.core.data.HikeSessionRepository
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import javax.inject.Inject

@AndroidEntryPoint
class RecordHikeService : Service() {

    @Inject lateinit var hikeSessionRepository: HikeSessionRepository

    private val serviceScope = CoroutineScope(SupervisorJob())
    private lateinit var fusedLocationClient: FusedLocationProviderClient
    private lateinit var locationCallback: LocationCallback

    override fun onCreate() {
        super.onCreate()
        fusedLocationClient = LocationServices.getFusedLocationProviderClient(this)
        locationCallback = object : LocationCallback() {
            override fun onLocationResult(result: LocationResult) {
                result.lastLocation?.let { location ->
                    serviceScope.launch { hikeSessionRepository.recordPoint(location) }
                }
            }
        }
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        val notification = NotificationCompat.Builder(this, CHANNEL_ID)
            .setContentTitle(getString(R.string.recording_hike))
            .setContentText(getString(R.string.tap_to_view_progress))
            .setSmallIcon(R.drawable.ic_hike)
            .setOngoing(true)
            .build()

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            ServiceCompat.startForeground(
                this,
                NOTIFICATION_ID,
                notification,
                ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION,
            )
        } else {
            startForeground(NOTIFICATION_ID, notification)
        }

        val locationRequest = LocationRequest.Builder(LOCATION_INTERVAL_MS).build()
        fusedLocationClient.requestLocationUpdates(locationRequest, locationCallback, mainLooper)

        // START_STICKY: if the system kills this process under memory pressure, recreate the
        // service (with a null intent) once resources free up, rather than dropping the session.
        return START_STICKY
    }

    override fun onDestroy() {
        fusedLocationClient.removeLocationUpdates(locationCallback)
        serviceScope.cancel()
        super.onDestroy()
    }

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

    companion object {
        const val CHANNEL_ID = "trail_updates"
        private const val NOTIFICATION_ID = 42
        private const val LOCATION_INTERVAL_MS = 5_000L
    }
}

ServiceCompat.startForeground(…​, type) is the version-safe way to pass a foreground service type — it forwards to the type-aware overload on API 29+ and to the plain two-argument startForeground below it. CHANNEL_ID refers to the trail_updates notification channel created on Notifications, Firebase Cloud Messaging and App Widgets (Glance), which must exist before startForeground is ever called (posting to an unregistered channel silently drops the notification on API 26+). onStartCommand must call startForeground() within a few seconds of the service being started, or the system throws ForegroundServiceDidNotStartInTimeException (API 31+) and stops it. RecordHikeService is started from MainActivity/TrailDetailViewModel with ContextCompat.startForegroundService(context, intent) rather than a plain startService, which is what actually grants it the few seconds of foreground-execution grace it needs to call startForeground in time. See Android Developers — Foreground services and Android Developers — Declare a foreground service’s type.

Android 14+ Restrictions

Beyond the mandatory type declaration above, Android 14 and 15 tightened when and for how long a foreground service can run:

  • Background start restrictions. Most foreground service types can no longer be started while the app itself is in the background (a small allow-list of exceptions exists, e.g. in response to a user-visible notification action) — RecordHikeService must be started while MainActivity/TrailDetailScreen is in the foreground, which matches TrailMate’s actual UX (the user taps "Start recording" while the app is open).

  • dataSync time limit (Android 15). For apps targeting Android 15 (API 35) or higher, a dataSync-type foreground service that has run for six cumulative hours within a 24-hour window gets its Service.onTimeout() callback (added in API 35) and must call stopSelf() within a few seconds; the budget resets when the user brings the app to the foreground. Android 15 also stops dataSync services from being launched from a BOOT_COMPLETED receiver. TrailMate does not use dataSync for exactly this reason, preferring WorkManager expedited work or UIDT (below) for data transfer.

  • ForegroundServiceStartNotAllowedException. Thrown from startForegroundService()/startForeground() when a background-start restriction is violated — catching it and falling back to a deferred WorkManager request is the recommended recovery, not retrying immediately.

User-Initiated Data Transfer (UIDT)

A user-initiated data transfer (API 34+) is a JobScheduler job — not a foreground service — for a large upload or download the user explicitly started, such as exporting a hike’s full GPX track and photos to a cloud backup. The job is marked with JobInfo.Builder.setUserInitiated(true), must be scheduled while the app is visible (or under one of the documented background-activity-start exceptions), needs a network constraint, and must post a notification through JobService.setNotification() as soon as it starts. In return it can run for as long as the system allows, several transfers can run concurrently, and it is not subject to App Standby bucket quotas or to the foreground-service time limits above. There is no Jetpack wrapper; the app uses the platform JobScheduler API directly:

<!-- app/src/main/AndroidManifest.xml -->
<uses-permission android:name="android.permission.RUN_USER_INITIATED_JOBS" />

<service
    android:name="com.example.trailmate.feature.trails.HikeExportJobService"
    android:permission="android.permission.BIND_JOB_SERVICE"
    android:exported="false" />
// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/HikeExportJobService.kt
package com.example.trailmate.feature.trails

import android.app.job.JobInfo
import android.app.job.JobParameters
import android.app.job.JobScheduler
import android.app.job.JobService
import android.content.ComponentName
import android.content.Context
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Build
import android.os.PersistableBundle
import androidx.annotation.RequiresApi
import androidx.core.app.NotificationCompat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelChildren
import kotlinx.coroutines.launch

@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE)
class HikeExportJobService : JobService() {

    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)

    override fun onStartJob(params: JobParameters): Boolean {
        val notification = NotificationCompat.Builder(this, "trail_updates")
            .setContentTitle("Exporting hike")
            .setProgress(100, 0, false)
            .setSmallIcon(android.R.drawable.stat_sys_upload)
            .build()
        // Mandatory for a user-initiated job: attach the notification right away.
        setNotification(params, NOTIFICATION_ID, notification, JOB_END_NOTIFICATION_POLICY_REMOVE)

        val hikeId = params.extras.getString(EXTRA_HIKE_ID)
        scope.launch {
            val succeeded = runCatching {
                // Upload the GPX track and photos for hikeId, re-posting the notification with
                // setProgress(...) as the upload advances.
            }.isSuccess
            jobFinished(params, /* wantsReschedule = */ !succeeded)
        }
        return true // the work continues asynchronously until jobFinished()
    }

    override fun onStopJob(params: JobParameters): Boolean {
        // Called when the network constraint is lost or the system stops the job (not when the
        // user stops it from Task Manager, which kills the process). Returning true reschedules it.
        scope.coroutineContext.cancelChildren()
        return true
    }

    override fun onDestroy() {
        scope.cancel()
        super.onDestroy()
    }

    companion object {
        private const val JOB_ID = 43
        private const val NOTIFICATION_ID = 43
        private const val EXTRA_HIKE_ID = "hikeId"

        /** Call from the "Export hike" click handler, while TrailMate is visible. */
        fun schedule(context: Context, hikeId: String) {
            val network = NetworkRequest.Builder()
                .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
                .build()
            val job = JobInfo.Builder(JOB_ID, ComponentName(context, HikeExportJobService::class.java))
                .setUserInitiated(true)
                .setRequiredNetwork(network)
                .setEstimatedNetworkBytes(/* download = */ 0L, /* upload = */ 50L * 1024 * 1024)
                .setExtras(PersistableBundle().apply { putString(EXTRA_HIKE_ID, hikeId) })
                .build()
            context.getSystemService(JobScheduler::class.java).schedule(job)
        }
    }
}

Because setUserInitiated() only exists on API 34+, and TrailMate’s minSdk is 26, the "Export hike" action checks Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE and falls back to an expedited WorkManager request (above) on older releases. A job scheduled while the app is not visible fails with RESULT_FAILURE, and the user can stop a running transfer from the Task Manager. For anything that does not need to keep running past the user leaving the app, WorkManager’s expedited work is the simpler choice. See Android Developers — User-initiated data transfer and Android Developers — JobService.

AlarmManager and Exact Alarms

AlarmManager schedules a PendingIntent (typically targeting a BroadcastReceiver) to fire at, or near, a specific time — the one API in this page that deliberately bypasses Doze/App-Standby batching for exact alarms, which is exactly why the platform restricts it.

// A reminder to start a saved hike plan at a specific time.
val alarmManager = context.getSystemService(android.app.AlarmManager::class.java)
val intent = Intent(context, HikeReminderReceiver::class.java).apply {
    putExtra("trailId", trailId)
}
val pendingIntent = android.app.PendingIntent.getBroadcast(
    context, trailId.hashCode(), intent,
    android.app.PendingIntent.FLAG_IMMUTABLE,
)

if (alarmManager.canScheduleExactAlarms()) {
    alarmManager.setExactAndAllowWhileIdle(
        android.app.AlarmManager.RTC_WAKEUP,
        triggerAtMillis,
        pendingIntent,
    )
} else {
    // Fall back to an inexact alarm, or direct the user to the exact-alarm settings screen.
    alarmManager.setAndAllowWhileIdle(android.app.AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent)
}
<!-- app/src/main/AndroidManifest.xml -->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />

Since Android 13 (API 33), SCHEDULE_EXACT_ALARM is a special "app op" permission the user can revoke from Settings even after granting it (unlike a normal runtime permission), and Android 14 additionally denies it by default to newly installed apps unless the app declares the stricter USE_EXACT_ALARM permission instead, which Google Play only allows for a narrow set of core use cases (alarm clocks, calendar apps). canScheduleExactAlarms() must always be checked before scheduling — a revoked permission makes setExactAndAllowWhileIdle silently behave like an inexact alarm on some OEM builds, or throw SecurityException on stock Android. setAndAllowWhileIdle (no "Exact") is the inexact equivalent, batched like ordinary Doze-deferred work but requiring no special permission — TrailMate should generally prefer WorkManager over either, and reach for AlarmManager only for a feature that genuinely needs a precise trigger instant, as the decision tree above shows. See Android Developers — Schedule repeating alarms and Android Developers — Schedule alarms.

Broadcasts

A broadcast is a system-wide message — from the OS (BOOT_COMPLETED, ACTION_POWER_CONNECTED, an AlarmManager firing) or from another app/component — delivered to every registered BroadcastReceiver that matches its intent filter.

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/HikeReminderReceiver.kt
package com.example.trailmate.feature.trails

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.workDataOf

class HikeReminderReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        val trailId = intent.getStringExtra("trailId") ?: return
        // onReceive() must return quickly: enqueue work rather than doing real work here.
        val showReminder = OneTimeWorkRequestBuilder<ShowHikeReminderWorker>()
            .setInputData(workDataOf("trailId" to trailId))
            .build()
        WorkManager.getInstance(context).enqueue(showReminder)
    }
}

Two registration styles exist. A manifest-registered receiver (declared with <receiver> in AndroidManifest.xml) can be woken up even while the app process is not running, but since Android 8 (API 26) this only works for a small allow-list of implicit system broadcasts (most others require the receiver to be registered at run time instead). A context-registered receiver (context.registerReceiver(receiver, intentFilter)/ContextCompat.registerReceiver with an explicit RECEIVER_NOT_EXPORTED flag on API 33+) only receives broadcasts while its owning component is alive, and must be unregistered (typically in onStop()/onDestroy()) to avoid a leak. LocalBroadcastManager, once the standard way to broadcast only within the app’s own process, is deprecated — a kotlinx.coroutines.flow.SharedFlow/ MutableStateFlow exposed from a singleton repository, or a Flow-based event bus, replaces it in TrailMate and in current Android guidance generally. See Android Developers — Broadcast overview.

Doze Mode and App Standby Buckets

Doze activates when the device has been stationary, unplugged and screen-off for a while: the system periodically enters a maintenance window (during which network access and jobs briefly resume) and otherwise defers network access, WorkManager/JobScheduler jobs and non-exact alarms for every app. App Standby buckets apply independently of Doze, classifying each app (ACTIVE, WORKING_SET, FREQUENT, RARE, RESTRICTED) by how recently and how often the user actually interacts with it, and throttling how often that specific app’s deferrable jobs and alarms are allowed to run — an app the user opens daily gets far more frequent job execution than one untouched for weeks, even outside Doze.

Neither mechanism is something TrailMate works around directly; it is the reason WorkManager (not raw AlarmManager/JobScheduler) is the default choice throughout this page — WorkManager already accounts for both Doze and standby buckets when it decides when to actually run a PeriodicWorkRequest, so TrailSyncWorker continues to sync reliably, just at a system-controlled cadence, without TrailMate having to detect or query either state itself. RecordHikeService’s foreground-service exemption and `AlarmManager’s `setExactAndAllowWhileIdle are the two deliberate escape hatches from Doze deferral covered above, and both are used sparingly for exactly that reason. See Android Developers — Optimize for Doze and App Standby and Android Developers — Understand battery optimizations for background tasks.

Legacy: IntentService, JobScheduler and Firebase JobDispatcher

Three older APIs predate WorkManager and are now legacy for new code, though they still appear in older codebases and in the six reference books this section draws structure from:

Legacy API Status and modern replacement

IntentService

Deprecated since API 30. Queued intents on a single background thread and stopped itself automatically once idle; superseded by a CoroutineWorker (a single one-time WorkRequest per intent) or, for genuinely short, immediate work started while the app is foregrounded, a plain coroutine launched from a ViewModel/LifecycleScope.

JobScheduler

Still present (API 21+) and is in fact what WorkManager itself schedules through on modern devices, but application code should not call it directly for ordinary deferrable work — the one exception is a user-initiated data transfer job (above), which has no WorkManager equivalent. WorkManager provides the same guarantees plus constraint handling, backoff, chaining and a consistent API across API levels, including the versions before JobScheduler existed.

Firebase JobDispatcher

Deprecated and no longer maintained by Firebase; existed only to backport JobScheduler-like scheduling to pre-API-21 devices. WorkManager’s own internal fallback chain (JobScheduler → AlarmManager+BroadcastReceiver) covers the same need without a Firebase dependency, and TrailMate’s minSdk = 26 means the fallback path is largely academic for it anyway.

None of the three should be reached for in new TrailMate code — every case they historically covered maps onto WorkManager, a foreground service, or a plain coroutine somewhere in this page. See Android Developers — IntentService and Android Developers — JobScheduler.

Common Mistakes

  • Using a foreground service for work that could be deferred. RecordHikeService is justified because the user is actively watching a live hike session; a periodic sync belongs in TrailSyncWorker, not a service that keeps a persistent notification and a wake lock alive far longer than necessary.

  • Forgetting setRequiredNetworkType on a sync worker. Without it, TrailSyncWorker runs even with no connectivity, fails immediately, and (depending on backoff) retries repeatedly for no benefit.

  • Re-enqueuing periodic work without enqueueUniquePeriodicWork. Calling WorkManager.enqueue() on a new PeriodicWorkRequest every time the app launches creates a duplicate, independently-scheduled sync running alongside the original.

  • Declaring the wrong (or no) foreground service type on API 34+. A location-tracking service declared with dataSync, or with no type at all, either crashes at startForeground() or is silently subject to restrictions meant for a different kind of work.

  • Reaching for SCHEDULE_EXACT_ALARM/USE_EXACT_ALARM by default. Exact alarms bypass battery optimizations the system relies on across every app; Google Play policy and the OS both push toward WorkManager or an inexact alarm unless the feature is genuinely time-critical (an alarm clock, not a "sync every morning" reminder).

  • Doing real work inside BroadcastReceiver.onReceive(). The system may kill the process shortly after onReceive() returns; long-running work belongs in an enqueued WorkRequest, not directly in the receiver.