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.

Create a free account to save this page, take notes and track your progress. Sign in

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:

Backoff schedule and permission decisions
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 CoroutineWorker for suspend code.
  • Ask for runtime permissions in context, handle denial gracefully, and re-check before use.
  • Notifications on Android 13+ need POST_NOTIFICATIONS and 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.

Was this page helpful?