Notifications in Android for Android & Kotlin developers
Push notifications are often the first touchpoint between your app and dormant users—but they're also the fastest way to drive them toward uninstalling if handled poorly. Getting notifications right means understanding permissions, channels, and user expectations. In this guide, we'll walk through the modern approach to notifications in Android, covering what changed in Android 13, how to structure your notification strategy, and how to respect user preferences without sacrificing engagement.
Why notifications matter in production apps
Whether you're building an e-commerce app, a ride-hailing platform, or a SaaS dashboard, notifications are a critical re-engagement tool. They remind users about orders, alerts, messages, and time-sensitive events. But they're also intrusive: a poorly timed or badly-designed notification can spark a wave of one-star reviews and app deletions. The challenge is balancing business goals with user experience.
Most production issues we've seen stem from three problems: not requesting the right permissions, using a one-size-fits-all notification channel, and ignoring user settings. Let's address each one systematically.
POST_NOTIFICATIONS permission on Android 13+
Android 13 introduced the POST_NOTIFICATIONS permission, a significant shift in how the OS controls notification behavior. Before Android 13, apps could post notifications automatically once installed. Now, you must explicitly request permission from the user at runtime, just like you do for location or camera access.
If you skip this step, your notifications simply won't appear on Android 13+ devices—no error, no log entry, just silent failure. We've seen this catch production teams off-guard during rollouts.
Declaring the permission
First, declare it in your manifest:
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />This does nothing on Android 12 or earlier, and it doesn't grant the permission automatically on Android 13+. It's just a declaration that your app wants to post notifications.
Requesting at runtime
Next, request permission at runtime. The ideal moment is when the user first encounters a feature that needs notifications—for instance, when they enable order tracking or turn on reminders. Avoid requesting it on app launch; instead, contextualize it.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(
this,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
ActivityCompat.requestPermissions(
this,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
REQUEST_CODE_NOTIFICATIONS
)
}
}Handle the result in onRequestPermissionsResult. If the user denies permission, your app won't crash—it simply won't send notifications. Log this state and consider offering an in-app banner explaining why notifications are useful.
Using NotificationManager to check permission
Before actually posting a notification, verify the permission is granted:
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) == PackageManager.PERMISSION_GRANTED
) {
notificationManager.notify(notificationId, notification)
}
} else {
notificationManager.notify(notificationId, notification)
}This pattern ensures you never attempt to post a notification without permission on Android 13+.
Notification channels per type
Notification channels, introduced in Android 8.0, let users control notification behavior at a granular level. Each channel can have its own sound, vibration, light behavior, and importance level. If you dump all notifications into a single channel, users can't fine-tune their experience—they either accept all notifications or disable them entirely.
Designing your channel strategy
Think about the different notification types in your app and segment them logically. For an e-commerce platform, you might have:
- Order updates: When a package ships, is delivered, or has a delay.
- Promotions: Sales, discounts, and personalized offers.
- Account security: Login attempts, password changes, billing alerts.
- Community: Messages, reviews, social activity.
Each channel should reflect its purpose and urgency. Order updates might be high priority with sound; promotions might be silent.
Creating channels
Create channels in your app's initialization (or in an Application subclass). Channels are persistent—once created, they remain even if you update your app, so their importance level won't change until the user manually adjusts it.
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val orderUpdatesChannel = NotificationChannel(
"order_updates",
"Order Updates",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Notifications about your orders"
enableVibration(true)
setSound(
RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION),
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.build()
)
}
val promotionsChannel = NotificationChannel(
"promotions",
"Promotions",
NotificationManager.IMPORTANCE_LOW
).apply {
description = "Sales and special offers"
enableVibration(false)
}
val securityChannel = NotificationChannel(
"account_security",
"Account Security",
NotificationManager.IMPORTANCE_HIGH
).apply {
description = "Important account alerts"
enableVibration(true)
setSound(
RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM),
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_ALARM)
.build()
)
}
notificationManager.createNotificationChannels(
listOf(orderUpdatesChannel, promotionsChannel, securityChannel)
)
}The importance levels are:
IMPORTANCE_HIGH: Shows as a heads-up notification and makes sound.IMPORTANCE_DEFAULT: Shows as a notification and makes sound.IMPORTANCE_LOW: Shows in the notification drawer, no sound.IMPORTANCE_MIN: Shows only when expanded in the notification drawer.
When posting a notification, reference the channel ID:
val notification = NotificationCompat.Builder(context, "order_updates")
.setContentTitle("Order Shipped")
.setContentText("Your order #12345 is on its way")
.setSmallIcon(R.drawable.ic_notification)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.build()
notificationManager.notify(1001, notification)Deep links into the right screen
A notification that launches your app's home screen is nearly useless. If a user taps a notification about a specific order, they should land on that order's detail screen immediately—not forced to navigate manually.
Building a deep link PendingIntent
Use PendingIntent with an explicit intent or a deep link URI to navigate to the correct destination:
val orderId = "12345"
val deepLinkUri = "myapp://orders/$orderId".toUri()
val intent = Intent(Intent.ACTION_VIEW, deepLinkUri).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pendingIntent = PendingIntent.getActivity(
context,
orderId.hashCode(),
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)The flags matter: FLAG_ACTIVITY_NEW_TASK ensures the activity stack is handled correctly, and FLAG_ACTIVITY_CLEAR_TOP prevents duplicate instances. FLAG_IMMUTABLE is required on Android 12+; it prevents apps from modifying the intent at runtime.
Declaring deep link routes
In your Navigation graph or manifest, declare the deep link routes your app handles:
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="myapp"
android:host="orders"
android:pathPattern="/.*" />
</intent-filter>
</activity>If using Navigation Compose, define deep link destinations in your nav graph:
composable(
route = "order/{orderId}",
deepLinks = listOf(
navDeepLink {
uriPattern = "myapp://orders/{orderId}"
}
)
) { backStackEntry ->
val orderId = backStackEntry.arguments?.getString("orderId")
OrderDetailScreen(orderId)
}Passing data safely
Avoid embedding sensitive data (like auth tokens) in deep link URIs. Instead, pass only the resource ID and fetch the full data from your backend or local database once the app opens. This reduces security risk and keeps URLs manageable.
Respect user settings
Users have multiple ways to control notifications: per-channel settings in the notification settings panel, global do-not-disturb (DND) schedules, and app-level toggles. Respecting these preferences builds trust and reduces uninstalls.
Checking if notifications are enabled
Before posting a notification, verify it won't be silenced by global or channel-level settings:
val notificationManager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val areNotificationsEnabled = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
notificationManager.areNotificationsEnabled() &&
notificationManager.getNotificationChannel("order_updates")?.importance != NotificationManager.IMPORTANCE_NONE
} else {
notificationManager.areNotificationsEnabled()
}If notifications are disabled globally or for a specific channel, you might still want to log this event or show an in-app message instead of silently dropping the notification.
Offering in-app settings
Many users don't know how to adjust notification settings on Android. Provide a user-friendly preferences page in your app that maps to the underlying channels:
// In your settings screen
Button(
onClick = {
val intent = Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, context.packageName)
putExtra(Settings.EXTRA_CHANNEL_ID, "order_updates")
}
context.startActivity(intent)
}
) {
Text("Manage Order Notifications")
}This launches the system notification settings for that specific channel, giving users direct control without leaving your app.
Respecting Do Not Disturb schedules
On Android 6.0+, users can define Do Not Disturb (DND) schedules. Critical notifications (like security alerts) can be marked as IMPORTANCE_HIGH and will bypass DND if the user allows it. Less critical notifications should honor DND and appear only in the notification drawer:
val notification = NotificationCompat.Builder(context, "promotions")
.setContentTitle("Limited-Time Sale")
.setContentText("Get 20% off today")
.setSmallIcon(R.drawable.ic_notification)
.setContentIntent(pendingIntent)
.setPriority(NotificationCompat.PRIORITY_LOW) // Respects DND
.setAutoCancel(true)
.build()Common mistakes
- Forgetting to check POST_NOTIFICATIONS permission before posting: This causes silent failures on Android 13+. Always guard notification posting with a permission check.
- Using a single notification channel for all notification types: Users can't customize their experience, so they often disable all notifications. Segment by purpose and urgency.
- Deep links that land on the home screen: Users tap a notification expecting to go directly to relevant content. A home screen landing page is frustrating and wastes the notification's value.
- Ignoring DND schedules for low-priority notifications: Sending promotions or social updates during DND hours signals your app doesn't respect user time and triggers uninstalls.
- Not handling permission denial gracefully: If the user denies POST_NOTIFICATIONS, your app should offer an explanation and still function normally—just without notifications.
- Embedding secrets in deep link URIs: Auth tokens, API keys, and sensitive IDs should never appear in URLs. Fetch them from storage or a backend after the app launches.
Key takeaways
- Android 13+ requires the
POST_NOTIFICATIONSpermission to be declared in the manifest and requested at runtime. Always check permission status before posting. - Design notification channels per message type and urgency level. Let users control sound, vibration, and importance via channels, not a global toggle.
- Deep links should navigate users directly to the relevant content—an order, a message, a specific feature—not the home screen. Use intent flags to manage the back stack correctly.
- Respect user settings: check if notifications are enabled, honor DND schedules for non-critical messages, and provide in-app settings that map to system channels.
- Test on real devices running Android 13+ to catch permission and channel issues before production. Silent failures are easy to miss in emulators.






Comments
Loading comments…