Notifications are how your app communicates with users when they are not looking at the screen. A chat message arrives. A download completes. A delivery is on the way.

In this tutorial, you will learn how to build notifications from basic to advanced — channels, styles, actions, permission handling, and the new Progress-centric notifications in Android 16.

Prerequisites: You should know Kotlin basics and Compose fundamentals. If not, start with the Kotlin tutorial series and the Jetpack Compose tutorial series.


How Android Notifications Work

Every notification goes through three steps:

  1. Create a notification channel (required since Android 8, API 26)
  2. Build the notification using NotificationCompat.Builder
  3. Show the notification using NotificationManagerCompat

The channel defines the behavior — sound, vibration, importance. The notification defines the content — title, text, actions.


Setting Up

Add the dependency to your build.gradle.kts:

dependencies {
    implementation("androidx.core:core-ktx:1.15.0")
}

That is all you need. NotificationCompat is part of the AndroidX Core library.


Notification Channels

Channels let users control notification behavior per category. Your chat app might have a “Messages” channel (high importance) and a “Promotions” channel (low importance).

Creating a Channel

object NotificationChannels {
    const val MESSAGES = "messages"
    const val DOWNLOADS = "downloads"
    const val DELIVERY = "delivery"

    fun createAll(context: Context) {
        val manager = context.getSystemService(NotificationManager::class.java)

        val messagesChannel = NotificationChannel(
            MESSAGES,
            "Messages",
            NotificationManager.IMPORTANCE_HIGH
        ).apply {
            description = "Chat message notifications"
        }

        val downloadsChannel = NotificationChannel(
            DOWNLOADS,
            "Downloads",
            NotificationManager.IMPORTANCE_LOW
        ).apply {
            description = "File download progress"
        }

        val deliveryChannel = NotificationChannel(
            DELIVERY,
            "Delivery Updates",
            NotificationManager.IMPORTANCE_DEFAULT
        ).apply {
            description = "Order delivery tracking"
        }

        manager.createNotificationChannels(
            listOf(messagesChannel, downloadsChannel, deliveryChannel)
        )
    }
}

Call this from your Application class:

@HiltAndroidApp
class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        NotificationChannels.createAll(this)
    }
}

Importance Levels

LevelBehaviorUse Case
IMPORTANCE_HIGHSound + heads-up popupMessages, calls
IMPORTANCE_DEFAULTSound, no popupEmails, updates
IMPORTANCE_LOWNo sound, no popupDownloads, syncing
IMPORTANCE_MINNo sound, hidden from status barBackground tasks

Users can change the importance level for each channel in Settings. You cannot change it programmatically after creation.


Requesting Permission

Since Android 13 (API 33), you must request the POST_NOTIFICATIONS permission at runtime.

Add to your AndroidManifest.xml:

<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

Request it in Compose:

@Composable
fun NotificationPermissionRequest() {
    val context = LocalContext.current

    val permissionLauncher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.RequestPermission()
    ) { isGranted ->
        if (isGranted) {
            // Permission granted — show notifications
        } else {
            // Permission denied — explain why notifications matter
        }
    }

    LaunchedEffect(Unit) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            val hasPermission = ContextCompat.checkSelfPermission(
                context,
                Manifest.permission.POST_NOTIFICATIONS
            ) == PackageManager.PERMISSION_GRANTED

            if (!hasPermission) {
                permissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
            }
        }
    }
}

Best practice: Don’t request permission immediately on app launch. Wait until the user does something that benefits from notifications (like enabling chat alerts).


Building Basic Notifications

Simple Text Notification

fun showSimpleNotification(context: Context) {
    val notification = NotificationCompat.Builder(context, NotificationChannels.MESSAGES)
        .setSmallIcon(R.drawable.ic_notification)
        .setContentTitle("New Message")
        .setContentText("Alex sent you a message")
        .setPriority(NotificationCompat.PRIORITY_HIGH)
        .setAutoCancel(true)
        .build()

    NotificationManagerCompat.from(context).notify(1, notification)
}

Key parameters:

  • setSmallIcon() — required. Shows in the status bar.
  • setAutoCancel(true) — dismisses the notification when tapped.
  • setPriority() — used on Android 7 and below (channels handle this on Android 8+).

Opening a Screen on Tap

