Section 3 · Notifications
Channels, styles, scheduling, permissions — the complete Android notification stack
Architecture Overview
Android’s notification system has three layers:
- Notification Channel — a category bucket that the user can configure independently (sound, vibration, importance). Required on Android 8.0+ (API 26). One app has one or more channels; the user controls each.
- Notification — the individual alert built with
NotificationCompat.Builder. It belongs to a channel. - NotificationManager — the system service that posts, updates, and cancels notifications.
1
2
3
4
5
6
7
App → NotificationManagerCompat → NotificationManager (system)
│
Channel (API 26+)
importance / sound / vibration
│
Notification
title / text / icon / actions / style
Android Developers — Notifications overview
Notification Channels (API 26+)
Channels must be created before posting any notification. Creating a channel that already exists is a no-op, so it is safe to call at app startup.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
object NotificationChannels {
const val SYNC = "sync_channel"
const val ALERTS = "alerts_channel"
const val DOWNLOADS = "downloads_channel"
}
fun createChannels(context: Context) {
val manager = context.getSystemService(NotificationManager::class.java)
val syncChannel = NotificationChannel(
NotificationChannels.SYNC,
"Background Sync",
NotificationManager.IMPORTANCE_LOW // silent, no heads-up
).apply {
description = "Periodic data synchronisation status"
setShowBadge(false)
}
val alertsChannel = NotificationChannel(
NotificationChannels.ALERTS,
"Important Alerts",
NotificationManager.IMPORTANCE_HIGH // heads-up + sound
).apply {
description = "Critical alerts that require your attention"
enableLights(true)
lightColor = Color.RED
enableVibration(true)
}
val downloadsChannel = NotificationChannel(
NotificationChannels.DOWNLOADS,
"Downloads",
NotificationManager.IMPORTANCE_DEFAULT
)
manager.createNotificationChannels(listOf(syncChannel, alertsChannel, downloadsChannel))
}
Importance levels
| Importance constant | UI behaviour | Use when |
|---|---|---|
IMPORTANCE_HIGH |
Heads-up banner + sound + vibration | Incoming call, urgent alert |
IMPORTANCE_DEFAULT |
Sound + status bar icon, no heads-up | New message, reminder |
IMPORTANCE_LOW |
Status bar icon only, no sound | Background sync progress |
IMPORTANCE_MIN |
No icon in status bar, no sound | Fully silent, informational |
The user can override channel importance at any time in Settings → App Info → Notifications. You can set the initial importance, but you cannot force it back up after the user lowers it.
Runtime Permission (Android 13+)
Android 13 (API 33) introduced POST_NOTIFICATIONS, a runtime permission. Without it, no notifications from your app appear.
1
2
<!-- AndroidManifest.xml -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// Request in an Activity or Fragment
private val requestPermissionLauncher =
registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
if (!granted) {
// show rationale or disable notification features
}
}
override fun onStart() {
super.onStart()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(
this, Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
Google — Notification runtime permission
Building a Notification
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
fun buildBasicNotification(context: Context): Notification {
val tapIntent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
}
val pendingIntent = PendingIntent.getActivity(
context,
0,
tapIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
return NotificationCompat.Builder(context, NotificationChannels.ALERTS)
.setSmallIcon(R.drawable.ic_notification) // mandatory — 24×24 dp, alpha only
.setContentTitle("Sync complete")
.setContentText("42 items updated")
.setSubText("Last sync") // third line of text (optional)
.setContentIntent(pendingIntent) // tap action
.setAutoCancel(true) // dismiss on tap
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.build()
}
NotificationManagerCompat.from(context)
.notify(NOTIFICATION_ID, notification)
PendingIntent.FLAG_IMMUTABLEis required on Android 12+ (API 31). TheMUTABLEflag is only needed when the system needs to fill in intent extras (e.g., inline reply actions).
Notification Styles
Styles expand the notification to show richer content when the user swipes down.
BigTextStyle
1
2
3
4
5
6
7
8
9
10
11
12
NotificationCompat.Builder(context, NotificationChannels.ALERTS)
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle("Server error")
.setContentText("An error occurred. Tap to see details.")
.setStyle(
NotificationCompat.BigTextStyle()
.bigText("HTTP 503 Service Unavailable on /api/v2/sync. " +
"Retry scheduled in 15 minutes. Check server logs for details.")
.setBigContentTitle("Server Error")
.setSummaryText("api.example.com")
)
.build()
BigPictureStyle
1
2
3
4
5
6
7
8
9
NotificationCompat.Builder(context, NotificationChannels.ALERTS)
.setSmallIcon(R.drawable.ic_photo)
.setContentTitle("Photo uploaded")
.setStyle(
NotificationCompat.BigPictureStyle()
.bigPicture(bitmap)
.bigLargeIcon(null as Bitmap?) // hide the large icon when expanded
)
.build()
InboxStyle — multiple lines
1
2
3
4
5
6
7
8
9
10
11
NotificationCompat.Builder(context, NotificationChannels.ALERTS)
.setSmallIcon(R.drawable.ic_email)
.setContentTitle("3 new messages")
.setStyle(
NotificationCompat.InboxStyle()
.addLine("Alice: See you tomorrow")
.addLine("Bob: The meeting is at 3pm")
.addLine("Carol: Did you get my email?")
.setSummaryText("+3 more")
)
.build()
MessagingStyle — chat threads
1
2
3
4
5
6
7
8
9
10
11
12
13
14
val person = Person.Builder()
.setName("Alice")
.setIcon(IconCompat.createWithBitmap(avatarBitmap))
.build()
NotificationCompat.Builder(context, NotificationChannels.ALERTS)
.setSmallIcon(R.drawable.ic_message)
.setStyle(
NotificationCompat.MessagingStyle(person)
.setConversationTitle("Team Chat")
.addMessage("Deploy is done!", System.currentTimeMillis(), person)
.addMessage("Tests passed!", System.currentTimeMillis() + 1000, person)
)
.build()
Actions
Add up to three tappable action buttons.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
val replyIntent = Intent(context, ReplyReceiver::class.java)
val replyPendingIntent = PendingIntent.getBroadcast(
context, 0, replyIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE // MUTABLE for inline reply
)
val remoteInput = RemoteInput.Builder("reply_key")
.setLabel("Reply")
.build()
val replyAction = NotificationCompat.Action.Builder(
R.drawable.ic_reply, "Reply", replyPendingIntent
)
.addRemoteInput(remoteInput) // enables inline reply on API 24+
.build()
NotificationCompat.Builder(context, NotificationChannels.ALERTS)
.setSmallIcon(R.drawable.ic_message)
.setContentTitle("New message from Alice")
.setContentText("Are you free tomorrow?")
.addAction(replyAction)
.addAction(R.drawable.ic_mark_read, "Mark as read", markReadPendingIntent)
.build()
Notification Groups
Group related notifications under a summary so the status bar stays clean.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
val GROUP_KEY = "com.example.EMAIL_GROUP"
// Individual notifications — same group key
val msg1 = NotificationCompat.Builder(context, NotificationChannels.ALERTS)
.setSmallIcon(R.drawable.ic_email)
.setContentTitle("Alice")
.setContentText("See you tomorrow")
.setGroup(GROUP_KEY)
.build()
val msg2 = NotificationCompat.Builder(context, NotificationChannels.ALERTS)
.setSmallIcon(R.drawable.ic_email)
.setContentTitle("Bob")
.setContentText("Meeting at 3pm")
.setGroup(GROUP_KEY)
.build()
// Summary notification — required on API 24+
val summary = NotificationCompat.Builder(context, NotificationChannels.ALERTS)
.setSmallIcon(R.drawable.ic_email)
.setContentTitle("2 new messages")
.setGroup(GROUP_KEY)
.setGroupSummary(true) // this is the stack header
.setStyle(
NotificationCompat.InboxStyle()
.addLine("Alice: See you tomorrow")
.addLine("Bob: Meeting at 3pm")
.setSummaryText("your@email.com")
)
.build()
val nm = NotificationManagerCompat.from(context)
nm.notify(1, msg1)
nm.notify(2, msg2)
nm.notify(0, summary) // summary must be posted
Updating and Cancelling
1
2
3
4
5
6
7
8
9
10
val nm = NotificationManagerCompat.from(context)
// Update — use the same notification ID
nm.notify(NOTIFICATION_ID, updatedNotification)
// Cancel one
nm.cancel(NOTIFICATION_ID)
// Cancel all from this app
nm.cancelAll()
For progress bars, update the same ID with a new notification:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
fun showProgress(context: Context, progress: Int, max: Int) {
val notification = NotificationCompat.Builder(context, NotificationChannels.DOWNLOADS)
.setSmallIcon(R.drawable.ic_download)
.setContentTitle("Downloading…")
.setProgress(max, progress, false) // false = determinate
.setOngoing(true) // user cannot swipe away
.build()
NotificationManagerCompat.from(context).notify(DOWNLOAD_NOTIFICATION_ID, notification)
}
fun completeDownload(context: Context) {
val notification = NotificationCompat.Builder(context, NotificationChannels.DOWNLOADS)
.setSmallIcon(R.drawable.ic_download_done)
.setContentTitle("Download complete")
.setProgress(0, 0, false)
.setAutoCancel(true)
.build()
NotificationManagerCompat.from(context).notify(DOWNLOAD_NOTIFICATION_ID, notification)
}
Scheduling Notifications
Android provides no dedicated “schedule a notification at time T” API. You choose between two approaches depending on timing precision and survivability requirements.
Option 1: AlarmManager (exact, system-level)
For notifications that must fire at an exact time even when the app is not running. Requires the SCHEDULE_EXACT_ALARM (API 31+) or USE_EXACT_ALARM (API 33+) permission.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// Manifest
// <uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
// or for API 33+:
// <uses-permission android:name="android.permission.USE_EXACT_ALARM" />
fun scheduleNotification(context: Context, triggerAtMillis: Long, notificationId: Int) {
val intent = Intent(context, NotificationReceiver::class.java).apply {
putExtra("notification_id", notificationId)
}
val pendingIntent = PendingIntent.getBroadcast(
context,
notificationId,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val alarmManager = context.getSystemService(AlarmManager::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && alarmManager.canScheduleExactAlarms()) {
alarmManager.setExactAndAllowWhileIdle(
AlarmManager.RTC_WAKEUP,
triggerAtMillis,
pendingIntent
)
} else {
alarmManager.setAndAllowWhileIdle( // inexact but still fires in Doze
AlarmManager.RTC_WAKEUP,
triggerAtMillis,
pendingIntent
)
}
}
// Receiver that posts the notification
class NotificationReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val id = intent.getIntExtra("notification_id", 0)
val notification = buildReminderNotification(context)
NotificationManagerCompat.from(context).notify(id, notification)
}
}
Android Developers — Schedule exact alarms
Option 2: WorkManager (deferrable, battery-friendly)
For notifications that do not need to fire at an exact millisecond — reminders, digest notifications, or daily summaries. WorkManager respects Doze and battery optimisation, so the actual trigger time may be slightly later than requested.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class DailyDigestWorker(ctx: Context, params: WorkerParameters)
: CoroutineWorker(ctx, params) {
override suspend fun doWork(): Result {
val digest = repository.buildDailyDigest()
val notification = buildDigestNotification(applicationContext, digest)
NotificationManagerCompat.from(applicationContext)
.notify(DIGEST_NOTIFICATION_ID, notification)
return Result.success()
}
}
// Schedule to run approximately every day
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
"daily-digest",
ExistingPeriodicWorkPolicy.KEEP,
PeriodicWorkRequestBuilder<DailyDigestWorker>(1, TimeUnit.DAYS)
.setInitialDelay(hoursUntilMorning(), TimeUnit.HOURS)
.build()
)
Choosing between them
| Requirement | AlarmManager | WorkManager |
|---|---|---|
| Must fire at exact time | Yes | No — may be deferred |
| Survives device reboot | Only if re-registered after BOOT_COMPLETED |
Yes — automatic |
| Respects Doze mode | setExactAndAllowWhileIdle wakes from Doze |
Yes — WorkManager is Doze-aware |
| Battery impact | High if used frequently | Low — batched by OS |
| Requires extra permission | Yes (API 31+) | No |
| Best for | Calendar alarms, medication reminders | Daily digests, periodic reminders, deferrable alerts |
Foreground Service Notifications
A Foreground Service requires a persistent, non-dismissible notification to keep running while the app is in the background. This is covered in detail in the Services section, but the notification pattern is:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class DownloadService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
val notification = NotificationCompat.Builder(this, NotificationChannels.DOWNLOADS)
.setSmallIcon(R.drawable.ic_download)
.setContentTitle("Downloading file…")
.setProgress(100, 0, true) // indeterminate progress initially
.setOngoing(true)
.build()
startForeground(FOREGROUND_NOTIFICATION_ID, notification)
return START_NOT_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
}
The foreground notification must be posted before startForeground() times out (5 seconds on most versions).
NotificationCompat vs Notification
Always use NotificationCompat.Builder from androidx.core, not android.app.Notification.Builder directly. NotificationCompat:
- Back-fills modern features (MessagingStyle, inline reply) on older API levels gracefully
- Normalises priority/importance differences between pre-26 and post-26 APIs
- Is the only way to use
Person,BubbleMetadata, and other Jetpack-defined types
Common Pitfalls
- No channel on API 26+: the notification silently disappears —
NotificationManager.IMPORTANCE_NONEif the channel isn’t created. - MUTABLE PendingIntent not used for RemoteInput: inline reply will not work if the
PendingIntentis immutable. - Reusing IDs unintentionally: posting to the same ID updates the existing notification — intended, but easy to do by mistake if IDs are hardcoded.
- Ongoing foreground service notification dismissible: use
setOngoing(true)to prevent the user from swiping it away while the service is running. - Missing POST_NOTIFICATIONS permission on API 33+: the permission check returns granted on older APIs, so guard with a version check before posting or requesting.
- Exact alarm permission removed by user (API 31+): always check
alarmManager.canScheduleExactAlarms()before callingsetExactAndAllowWhileIdle.
← Previous: BroadcastReceiver · ↑ Chapter Index · Next: Foreground Services →