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.
| 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 |
Silent, unattended transfers — for that, use WorkManager with a network constraint instead. |
|
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. |
|
Reacting to a system event ( |
Long-running work — a receiver’s |
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" }
See Android Developers — Persistent work with WorkManager and Android Developers — Getting started with WorkManager.
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 |
|---|---|
|
|
|
Only run while the device is plugged in and charging. |
|
Skip while the battery is critically low. |
|
Skip while device storage is critically low. |
|
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 |
|---|---|
|
Do nothing if work with this name is already pending/running; drop the new request. |
|
Cancel the existing work and enqueue the new request in its place. |
|
Add the new request as a dependent of the existing chain’s last stage (one-time work only). |
|
Like |
|
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 |
|---|---|
|
Continuous location tracking the user is aware of — |
|
Recording video/audio while the app may not be in the foreground. |
|
Playing audio/video, e.g. a music or podcast player. |
|
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. |
|
Interacting with an external Bluetooth/USB/companion device. |
|
Narrower cases — a fitness tracker with the |
<!-- 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) —
RecordHikeServicemust be started whileMainActivity/TrailDetailScreenis in the foreground, which matches TrailMate’s actual UX (the user taps "Start recording" while the app is open). -
dataSynctime limit (Android 15). For apps targeting Android 15 (API 35) or higher, adataSync-type foreground service that has run for six cumulative hours within a 24-hour window gets itsService.onTimeout()callback (added in API 35) and must callstopSelf()within a few seconds; the budget resets when the user brings the app to the foreground. Android 15 also stopsdataSyncservices from being launched from aBOOT_COMPLETEDreceiver. TrailMate does not usedataSyncfor exactly this reason, preferring WorkManager expedited work or UIDT (below) for data transfer. -
ForegroundServiceStartNotAllowedException. Thrown fromstartForegroundService()/startForeground()when a background-start restriction is violated — catching it and falling back to a deferredWorkManagerrequest 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 |
|---|---|
|
Deprecated since API 30. Queued intents on a single background thread and stopped itself automatically once idle; superseded by a |
|
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 |
Firebase JobDispatcher |
Deprecated and no longer maintained by Firebase; existed only to backport |
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.
RecordHikeServiceis justified because the user is actively watching a live hike session; a periodic sync belongs inTrailSyncWorker, not a service that keeps a persistent notification and a wake lock alive far longer than necessary. -
Forgetting
setRequiredNetworkTypeon a sync worker. Without it,TrailSyncWorkerruns even with no connectivity, fails immediately, and (depending on backoff) retries repeatedly for no benefit. -
Re-enqueuing periodic work without
enqueueUniquePeriodicWork. CallingWorkManager.enqueue()on a newPeriodicWorkRequestevery 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 withdataSync, or with no type at all, either crashes atstartForeground()or is silently subject to restrictions meant for a different kind of work. -
Reaching for
SCHEDULE_EXACT_ALARM/USE_EXACT_ALARMby 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 afteronReceive()returns; long-running work belongs in an enqueuedWorkRequest, not directly in the receiver.