Use a PendingIntent to open a specific screen when the user taps the notification:

fun showNotificationWithNavigation(context: Context) {
    val intent = Intent(context, MainActivity::class.java).apply {
        flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
        putExtra("navigate_to", "chat")
    }

    val pendingIntent = PendingIntent.getActivity(
        context,
        0,
        intent,
        PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
    )

    val notification = NotificationCompat.Builder(context, NotificationChannels.MESSAGES)
        .setSmallIcon(R.drawable.ic_notification)
        .setContentTitle("New Message")
        .setContentText("Alex sent you a photo")
        .setContentIntent(pendingIntent)
        .setAutoCancel(true)
        .build()

    NotificationManagerCompat.from(context).notify(2, notification)
}

Important: Always use PendingIntent.FLAG_IMMUTABLE for security unless the intent needs to be modified by the receiver.


Notification Styles

Plain text notifications are limited. Styles let you show rich content.

Big Text Style

For long messages that don’t fit in one line:

fun showBigTextNotification(context: Context) {
    val longText = "Hey Alex, I just finished the new feature. " +
        "Can you review the pull request when you get a chance? " +
        "I added unit tests and updated the documentation."

    val notification = NotificationCompat.Builder(context, NotificationChannels.MESSAGES)
        .setSmallIcon(R.drawable.ic_notification)
        .setContentTitle("Sam")
        .setContentText(longText)
        .setStyle(
            NotificationCompat.BigTextStyle()
                .bigText(longText)
                .setSummaryText("Code Review")
        )
        .build()

    NotificationManagerCompat.from(context).notify(3, notification)
}

Big Picture Style

Show an image in the notification:

fun showBigPictureNotification(context: Context, bitmap: Bitmap) {
    val notification = NotificationCompat.Builder(context, NotificationChannels.MESSAGES)
        .setSmallIcon(R.drawable.ic_notification)
        .setContentTitle("Photo from Alex")
        .setContentText("Check out this view!")
        .setStyle(
            NotificationCompat.BigPictureStyle()
                .bigPicture(bitmap)
                .bigLargeIcon(null as Bitmap?)
        )
        .setLargeIcon(bitmap)
        .build()

    NotificationManagerCompat.from(context).notify(4, notification)
}

Inbox Style

Show multiple lines like an email inbox:

fun showInboxNotification(context: Context) {
    val notification = NotificationCompat.Builder(context, NotificationChannels.MESSAGES)
        .setSmallIcon(R.drawable.ic_notification)
        .setContentTitle("3 new messages")
        .setContentText("Alex, Sam, and Jordan")
        .setStyle(
            NotificationCompat.InboxStyle()
                .addLine("Alex: Hey, are you free?")
                .addLine("Sam: PR is ready for review")
                .addLine("Jordan: Meeting at 3pm")
                .setSummaryText("3 messages")
        )
        .build()

    NotificationManagerCompat.from(context).notify(5, notification)
}

Messaging Style

For chat apps. Shows a conversation with sender names:

fun showMessagingNotification(context: Context) {
    val sender = Person.Builder()
        .setName("Alex")
        .build()

    val message1 = NotificationCompat.MessagingStyle.Message(
        "Hey, are you free tonight?",
        System.currentTimeMillis() - 60000,
        sender
    )

    val message2 = NotificationCompat.MessagingStyle.Message(
        "Let's grab dinner",
        System.currentTimeMillis(),
        sender
    )

    val you = Person.Builder()
        .setName("You")
        .build()

    val notification = NotificationCompat.Builder(context, NotificationChannels.MESSAGES)
        .setSmallIcon(R.drawable.ic_notification)
        .setStyle(
            NotificationCompat.MessagingStyle(you)
                .setConversationTitle("Alex")
                .addMessage(message1)
                .addMessage(message2)
        )
        .build()

    NotificationManagerCompat.from(context).notify(6, notification)
}

Action Buttons

Actions let users respond directly from the notification without opening the app.

Simple Actions

