Skip to content
Commitwise
Menu
Android Development

Permissions and notifications

Ask for runtime permissions at the moment they make sense and handle every answer, including "don't ask again", then show notifications properly on modern Android, with channels, the POST_NOTIFICATIONS permission, PendingIntents that open the right screen, and FCM messages that behave the same in the foreground and background.

intermediate

The core idea with one simple example.Step by step, with more examples and exercises.A cheat sheet: key APIs, gotchas and trade-offs.Internals, trade-offs and production pitfalls.

The app’s first screen asks for location, camera, contacts and notifications in four dialogs in a row. Most users tap “Don’t allow” four times, and the features those permissions were for never work again, because Android stops showing the dialog after the second refusal. Months later, the team finds that order notifications haven’t appeared on any Android 13 phone since launch: nobody asked for the notification permission at all.

Permissions and notifications are where Android apps most often feel broken, and they’re mostly about timing and handling every answer.

New to some of these? Open what you need, or skip ahead.

Basics:What is a composable function?

In Jetpack Compose, UI is written as functions marked @Composable that describe what the screen shows for the data they receive. When the data changes, Compose calls them again and updates only what changed. The Jetpack Compose basics tutorial covers them.

Basics:What is a dangerous permission?

Some features touch private data or hardware: location, camera, microphone, contacts, notifications. For these runtime permissions, declaring them in the manifest isn’t enough; the app must ask the user while it runs, and the user can say no, or grant and later revoke them in Settings.

Part Not started

Asking at the right moment

A shop assistant who asks for your phone number the moment you walk in gets a no. One who asks at the till, “so we can text you when your order is ready”, usually gets a yes. Same question, different moment.

Ask for a permission when the user taps the feature that needs it: the camera permission when they tap “Scan receipt”, location when they tap “Find stores near me”. The reason is obvious then, and the feature works right after they agree.

Every request has three possible outcomes, and the app must handle all of them:

  • Granted: continue with the feature.
  • Denied: the feature can’t work; explain briefly and offer an alternative (type the address instead).
  • Denied permanently: after the second refusal, Android doesn’t show the dialog anymore. The only way back is the app’s page in Settings.

Use the Activity Result API (ActivityResultContracts.RequestPermission / RequestMultiplePermissions, or Accompanist’s/compose permission helpers) rather than onRequestPermissionsResult. The system shows the dialog at most twice per permission before treating further requests as denied without UI; shouldShowRequestPermissionRationale is true after one denial (show your explanation) and false both before the first request and after a permanent denial, so you need your own “have I asked before” flag to distinguish them. Prefer permission-free alternatives where they exist: the Photo Picker instead of storage permissions, approximate location instead of precise, ACTION_IMAGE_CAPTURE intents instead of the camera permission when you only need a photo.

@Composable
fun ScanReceiptButton(onScan: () -> Unit) {
    val context = LocalContext.current
    var showRationale by remember { mutableStateOf(false) }
    var permanentlyDenied by remember { mutableStateOf(false) }

    val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
        if (granted) onScan()
        else permanentlyDenied = !context.findActivity()
            .shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)
    }

    Button(onClick = {
        when {
            ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) ==
                PackageManager.PERMISSION_GRANTED -> onScan()
            context.findActivity().shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) ->
                showRationale = true
            else -> launcher.launch(Manifest.permission.CAMERA)
        }
    }) { Text("Scan receipt") }

    if (showRationale) {
        AlertDialog(
            onDismissRequest = { showRationale = false },
            title = { Text("Camera access") },
            text = { Text("The camera is used only to read the receipt. Photos aren't saved.") },
            confirmButton = {
                TextButton(onClick = { showRationale = false; launcher.launch(Manifest.permission.CAMERA) }) {
                    Text("Continue")
                }
            },
            dismissButton = { TextButton(onClick = { showRationale = false }) { Text("Not now") } },
        )
    }

    if (permanentlyDenied) {
        AlertDialog(
            onDismissRequest = { permanentlyDenied = false },
            text = { Text("Camera access is off. You can turn it on in Settings, or type the amount instead.") },
            confirmButton = {
                TextButton(onClick = {
                    permanentlyDenied = false
                    context.startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
                        Uri.fromParts("package", context.packageName, null)))
                }) { Text("Open Settings") }
            },
            dismissButton = { TextButton(onClick = { permanentlyDenied = false }) { Text("Type it") } },
        )
    }
}
  • Check first: if the permission is already granted, just start the feature.
  • Rationale: after the user said no once, Android suggests explaining why before asking again. The explanation is your own dialog, in your own words.
  • Permanently denied: after a refusal with no rationale needed, the system won’t show its dialog again, so offer a button to the app’s Settings page, and an alternative that works without the permission.
  • Check before every use: users can turn permissions off in Settings at any time while the app is in the background.

