Android Notification Handler Guide
Introduction
Notifications are one of the most powerful ways to communicate with users on Android, whether your app is in the foreground, background, or completely killed. A well-implemented notification system keeps users informed without being intrusive. This guide walks through the complete notification stack on modern Android: channel configuration, building notifications with the compatibility library, handling user taps through pending intents, promoting a service to the foreground, and correctly requesting the POST_NOTIFICATIONS runtime permission on Android 13 (API 33) and above. Each section builds on the previous one, so by the end you will have a production-ready notification handler ready to drop into your project.
Understanding Notification Channels
Notification channels are the foundational grouping mechanism introduced in Android 8.0 (API 26). Every notification must be assigned to a channel, and the channel determines the sound, vibration pattern, lock-screen visibility, and even whether the user receives an at-a-glance summary or a persistent status-bar icon. You create channels once during early app startup—typically inside your Application subclass or the first launched Activity—and the system persists their configuration. After the first creation, subsequent attempts to create a channel with the same ID are ignored, but you can update group-level properties like the channel's description.
The code below demonstrates how to create a channel for low-priority updates and one for urgent alerts. Notice that we check the SDK version before invoking any channel APIs; on devices below API 26 the NotificationManagerCompat.createNotificationChannels() call is a no-op, so the guard keeps older builds from crashing.
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
class NotificationChannelHelper(private val context: Context) {
companion object {
const val CHANNEL_UPDATES = "channel_updates"
const val CHANNEL_ALERTS = "channel_alerts"
}
fun createChannels() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val updatesChannel = NotificationChannel(
CHANNEL_UPDATES,
"Updates",
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = "Low-priority status updates"
lightColor = android.graphics.Color.BLUE
enableVibration(false)
}
val alertsChannel = NotificationChannel(
CHANNEL_ALERTS,
"Alerts",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Urgent notifications requiring immediate attention"
lightColor = android.graphics.Color.RED
enableVibration(true)
lockscreenVisibility = NotificationCompat.VISIBILITY_PRIVATE
}
manager.createNotificationChannels(listOf(updatesChannel, alertsChannel))
}
}
The helper above creates two distinct channels: one for routine background updates and another for time-critical alerts. The IMPORTANCE_HIGH level on the alerts channel guarantees that the notification will make a sound and appear as a heads-up banner on locked screens, while the default-importance channel produces a quiet notification. Because createNotificationChannels() accepts a list, both channels are registered in a single system call, which is more efficient than making two separate calls.
Declaring the Permission in Manifest
Before your app can post any notification on Android 13 and above, you must declare the POST_NOTIFICATIONS permission in your manifest and request it at runtime. On Android 12 (API 31) and below, no special permission is required to post notifications—the restriction was added later to give users finer-grained control. Always include the manifest declaration regardless of your minSdkVersion, because static analysis tools and automated checks will flag its absence even if your runtime logic only requests the permission on API 33+.
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
package="com.example.notificationhandler">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".MyApplication"
android:allowBackup="true"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.NotificationHandler">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
Including the permission in the manifest alone does not grant it—Android treats POST_NOTIFICATIONS as a dangerous permission, which means the system will always prompt the user at runtime. This two-step model (manifest declaration plus runtime request) is consistent with every other sensitive permission on the platform, and it ensures your app cannot silently start spamming notifications after an OTA update.
Building Notifications with NotificationCompat
The NotificationCompat.Builder class lives in AndroidX and shields you from the countless behavioral changes that Google has introduced across OS releases. It lets you compose rich notifications—titles, body text, inline actions, progress indicators, and custom views—while automatically using the appropriate framework APIs for whatever Android version is running on the device. The builder also gives you control over notification prioritization, icon styling, and auto-dismiss behavior.
Below is a complete method that posts a simple text notification to the updates channel, incrementing a counter so the same notification updates rather than creating a duplicate each time.
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
class NotificationService(private val context: Context) {
private val manager = NotificationManagerCompat.from(context)
private var notificationCounter = 0
fun sendUpdate(title: String, body: String) {
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pendingIntentFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
val pendingIntent = PendingIntent.getActivity(
context, 0, intent, pendingIntentFlags
)
val notification = NotificationCompat.Builder(context, NotificationChannelHelper.CHANNEL_UPDATES)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(title)
.setContentText(body)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.build()
manager.notify("update_key", ++notificationCounter, notification)
}
}
This example uses "update_key" as a stable notification tag so the system replaces the existing notification instead of stacking a new one each time the method is called. The notificationCounter is passed as the request code to keep each notification entry uniquely identifiable, which is useful when you want to cancel or update a specific notification later. We also set setAutoCancel(true) so the notification dismisses when the user taps it, and FLAG_IMMUTABLE on the pending intent because that is now the required flag on modern Android—using FLAG_MUTABLE will throw an exception on API 31+.
Handling User Interaction with PendingIntent
Notifications become interactive when you attach a PendingIntent that the system fires on the user's tap. PendingIntent is a token you hand to the system that retains the right to execute an operation on your behalf, even after your process has died. For notification actions you typically use either getActivity() to launch a screen or getBroadcast() to trigger a BroadcastReceiver for quick actions like snooze or dismiss.
The next snippet shows a notification with two inline action buttons—a "Snooze" action that sends a broadcast and a "Dismiss" action that simply cancels the notification.
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
const val ACTION_SNOOZE = "com.example.SNOOZE"
const val ACTION_DISMISS = "com.example.DISMISS"
fun sendActionableNotification(context: Context) {
val snoozeIntent = Intent(context, NotificationReceiver::class.java).apply {
action = ACTION_SNOOZE
}
val snoozeFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
val snoozePendingIntent = PendingIntent.getBroadcast(
context, 100, snoozeIntent, snoozeFlags
)
val dismissIntent = Intent(context, NotificationReceiver::class.java).apply {
action = ACTION_DISMISS
}
val dismissPendingIntent = PendingIntent.getBroadcast(
context, 101, dismissIntent, if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
)
val notification = NotificationCompat.Builder(context, NotificationChannelHelper.CHANNEL_ALERTS)
.setSmallIcon(android.R.drawable.ic_dialog_alert)
.setContentTitle("Important Alert")
.setContentText("Tap to view details")
.setPriority(NotificationCompat.PRIORITY_HIGH)
.addAction(android.R.drawable.ic_menu_revert, "Snooze", snoozePendingIntent)
.addAction(android.R.drawable.ic_menu_delete, "Dismiss", dismissPendingIntent)
.build()
NotificationManagerCompat.from(context).notify("actionable_key", notification)
}
The two actions here demonstrate why you need separate request codes (100 and 101) when registering multiple pending intents that target the same underlying BroadcastReceiver. Without distinct codes, the system would overwrite one pending intent with the other, and only the last one registered would ever fire. The FLAG_IMMUTABLE flag is mandatory here as well because these broadcasts will execute outside your app's process.
Running Foreground Services with Notifications
Some operations—such as music playback, fitness tracking, or location recording—must continue while the user is on another app. Android enforces this by requiring a foreground service to display an ongoing notification that the user can see and interact with. A foreground service notification has a different appearance from a regular notification: it typically uses a gray background, cannot be swiped away, and includes a "Clear" button in the notification settings.
The following example shows how to start a foreground service and post the required notification using a dedicated channel.
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
class TrackingService : Service() {
companion object {
const val CHANNEL_TRACKING = "channel_tracking"
const val ACTION_START = "com.example.START_TRACKING"
const val ACTION_STOP = "com.example.STOP_TRACKING"
fun startIntent(context: Context) = Intent(context, TrackingService::class.java).apply {
action = ACTION_START
}
}
override fun onCreate() {
super.onCreate()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_TRACKING,
"Active Tracking",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Tracks your activity in the background"
setShowBadge(false)
}
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as android.app.NotificationManager
manager.createNotificationChannel(channel)
}
val notification = NotificationCompat.Builder(this, CHANNEL_TRACKING)
.setSmallIcon(android.R.drawable.ic_menu_mylocation)
.setContentTitle("Tracking Active")
.setContentText("Your activity is being recorded")
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
startForeground(1, notification)
} else {
@Suppress("DEPRECATION")
startForeground(1, notification)
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
return START_STICKY
}
override fun onDestroy() {
super.onDestroy()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
@Suppress("DEPRECATION")
stopForeground(true)
}
}
override fun onBind(intent: Intent?): IBinder? = null
}
The key detail here is that startForeground() must be called before the service becomes visible to the system, which is why we build and post the notification inside onCreate(). Calling setOngoing(true) tells the system this notification should remain persistent and not be dismissed by swipe. When stopForeground(STOP_FOREGROUND_REMOVE) is called in onDestroy(), the notification is removed along with the service, keeping the status bar clean.
Android 13+ Runtime Permission Request
Even with the manifest declaration, your app will fail to post notifications on Android 13 and above unless the user explicitly grants the permission at runtime. The permission request flow works like any other dangerous permission: you check ContextCompat.checkSelfPermission(), and if the result is PERMISSION_DENIED, you call ActivityResultContracts.RequestPermission(). Using the Activity Result API is preferred over the deprecated requestPermissions() because it integrates cleanly with AndroidX lifecycle and provides a typed callback.
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import com.example.notificationhandler.databinding.ActivityMainBinding
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted: Boolean ->
if (isGranted) {
Toast.makeText(this, "Notification permission granted", Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this, "Notification permission denied", Toast.LENGTH_SHORT).show()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
NotificationChannelHelper(this).createChannels()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
when {
ContextCompat.checkSelfPermission(
this, Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED -> {
// Permission already granted; safe to post.
}
shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS) -> {
Toast.makeText(this, "Grant permission to receive updates", Toast.LENGTH_LONG).show()
}
else -> {
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
binding.btnSendNotification.setOnClickListener {
NotificationService(this).sendUpdate("Hello", "This is a test notification")
}
}
}
This activity ties everything together: it initializes the notification channels immediately on launch, checks the Android version before touching the permission flow, and launches the permission request only when necessary. The shouldShowRequestPermissionRationale branch lets you explain to the user why the permission matters before actually requesting it, which improves your chance of a successful grant. Importantly, if the user previously denied the permission and checked "Never ask again," the rationale callback returns false and you should navigate them to the system settings page instead of retrying the request.
Common Pitfalls
Developers new to Android notifications tend to run into the same few issues. First, forgetting FLAG_IMMUTABLE on PendingIntent construction is a silent crash on API 31+—always include it. Second, creating a new notification channel with the same ID after the user has customized its settings will not override those settings; the channel ID is the permanent key. Third, omitting the POST_NOTIFICATIONS manifest declaration causes your notification to be silently dropped on Android 13+ with no runtime exception, which makes debugging especially frustrating. Fourth, calling startForeground() without first posting a notification results in a IllegalStateException, so the order of operations matters. Finally, using NotificationManager.notify() with the same ID but different tags on older Android versions can cause unexpected replacement behavior—always use a combination of tag and ID that reflects the logical scope of the notification.
Conclusion
Building a reliable notification system on Android requires attention to channel design, permission handling, pending intent flags, and foreground service lifecycle. By registering channels at startup, using NotificationCompat to stay compatible across versions, requesting POST_NOTIFICATIONS through the Activity Result API, and correctly wiring PendingIntent flags, your app will behave consistently on every Android release from API 26 through the latest. Use this guide as a reference checklist whenever you implement or audit notification code in your projects.