fun showNotificationWithActions(context: Context) {
    val acceptIntent = PendingIntent.getBroadcast(
        context, 0,
        Intent(context, NotificationReceiver::class.java).apply {
            action = "ACTION_ACCEPT"
        },
        PendingIntent.FLAG_IMMUTABLE
    )

    val declineIntent = PendingIntent.getBroadcast(
        context, 1,
        Intent(context, NotificationReceiver::class.java).apply {
            action = "ACTION_DECLINE"
        },
        PendingIntent.FLAG_IMMUTABLE
    )

    val notification = NotificationCompat.Builder(context, NotificationChannels.MESSAGES)
        .setSmallIcon(R.drawable.ic_notification)
        .setContentTitle("Meeting Invite")
        .setContentText("Team standup at 10:00 AM")
        .addAction(R.drawable.ic_check, "Accept", acceptIntent)
        .addAction(R.drawable.ic_close, "Decline", declineIntent)
        .build()

    NotificationManagerCompat.from(context).notify(7, notification)
}

Handle the action in a BroadcastReceiver:

class NotificationReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        when (intent.action) {
            "ACTION_ACCEPT" -> {
                // Accept the meeting
            }
            "ACTION_DECLINE" -> {
                // Decline the meeting
            }
        }
        // Dismiss the notification
        NotificationManagerCompat.from(context).cancel(7)
    }
}

Reply Action

Let users type a reply directly in the notification:

fun showReplyNotification(context: Context) {
    val remoteInput = RemoteInput.Builder("key_reply")
        .setLabel("Type a message...")
        .build()

    val replyIntent = PendingIntent.getBroadcast(
        context, 0,
        Intent(context, ReplyReceiver::class.java),
        PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
    )

    val replyAction = NotificationCompat.Action.Builder(
        R.drawable.ic_reply,
        "Reply",
        replyIntent
    )
        .addRemoteInput(remoteInput)
        .build()

    val notification = NotificationCompat.Builder(context, NotificationChannels.MESSAGES)
        .setSmallIcon(R.drawable.ic_notification)
        .setContentTitle("Alex")
        .setContentText("Are you coming to dinner?")
        .addAction(replyAction)
        .build()

    NotificationManagerCompat.from(context).notify(8, notification)
}

Read the reply in your receiver:

class ReplyReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        val reply = RemoteInput.getResultsFromIntent(intent)
            ?.getCharSequence("key_reply")
            ?.toString()

        if (reply != null) {
            // Send the reply to your server
            // Update the notification to show "Message sent"
            val updatedNotification = NotificationCompat.Builder(
                context, NotificationChannels.MESSAGES
            )
                .setSmallIcon(R.drawable.ic_notification)
                .setContentTitle("Alex")
                .setContentText("You: $reply")
                .build()

            NotificationManagerCompat.from(context).notify(8, updatedNotification)
        }
    }
}

Note: The reply PendingIntent must use FLAG_MUTABLE because the system modifies it to include the text input.


Notification Groups

When your app shows multiple notifications, group them together so the notification drawer stays clean.

const val GROUP_MESSAGES = "group_messages"

fun showGroupedNotifications(context: Context) {
    // Individual notifications
    val notification1 = NotificationCompat.Builder(context, NotificationChannels.MESSAGES)
        .setSmallIcon(R.drawable.ic_notification)
        .setContentTitle("Alex")
        .setContentText("Hey!")
        .setGroup(GROUP_MESSAGES)
        .build()

    val notification2 = NotificationCompat.Builder(context, NotificationChannels.MESSAGES)
        .setSmallIcon(R.drawable.ic_notification)
        .setContentTitle("Sam")
        .setContentText("PR approved!")
        .setGroup(GROUP_MESSAGES)
        .build()

    // Summary notification (shown on Android 7+)
    val summaryNotification = NotificationCompat.Builder(context, NotificationChannels.MESSAGES)
        .setSmallIcon(R.drawable.ic_notification)
        .setContentTitle("2 new messages")
        .setStyle(
            NotificationCompat.InboxStyle()
                .addLine("Alex: Hey!")
                .addLine("Sam: PR approved!")
                .setSummaryText("2 messages")
        )
        .setGroup(GROUP_MESSAGES)
        .setGroupSummary(true)
        .build()

    val manager = NotificationManagerCompat.from(context)
    manager.notify(100, notification1)
    manager.notify(101, notification2)
    manager.notify(99, summaryNotification)
}

Always provide a summary notification. Without it, individual notifications may not group properly on all devices.