Predict the output

The user denied the location permission twice. The app calls launcher.launch(ACCESS_FINE_LOCATION) a third time when they tap 'Stores near me'. What does the user see?

Exerciseeasy

Plan the permissions for a running app

A running app needs: precise location during a run, notifications for “run paused” reminders, and access to photos to set a profile picture. Decide when to ask for each, and whether any can be avoided.

›

For each one, find the first tap that actually needs it. Then check whether Android offers a way to do the job without asking at all.

SolutionTry it yourself first
  • Location: ask when the user taps Start run, with a short explanation on the screen before the system dialog (“Your route is recorded while you run”). Precise location is needed for route tracking; if the user grants only approximate, show a notice that the map will be less accurate. Background location during a locked-screen run is better done with a foreground service of type location started from the visible app, which avoids the separate background location permission in most cases.
  • Notifications: ask after the first run, when offering “Remind me if I forget to resume a paused run”, not at first launch.
  • Photos: don’t ask at all. The Photo Picker (PickVisualMedia) lets the user choose one picture without any storage permission.

Permission hygiene

  • Request the minimum: approximate location, Photo Picker, ACTION_IMAGE_CAPTURE, Credential Manager, and the share sheet avoid whole categories of permissions.
  • One-time and auto-reset: users can grant “only this time”, and Android revokes permissions of apps unused for months; never cache a “granted” flag.
  • Play policy: sensitive permissions (background location, all-files access, SMS, call log, exact alarms) require declarations and justification in the Play Console, and get apps rejected when unused.
  • Testing: GrantPermissionRule for instrumented tests, and adb shell pm revoke to test denial paths; UI Automator can tap the system dialog.
  • Activity Result API; ask in context; handle granted, denied (rationale), permanently denied (Settings + alternative). Track your own “asked before” flag, since the rationale flag is false both before the first ask and after permanent denial.
  • Dialog shown at most twice; one-time grants and auto-reset: check before every use.
  • Prefer permission-free alternatives (Photo Picker, approximate location, capture intents); sensitive permissions need Play declarations.

Check this part

  1. When should an app ask for the camera permission?

  2. The user permanently denied a permission. What can the app do?

  3. How does an app pick one profile photo without any storage permission?

Ask for a permission when the user starts the feature that needs it, explain why first if the system says a rationale should be shown, and handle every result: granted, denied, and permanently denied (send the user to Settings). Check before every use, since permissions can be revoked at any time.

Part Not started

Showing notifications

Since Android 8, every notification belongs to a channel: a category like “Orders”, “Chat messages” or “Promotions” that the user can turn on or off and set the sound for in Settings. The app creates its channels once, and must pick one for every notification. Since Android 13, the app also needs the POST_NOTIFICATIONS permission, asked like any other runtime permission.

Tapping a notification should open exactly the thing it’s about: the order, the chat. That’s done with a PendingIntent, a description of what to open, handed to the system in advance.

