Android with Kotlin and Jetpack Compose · Production: Dependency Injection, Background Work, Testing and Release
Background Work, Notifications and Runtime Permissions
Run reliable background sync with WorkManager (constraints, backoff, unique work), ask for runtime permissions such as notifications at the right moment, and respect Android's background limits.
What you'll build
An hourly background sync that runs only on an unmetered network, retries with backoff when the server fails, and posts a notification when new orders arrive, after asking for the notification permission at a sensible moment.
The idea
Android aggressively limits background work to save battery. For deferrable work that must eventually run (sync, uploads, cleanup), use WorkManager:
- It survives app restarts and device reboots.
- Constraints: run only when connected, on Wi-Fi, charging or with enough storage.
- Backoff: when a worker returns
Result.retry(), WorkManager waits and retries (exponential by default, starting at 30 seconds and capped at 5 hours). - Unique work prevents duplicate schedules when the app starts several times.
Some features need runtime permissions that the user grants while using the app: notifications (Android 13+), location, camera, contacts. Ask in context (when the user turns on order alerts, not at first launch), explain why if they declined once, and keep the app usable when they say no.
The code
class SyncWorker(context: Context, params: WorkerParameters) : CoroutineWorker(context, params) {
override suspend fun doWork(): Result = try {
val newOrders = repository.sync()
if (newOrders > 0) notifier.showNewOrders(newOrders)
Result.success()
} catch (e: IOException) {
if (runAttemptCount < 5) Result.retry() else Result.failure()
}
}
fun scheduleSync(context: Context) {
val request = PeriodicWorkRequestBuilder<SyncWorker>(1, TimeUnit.HOURS)
.setConstraints(Constraints(requiredNetworkType = NetworkType.UNMETERED))
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork("order-sync", ExistingPeriodicWorkPolicy.KEEP, request)
}
@Composable
fun OrderAlertsToggle(onEnabled: () -> Unit) {
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (granted) onEnabled()
}
Button(onClick = {
if (Build.VERSION.SDK_INT >= 33) launcher.launch(Manifest.permission.POST_NOTIFICATIONS) else onEnabled()
}) { Text("Turn on order alerts") }
}
Declare <uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> in the manifest and create a notification channel before posting.
Try the logic
The retry schedule WorkManager follows with exponential backoff, and the decision of what to show for a permission:
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.seconds
fun backoffDelays(attempts: Int, initial: Duration = 30.seconds, max: Duration = 5.hours): List<Duration> =
(0 until attempts).map { n -> minOf(initial * (1L shl n).toDouble(), max) }
enum class Permission { GRANTED, DENIED_ONCE, DENIED_PERMANENTLY, NOT_ASKED }
fun nextStep(state: Permission) = when (state) {
Permission.GRANTED -> "schedule notifications"
Permission.NOT_ASKED -> "show the system permission dialog"
Permission.DENIED_ONCE -> "explain why alerts help, then ask again"
Permission.DENIED_PERMANENTLY -> "keep the app usable; offer a link to Settings"
}
fun main() {
println(backoffDelays(6).joinToString(", "))
println(backoffDelays(12).last())
Permission.entries.forEach { println("${it.name}: ${nextStep(it)}") }
}
Output
30s, 1m, 2m, 4m, 8m, 16m 5h GRANTED: schedule notifications DENIED_ONCE: explain why alerts help, then ask again DENIED_PERMANENTLY: keep the app usable; offer a link to Settings NOT_ASKED: show the system permission dialog
Run this on your computer — see “Get Started” for setup.
Common mistakes
- Using a plain coroutine or thread for work that must finish. It dies with the process; use WorkManager.
- Enqueuing periodic work without a unique name. Every app start adds another schedule.
- Asking for every permission at launch. Users decline what they don't understand; ask when the feature is used.
- Assuming a permission stays granted. Users can revoke it in Settings; check before each use.
Key points
- WorkManager is the tool for deferrable, guaranteed background work, with constraints and backoff.
- Use unique work names and
CoroutineWorkerfor suspend code. - Ask for runtime permissions in context, handle denial gracefully, and re-check before use.
- Notifications on Android 13+ need
POST_NOTIFICATIONSand a channel.
Further reading: WorkManager, Request runtime permissions.
Practice questions
When should you use WorkManager instead of launching a coroutine?
Answer
When the work must complete even if the user leaves the app or the process is killed, such as syncing, uploading or cleanup, possibly under constraints (network, charging). A coroutine in viewModelScope is for work tied to a screen that can be cancelled with it.
How should an app handle a user declining a runtime permission?
Answer
Keep the related feature disabled but the rest of the app working, explain the benefit if it asks again (shouldShowRequestPermissionRationale), and if the user has permanently declined, offer a way to the app's Settings page instead of asking repeatedly.
Open in the app to mark lessons complete, take notes and track your progress.