Lesson 01 / 14
Notifications
A notification is built from a channel, an importance level and an action, and is shown only once the runtime permission is granted. The lesson measures how many copies of eight events reached the receiver and shows what a closed channel, a missing permission and a shared ID turn the delivery into.
Published Updated
In this lesson
- Separates the roles that the notification channel, importance level, action and runtime permission play in a notification
- Classifies how many copies of a sent event reached the receiver as once, duplicated, superseded or lost
- Measures that a closed channel and a missing permission turn a sent event into a lost one
- Shows that when a second notification sent with the same ID replaces the first, the first is superseded
Contents
In the previous course the data lived inside the app and the screen pulled it. In this course the direction reverses: data is sent to the app. A server sends an event, the platform filters it, merges it, sometimes retries it, and the event either reaches the receiver or does not. The first stop is the notification. The question of the course is born in this lesson: how many copies of this sent event reached the receiver?
We will measure the question with four answers and call them the delivery. If the event reached the receiver as exactly one copy, its delivery is once; if it reached the receiver as more than one copy, it is duplicated. If no copy of it arrived but a later event carrying the same key arrived and took its place, it is superseded; if nothing arrived, it is lost. We call the identity of the event its key and its position in the send order its sequence number. A rule, not a label, makes the classification.
// Arrival.kt — classifies how many copies of a sent event reached the receiver
class Signal(val key: String, val seq: Int)
class Arrival(val label: String, val sent: Signal, received: List<Signal>) {
val copies: Int = received.count { it.seq == sent.seq }
val verdict: String = when {
copies > 1 -> "duplicated"
copies == 1 -> "once"
received.any { it.key == sent.key && it.seq > sent.seq } -> "superseded"
else -> "lost"
}
fun line() {
println("$label: key=${sent.key} | copies=$copies -> $verdict")
}
}
fun countArrivals(arrivals: List<Arrival>) {
for (name in listOf("once", "duplicated", "superseded", "lost")) {
println("$name: ${arrivals.count { it.verdict == name }}")
}
}
The rule counts the copies in the receiver’s list that carry the same sequence number as the sent event. One copy means once, more than one copy means duplicated. If there is no copy, it checks whether an event with a larger sequence number and the same key arrived: if one did, the event is superseded, and if none did, it is lost. The rule sees the copy count; it does not see the content or the cause.
The Parts of a Notification
Before an event lands on the screen as a notification on the platform, it passes through four gates, and each gate can move the event away from the receiver. The gates come in order, because the rest of the lesson measures this order.
A notification is built from four parts. The notification channel groups notifications by topic and carries the setting that the user can switch on and off one by one. The importance level determines how much attention a notification draws: a notification that makes a sound and drops over the top of the screen and one that is quietly added to the list are two different levels of the same channel. The action is the button on the notification, and pressing it runs a PendingIntent. The importance level of a channel is given when the channel is created; after the app has created the channel it cannot raise the level, and the user changes it in the channel settings. This is why getting the channel right is cheaper than correcting it afterwards. The fourth part is the permission: on the Android versions that make posting notifications a runtime permission, the app cannot show a notification until the permission is granted.
// Platform code: OrderNotifier
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import androidx.core.app.NotificationCompat
fun createChannel(manager: NotificationManager) {
val channel = NotificationChannel("orders", "Orders", NotificationManager.IMPORTANCE_HIGH)
manager.createNotificationChannel(channel)
}
fun postOrder(context: Context, manager: NotificationManager, mapAction: PendingIntent) {
val notification = NotificationCompat.Builder(context, "orders")
.setContentTitle("Your order is on the way")
.setSmallIcon(android.R.drawable.ic_dialog_info)
.addAction(0, "View on map", mapAction)
.build()
manager.notify(42, notification)
}
createChannel builds the channel with an ID and an importance level, and postOrder prepares a notification with the same channel ID and sends it with notify. The action added with addAction runs the PendingIntent passed as mapAction when the user presses the button; the action belongs not to whether the notification is shown but to what can be done after it is shown. The 42 here is the ID of the notification; the bounding measurement will use it.
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
This line in the manifest declares the permission, but the permission becomes valid only if the user approves it at runtime. These two parts are platform code, and we claim no output, duration or display count for them here. The model builds this rule of the platform; the model is not the platform itself.
Four Deliveries for Eight Events
In the model, a sent event is a Signal: the part of the key before the dot gives the channel, and the whole key gives the event. We send eight events. Three functions build the path: gate drops the events of closed channels, collapse keeps only the last of the events that share a key, and resend delivers the event with a chosen sequence number twice. The list that reaches the receiver is the return value of these three functions.
// Main.kt
fun gate(sent: List<Signal>, closed: Set<String>): List<Signal> =
sent.filter { it.key.substringBefore('.') !in closed }
fun collapse(sent: List<Signal>): List<Signal> =
sent.groupBy { it.key }.values.map { it.last() }.sortedBy { it.seq }
fun resend(sent: List<Signal>, again: Set<Int>): List<Signal> =
sent.flatMap { if (it.seq in again) listOf(it, it) else listOf(it) }
fun main() {
val sent = listOf(
Signal("message.deniz", 1),
Signal("order.42", 2),
Signal("message.deniz", 3),
Signal("campaign.discount", 4),
Signal("payment.confirmed", 5),
Signal("order.42", 6),
Signal("campaign.new", 7),
Signal("reminder.medication", 8),
)
val received = resend(collapse(gate(sent, setOf("campaign"))), setOf(5))
println("received seqs: ${received.map { it.seq }}")
val arrivals = sent.map { Arrival("event-${it.seq}", it, received) }
arrivals.forEach { it.line() }
countArrivals(arrivals)
}
received seqs: [3, 5, 5, 6, 8] event-1: key=message.deniz | copies=0 -> superseded event-2: key=order.42 | copies=0 -> superseded event-3: key=message.deniz | copies=1 -> once event-4: key=campaign.discount | copies=0 -> lost event-5: key=payment.confirmed | copies=2 -> duplicated event-6: key=order.42 | copies=1 -> once event-7: key=campaign.new | copies=0 -> lost event-8: key=reminder.medication | copies=1 -> once once: 3 duplicated: 1 superseded: 2 lost: 2
Of the eight events, three reached the receiver once, one duplicated, two superseded and two lost: 3/1/2/2. We will use this distribution as the reference throughout the course. Each of the four deliveries came from one function. gate took events 4 and 7 on the campaign channel, and both became lost. collapse removed events 1 and 2, because 3 and 6 had arrived with the same keys; these two are superseded. resend delivered event 5 twice, so it is duplicated. Events 3, 6 and 8, which were left untouched, stayed once. The path of five events was changed by the hand of a function; the rule does not see the function, it only counts the result. The same distribution could have come out of other functions, and the rule could not have told them apart.
SC1. The model builds the arrival of an event as its appearance in the receiver’s list. On the platform, whether an event is shown also depends on whether the screen is on, whether the app is in the foreground and the power restrictions of the system; none of these exist in the model.
Closed Channel and Missing Permission
This lesson’s delivery is lost. If the user has closed a channel, a notification sent to that channel is not shown. If the permission was never granted, no channel can show a notification. In the core, the campaign channel was closed and two events were lost. Now we move the same filtering into a function for an app whose permission has not been granted.
// Channel.kt — filters the notifications that can be shown by channel and permission state
fun deliverable(sent: List<Signal>, closed: Set<String>, permitted: Boolean): List<Signal> =
if (!permitted) emptyList()
else sent.filter { it.key.substringBefore('.') !in closed }
We send four events. No channel is closed, but the permission has not been granted.
// Main.kt — with no permission, no channel shows a notification
fun main() {
val sent = listOf(
Signal("message.ayse", 9),
Signal("payment.refund", 10),
Signal("reminder.meeting", 11),
Signal("campaign.weekly", 12),
)
val received = deliverable(sent, emptySet(), permitted = false)
val arrivals = sent.map { Arrival("event-${it.seq}", it, received) }
arrivals.forEach { it.line() }
countArrivals(arrivals)
}
event-9: key=message.ayse | copies=0 -> lost event-10: key=payment.refund | copies=0 -> lost event-11: key=reminder.meeting | copies=0 -> lost event-12: key=campaign.weekly | copies=0 -> lost once: 0 duplicated: 0 superseded: 0 lost: 4
When the channel is closed and when the permission is not granted, the result is the same category. The rule does not see why an event did not arrive; it sees only that it did not arrive and that no event took its place.
In K02, unmet was the platform’s answer to a request from the app: the requested permission had not been granted. Here lost is something else: it is not the platform’s answer but the failure of a sent event to reach the receiver. A permission denial ties the two together, but what they measure is not the same. One counts the outcome of a request, the other the copy count of an event.
SC2. The model builds the permission as a single true or false value. On the platform the user can later turn the permission off in settings, lower the importance level of a channel, or silence only a particular channel; none of these is a separate state in the model.
Importance Level Is Quiet, Not Lost
The importance level says how much attention a channel will draw; it does not say whether the notification will arrive. A high-level channel arrives with sound and drops over the top of the screen. A low-level channel is added to the list without making a sound. Only the lowest level, the one that closes the channel, never shows a notification. In the model we build the levels with a Map: 4 is loud, 2 is quiet, 0 is closed. These numbers are the same as the values of the platform’s level constants; the model takes only these three levels.
// Importance.kt — by channel importance, drops only the closed channel and not the quiet one
fun byImportance(sent: List<Signal>, levels: Map<String, Int>): List<Signal> =
sent.filter { (levels[it.key.substringBefore('.')] ?: 0) > 0 }
Three channels, four events: the message channel is loud, the reminder channel is quiet, the campaign channel is closed.
// Main.kt — a notification on a quiet channel arrives, one on a closed channel does not
fun main() {
val sent = listOf(
Signal("message.nur", 13),
Signal("reminder.water", 14),
Signal("campaign.daily", 15),
Signal("reminder.sleep", 16),
)
val levels = mapOf("message" to 4, "reminder" to 2, "campaign" to 0)
val received = byImportance(sent, levels)
val arrivals = sent.map { Arrival("event-${it.seq}", it, received) }
arrivals.forEach { it.line() }
countArrivals(arrivals)
}
event-13: key=message.nur | copies=1 -> once event-14: key=reminder.water | copies=1 -> once event-15: key=campaign.daily | copies=0 -> lost event-16: key=reminder.sleep | copies=1 -> once once: 3 duplicated: 0 superseded: 0 lost: 1
Both quiet reminders arrived once. Being quiet does not change the copy count; the rule does not see whether a quiet notification was noticed, only that one copy reached the receiver. The only lost event is the campaign on the closed channel.
SC3. The model builds the importance level as a single number per channel. On the platform the user can change the level later in settings, and the effect of the level on sound, vibration and dropping over the top of the screen varies by version; none of these exist in the model.
Who Closes the Gates
Each of the four gates has a separate owner, and who the owner is determines how a loss can be prevented. The user opens the permission gate and the app can only ask; when the user has refused twice, later requests are refused without showing a dialog, so the app cannot reopen this gate on its own. The user also closes the channel gate: the app creates the channel, but it cannot open a closed channel through code. At the importance level gate the app makes the first decision and the user changes it later. The action gate belongs to the app alone.
This distribution limits what an app can do about lost notifications. The app cannot bring back a notification that disappeared because of the permission or the channel. What it can do is know that the gate is closed and show the user the settings. The state of the channel or the permission can be queried; an app that checks this state before sending a notification makes no wasted sends and can tell the user why it stayed silent. The lost events in the measurement are the numerical counterpart of the case in which the app does not make this check.
This is also why the channel is built in detail. An app that sends everything on a single channel leaves the user two options: take everything or take nothing. If the user is bothered only by campaign notifications and the campaigns are on the same channel as the order statuses, closing the channel also loses the order statuses. Channels separated by topic let the user choose what to lose.
Common Misconceptions
“If the permission is granted, all notifications are shown.” The permission is only the first gate. If the channel is closed, or if the user has silenced the app’s notifications in the system settings, then despite the permission a notification is either never shown or draws no attention.
“A quiet notification is a lost one.” A notification that lands on a quiet channel arrives and is once. It only lacks the sound and the screen alert. This is why events at a low importance level were not counted as lost in the measurement.
“The app can raise the importance level of a channel later.” After a channel is created, only the user can change its importance level. If the app tries to create the channel again with the same ID, the platform keeps the settings the user has changed and does not raise the importance level; the name and description are updated. If the level is to change, a new channel ID is needed, but this resets the setting of a user who was using the old channel.
“A loss is a bug in the sending code.” The sending code can work flawlessly and the event can still be lost, because the loss happens at a gate on the receiver side. The rule counts arrival, not the success of the send.
The Bounding Measurement
An event that did not arrive is called lost when no event replaces it. If a replacing event exists, the name changes. On the platform, if notify is called a second time with the same ID, the second notification takes the place of the first; the list shows one row, not two. In the model, the ID of a notification is the key.
// Main.kt — a second notification sent with the same ID replaces the first
fun replaceById(sent: List<Signal>): List<Signal> =
sent.filter { s -> sent.none { it.key == s.key && it.seq > s.seq } }
fun main() {
val sent = listOf(
Signal("order.42", 17),
Signal("message.deniz", 18),
Signal("order.42", 19),
Signal("reminder.medication", 20),
)
val received = replaceById(sent)
println("received seqs: ${received.map { it.seq }}")
val arrivals = sent.map { Arrival("event-${it.seq}", it, received) }
arrivals.forEach { it.line() }
countArrivals(arrivals)
}
received seqs: [18, 19, 20] event-17: key=order.42 | copies=0 -> superseded event-18: key=message.deniz | copies=1 -> once event-19: key=order.42 | copies=1 -> once event-20: key=reminder.medication | copies=1 -> once once: 3 duplicated: 0 superseded: 1 lost: 0
Event 17 became superseded: event 19 arrived with the same key and took its place. What the rule does not see is whether notification 17 was shown to the user for a moment. Only 19 is in the receiver’s list, and the rule reads only that. This also gives the difference between lost and superseded: even if the first event was never shown, the result is the same, because the difference lies not in whether the event arrived but in whether a later event arrived.
SC4. The model builds a second notification with the same ID as the instant deletion of the first. On the platform the second notification takes the place of the first, but whether the replacement is visible and whether the sound plays again depend on the settings of the channel and the notification.
Summary
- This lesson’s delivery: lost. A notification sent to a closed channel or to an app whose permission has not been granted never reaches the receiver.
- A notification is built from a channel, an importance level, an action and a permission; the model builds this rule with
Signallists and does not build the platform. - Eight events arrived as 3 once, 1 duplicated, 2 superseded and 2 lost; this distribution is the reference throughout the course.
- When the permission was not granted, all four of four events were lost: lost 4. It is the same category as a closed channel.
- A notification on a quiet channel arrives: three once, one lost. The importance level changes how much attention a notification draws, not its delivery.
- A second notification with the same ID replaced the first: superseded 1. The rule sees what replaced it, not whether the first was shown.
Next Step
In this lesson the event was born inside the app itself as a notification. Most of the time the event comes from a server and reaches the device through a service. The next lesson, Cloud Messaging, measures which of the messages that pile up under the same collapse key while the device is offline arrives, and why the one that does not arrive is superseded rather than lost.
To keep your progress and take notes, Log in
My notes
Log in to take notes.