Channels (NotificationChannelCompat) fix importance, sound and vibration at creation; afterwards only the user can change them, so choose categories by what users want to control separately, and rename rather than recreate. POST_NOTIFICATIONS (API 33+) defaults to off for new installs; existing apps upgrading get a prompt on first notification post when targeting older SDKs, but targeting 33+ requires explicit requests. PendingIntents need FLAG_IMMUTABLE (API 31+ requires a mutability flag), and identity is determined by request code, action, data URI and component, not extras, so distinct notifications need distinct request codes or data URIs. For navigation, use NavDeepLinkBuilder or a deep-link URI handled by the NavHost, building a proper back stack with TaskStackBuilder.

// Once, at app start
fun createChannels(context: Context) {
    val manager = NotificationManagerCompat.from(context)
    manager.createNotificationChannelsCompat(listOf(
        NotificationChannelCompat.Builder("orders", NotificationManagerCompat.IMPORTANCE_DEFAULT)
            .setName("Order updates")
            .setDescription("Shipping and delivery of your orders")
            .build(),
        NotificationChannelCompat.Builder("promotions", NotificationManagerCompat.IMPORTANCE_LOW)
            .setName("Offers")
            .build(),
    ))
}

// When an order ships
fun showOrderShipped(context: Context, orderId: String, carrier: String) {
    if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) !=
        PackageManager.PERMISSION_GRANTED) return                // Android 13+: no permission, no notification

    val intent = Intent(Intent.ACTION_VIEW, "shop://orders/$orderId".toUri(), context, MainActivity::class.java)
    val pending = TaskStackBuilder.create(context).run {
        addNextIntentWithParentStack(intent)                       // Back goes to the app, not the home screen
        getPendingIntent(orderId.hashCode(), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
    }

    val notification = NotificationCompat.Builder(context, "orders")
        .setSmallIcon(R.drawable.ic_package)
        .setContentTitle("Your order has shipped")
        .setContentText("On its way with $carrier")
        .setContentIntent(pending)
        .setAutoCancel(true)
        .build()

    NotificationManagerCompat.from(context).notify(orderId.hashCode(), notification)
}
  • Channels are created at start; creating an existing one again does nothing, so it’s safe to call every time.
  • Check the permission before posting on Android 13+; without it, the notification is silently dropped.
  • The PendingIntent opens shop://orders/1042, which the app’s navigation turns into the order screen, with the app’s main screen behind it for Back.
  • Different ids per order (orderId.hashCode() for both the PendingIntent and the notification) keep two orders’ notifications separate.
Spot the bugNotifications that never appear

Tap the line with the bug.

fun showChatMessage(context: Context, from: String, text: String) {
    val notification = NotificationCompat.Builder(context, "chat_messages")
        .setSmallIcon(R.drawable.ic_chat)
        .setContentTitle(from)
        .setContentText(text)
        .build()
    NotificationManagerCompat.from(context).notify(1, notification)
}

Production puzzles

Spot the bugEvery notification opens the same order

Tap the line with the bug.

fun pendingIntentFor(context: Context, orderId: String): PendingIntent {
    val intent = Intent(context, OrderActivity::class.java)
        .putExtra("orderId", orderId)
    return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE)
}

Good notification citizenship

  • Group related notifications (setGroup with a summary) and update one per conversation instead of stacking.
  • Importance by user value: high (heads-up) only for messages and time-critical alerts; promotions low.
  • Actions (addAction: Reply, Mark as read) with RemoteInput for inline replies, each with its own PendingIntent.
  • Respect the user’s choice: NotificationManagerCompat.areNotificationsEnabled() and per-channel importance tell you whether a notification will show; don’t fall back to other channels to get around a user’s “off”.
  • Channels fix importance/sound at creation; only users change them afterwards. Unknown channel = notification silently dropped.
  • POST_NOTIFICATIONS on API 33+, asked in context; check areNotificationsEnabled().
  • PendingIntent identity = request code + action + data + component (not extras): unique request codes or data URIs; FLAG_IMMUTABLE; TaskStackBuilder or NavDeepLinkBuilder for back stacks.
  • Group, update per conversation, actions with RemoteInput; importance by user value.

Check this part

  1. What happens to a notification posted to a channel that was never created?

  2. What does Android 13 add for notifications?

  3. Why do two order notifications open the same order?