Progress Notifications

Show a progress bar for long-running operations:

fun showProgressNotification(context: Context) {
    val manager = NotificationManagerCompat.from(context)
    val notificationId = 10

    // Initial progress
    val builder = NotificationCompat.Builder(context, NotificationChannels.DOWNLOADS)
        .setSmallIcon(R.drawable.ic_download)
        .setContentTitle("Downloading file")
        .setContentText("0%")
        .setPriority(NotificationCompat.PRIORITY_LOW)
        .setOngoing(true)

    // Show indeterminate progress first
    builder.setProgress(100, 0, false)
    manager.notify(notificationId, builder.build())

    // Update progress (call this from your download callback)
    // builder.setProgress(100, 50, false)
    // builder.setContentText("50%")
    // manager.notify(notificationId, builder.build())

    // When complete
    // builder.setContentText("Download complete")
    //     .setProgress(0, 0, false)
    //     .setOngoing(false)
    // manager.notify(notificationId, builder.build())
}

Android 16 Progress-Centric Notifications

Android 16 introduces Progress-centric notifications for multi-stage journeys. These notifications get upgraded visibility — they appear at the top of the notification drawer with a dedicated progress section.

This is ideal for delivery tracking, ride sharing, navigation, and any multi-step process.

fun showDeliveryProgressNotification(context: Context) {
    val steps = listOf("Order Confirmed", "Preparing", "Out for Delivery", "Delivered")
    val currentStep = 2 // "Out for Delivery"

    val notification = NotificationCompat.Builder(context, NotificationChannels.DELIVERY)
        .setSmallIcon(R.drawable.ic_delivery)
        .setContentTitle("Your order is on the way")
        .setContentText(steps[currentStep])
        .setStyle(
            NotificationCompat.BigTextStyle()
                .bigText("Step ${currentStep + 1} of ${steps.size}: ${steps[currentStep]}")
        )
        .setProgress(steps.size, currentStep + 1, false)
        .setOngoing(true)
        .setCategory(NotificationCompat.CATEGORY_PROGRESS)
        .build()

    NotificationManagerCompat.from(context).notify(20, notification)
}

On Android 16 devices, setting CATEGORY_PROGRESS along with progress indicators gives the notification elevated ranking in the drawer.

Note: Android 16 (API 36) also introduces NotificationCompat.ProgressStyle — a richer style with visual segments and tracker icons for the new “Live Updates” treatment. The example above uses the compatible BigTextStyle + CATEGORY_PROGRESS approach that works on all API levels. For the full Android 16 visual treatment, see the Android 16 progress-centric notifications guide.


Building a Notification Helper

In a real app, create a helper class to keep notification logic centralized:

class NotificationHelper(private val context: Context) {

    private val manager = NotificationManagerCompat.from(context)

    fun showChatMessage(
        senderName: String,
        message: String,
        notificationId: Int
    ) {
        if (!hasPermission()) return

        val intent = Intent(context, MainActivity::class.java).apply {
            putExtra("navigate_to", "chat")
        }

        val pendingIntent = PendingIntent.getActivity(
            context, notificationId, intent,
            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
        )

        val remoteInput = RemoteInput.Builder("key_reply")
            .setLabel("Reply to $senderName")
            .build()

        val replyIntent = PendingIntent.getBroadcast(
            context, notificationId,
            Intent(context, ReplyReceiver::class.java).apply {
                putExtra("notification_id", notificationId)
                putExtra("sender", senderName)
            },
            PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
        )

        val replyAction = NotificationCompat.Action.Builder(
            R.drawable.ic_reply, "Reply", replyIntent
        ).addRemoteInput(remoteInput).build()

        val sender = Person.Builder().setName(senderName).build()

        val notification = NotificationCompat.Builder(context, NotificationChannels.MESSAGES)
            .setSmallIcon(R.drawable.ic_notification)
            .setStyle(
                NotificationCompat.MessagingStyle(
                    Person.Builder().setName("You").build()
                )
                    .setConversationTitle(senderName)
                    .addMessage(message, System.currentTimeMillis(), sender)
            )
            .setContentIntent(pendingIntent)
            .addAction(replyAction)
            .setAutoCancel(true)
            .setGroup(GROUP_MESSAGES)
            .build()

        manager.notify(notificationId, notification)
    }

    fun showDownloadProgress(progress: Int, fileName: String) {
        if (!hasPermission()) return

        val builder = NotificationCompat.Builder(context, NotificationChannels.DOWNLOADS)
            .setSmallIcon(R.drawable.ic_download)
            .setContentTitle("Downloading $fileName")
            .setProgress(100, progress, false)
            .setContentText("$progress%")
            .setOngoing(progress < 100)

        if (progress >= 100) {
            builder.setContentText("Download complete")
                .setProgress(0, 0, false)
        }

        manager.notify(fileName.hashCode(), builder.build())
    }

    private fun hasPermission(): Boolean {
        return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            ContextCompat.checkSelfPermission(
                context,
                Manifest.permission.POST_NOTIFICATIONS
            ) == PackageManager.PERMISSION_GRANTED
        } else {
            true
        }
    }
}

Inject this with Hilt:

@Module
@InstallIn(SingletonComponent::class)
object NotificationModule {

    @Provides
    @Singleton
    fun provideNotificationHelper(
        @ApplicationContext context: Context
    ): NotificationHelper = NotificationHelper(context)
}

Edge-to-Edge Considerations

Starting from Android 15 (API 35), edge-to-edge display is mandatory for all apps. This means your app draws behind the system bars (status bar and navigation bar). When a notification opens your app, make sure your screens handle WindowInsets correctly.

@Composable
fun ChatScreen(
    modifier: Modifier = Modifier
) {
    Scaffold(
        topBar = {
            TopAppBar(title = { Text("Chat") })
        }
    ) { paddingValues ->
        LazyColumn(
            modifier = modifier.padding(paddingValues),
            contentPadding = PaddingValues(16.dp)
        ) {
            // Chat messages
        }
    }
}

Scaffold handles insets automatically. If you build custom layouts, use WindowInsets.systemBars to add the right padding. Check Compose Tutorial #20: Adaptive Layouts for a deep dive.


Common Mistakes

1. Forgetting to Create the Channel

If the channel does not exist, the notification is silently dropped on Android 8+. Always create channels at app startup.

2. Using Wrong PendingIntent Flags

FLAG_MUTABLE is required for reply actions. FLAG_IMMUTABLE is required for everything else. Mixing them up causes crashes on Android 12+.

3. Not Handling Permission Denial

On Android 13+, if the user denies the notification permission, all notify() calls silently fail. Check permission before building the notification to avoid wasted work.

4. Showing Too Many Notifications

Notification spam makes users disable your channel or uninstall the app. Group related notifications and use appropriate importance levels.

5. Missing Small Icon

The setSmallIcon() call is required. Without it, the notification will not show and you will get an error in Logcat.


Testing Notifications

Test on both physical devices and emulator:

// In your Compose UI — add a test button
@Composable
fun NotificationTestScreen(
    notificationHelper: NotificationHelper
) {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(8.dp)
    ) {
        Button(onClick = {
            notificationHelper.showChatMessage(
                senderName = "Alex",
                message = "Hey, check out this notification!",
                notificationId = 1001
            )
        }) {
            Text("Show Chat Notification")
        }

        Button(onClick = {
            notificationHelper.showDownloadProgress(50, "report.pdf")
        }) {
            Text("Show Download Progress (50%)")
        }

        Button(onClick = {
            notificationHelper.showDownloadProgress(100, "report.pdf")
        }) {
            Text("Complete Download")
        }
    }
}

Use adb to test from the command line:

# Check notification channels
adb shell dumpsys notification | grep -A 5 "your.package.name"

# Revoke notification permission (to test denial flow)
adb shell pm revoke your.package.name android.permission.POST_NOTIFICATIONS

What’s Next?

In this tutorial, you learned:

  • How notification channels control behavior and importance
  • Building notifications with different styles (big text, big picture, inbox, messaging)
  • Adding action buttons and inline reply
  • Grouping notifications and showing progress
  • Android 16 Progress-centric notifications for multi-stage journeys
  • Requesting the POST_NOTIFICATIONS permission

Next up: Android Tutorial #12: Foreground Services — Music Player, Location Tracking — where you will learn how to run long-running tasks that show a persistent notification.



This is part 11 of the Android Development Tutorial series.