Every notification belongs to a channel the app creates (Android 8+), and on Android 13+ the app needs the POST_NOTIFICATIONS permission. Tapping a notification runs a PendingIntent, which should deep-link to the right screen with a unique request code and FLAG_IMMUTABLE.

Part Not started

Push messages with FCM

Push notifications from your server arrive through Firebase Cloud Messaging (FCM). The app registers and gets a token, sends it to your server, and the server sends messages to that token. The confusing part: FCM has two kinds of messages, and they behave differently depending on whether the app is open.

FCM notification messages (with a notification block) are displayed by the system tray when the app is in the background, and onMessageReceived runs only in the foreground; data messages (only data) always reach onMessageReceived (subject to priority and background restrictions), so the app controls channel, grouping, deep links and localization. High-priority data messages get a short execution window and should be used for user-visible notifications; Android deprioritizes apps whose high-priority messages don’t result in notifications. Heavy work belongs in WorkManager (expedited if urgent). Token lifecycle: onNewToken on rotation, fetch at start, upsert on the server, delete on logout.

class PushService : FirebaseMessagingService() {

    override fun onNewToken(token: String) {
        DeviceRegistrationWorker.enqueue(applicationContext, token)    // retried until the server has it
    }

    override fun onMessageReceived(message: RemoteMessage) {
        val data = message.data
        when (data["type"]) {
            "order_shipped" -> showOrderShipped(
                context = this,
                orderId = data["orderId"] ?: return,
                carrier = data["carrier"].orEmpty(),
            )
            "chat_message" -> ChatNotifications.show(this, data)
            "sync" -> SyncWorker.enqueueExpedited(this)                // no UI: fetch fresh data
        }
    }
}
{
  "message": {
    "token": "fcm-device-token…",
    "android": { "priority": "high" },
    "data": { "type": "order_shipped", "orderId": "1042", "carrier": "DHL" }
  }
}
  • Data-only messages always reach onMessageReceived, in the foreground and background, so the app builds the notification itself: right channel, right text in the user’s language, and a tap that opens the right order.
  • onNewToken hands the new token to WorkManager, which retries until the server has it.
  • Keep onMessageReceived short: for real work (downloading, syncing), enqueue a worker.

Predict the output

The server sends an FCM message with a notification block (title, body) and data { orderId: '1042' }. The app is in the background. The user taps the notification. Was onMessageReceived called, and how does the app learn the order id?

  • Notification messages: system-displayed in background (no onMessageReceived; data in launcher intent extras); data messages: always onMessageReceived → build notifications yourself.
  • High priority only for user-visible notifications; heavy work via (expedited) WorkManager.
  • Token lifecycle: onNewToken, fetch at start, server upsert, delete on logout.

Check this part

  1. Why send FCM data messages and build the notification in the app?

  2. What happens if an app sends many high-priority FCM messages that don't show notifications?

FCM delivers notification messages (shown by the system when the app is in the background, with onMessageReceived only in the foreground) and data messages (always delivered to onMessageReceived). Send data messages and build the notification yourself for consistent behavior, keep the token registered, and do heavy work with WorkManager.

Key takeaways

  • Ask for a permission when the user taps the feature that needs it, and handle granted, denied and permanently denied.
  • Check permissions before every use; users can revoke them at any time.
  • Create notification channels at start, ask for POST_NOTIFICATIONS on Android 13+, and give each notification a tap action to the right screen.
  • Prefer FCM data messages and build notifications in the app.
  • Track “asked before” yourself; prefer permission-free alternatives; Play policy for sensitive permissions.
  • Channel settings belong to users after creation; unknown channels drop notifications silently.
  • PendingIntent identity ignores extras; FLAG_IMMUTABLE; proper back stacks.
  • Data messages, high priority only for visible notifications, WorkManager for the rest.

Finished this page?Nice work, this page is complete!

Mark it as done to track your progress. It's saved in this browser, no account needed.

Its cards are now in your Bites review ⚡
Esc

Type to search the whole site.