---
title: 'Platform Services and Integrations'
source: 'https://academia.sh/en/courses/platform-services-and-integrations'
language: en
updated: '2026-10-21T05:02:41+00:00'
license: 'CC BY-SA 4.0'
lessons: 14
outcomes:
    - 'Uses system services in line with the permission model'
    - 'Builds notification and deep link flows'
    - "Protects app data with the platform's security tools"
    - 'Distinguishes whether a sent event reached its receiver once or duplicated, was superseded by a later event, or was lost'
---

# Platform Services and Integrations

An event an app sends or receives does not always reach its receiver exactly once: a notification with the same ID takes the place of the earlier one, a notification sent to a closed channel never appears, a request whose response was delayed reaches the server twice when it is retried. The course measures every mechanism with a single question — how many copies of this sent event reached the receiver? Notifications, cloud messaging, location, the camera, sensors, deep links, widgets, encrypted storage, network security, authentication, app signing, analytics, remote configuration and in-app purchases all pass through the same frame.

## System Capabilities

Notifications with channels and priorities, cloud messaging from server to device, location providers, camera and media access, sensor data, deep links and shortcuts, and home screen widgets.

### Notifications

Source: https://academia.sh/en/courses/platform-services-and-integrations/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.

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.

```kotlin
// 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.

```kotlin
// 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.

```xml
<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.

```kotlin
// 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)
}
```

```text
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.

```kotlin
// 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.

```kotlin
// 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)
}
```

```text
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.

```kotlin
// 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.

```kotlin
// 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)
}
```

```text
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.

```kotlin
// 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)
}
```

```text
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 `Signal` lists 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.

### Cloud Messaging

Source: https://academia.sh/en/courses/platform-services-and-integrations/cloud-messaging

The platform's cloud messaging service carries a message from the server to the device by device token; the collapse key and the time to live decide what becomes of it. The lesson measures which of the messages piled up on an offline device arrives and the delivery of a message whose time to live runs out.

In the previous lesson the event was born inside the app as a notification. Most of the time the event comes from a server: an order status changes, a new message arrives, a payment is confirmed. A service stands between the server and the device, and the message is entrusted to it. If the device is off or out of network range, the service holds the message. In this lesson we ask the same question again: **how many copies of this sent event reached the receiver?**

```kotlin
// 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 classification is the same as in the first lesson. The rule counts the copies in the receiver's list that carry the same sequence number, and if there is no copy, it checks whether a later event with the same key arrived. It does not see the content or the cause.

#### Device Token and Payload

In the platform's cloud messaging service, the server sends a message not to a device but to a device token that identifies the app on that device. The token belongs to the installation of the app and can be refreshed over time; when the app receives a new token, it must report it to the server. A message sent to a token from before the refresh finds no receiver on the new installation.

The payload of a message comes in two kinds. The data payload carries the app's own keys and values and arrives at the app's receiver class; the app decides what to do with it. The notification payload carries a title and a text, and while the app is in the background the platform can show it on its own as a notification. The channel and the permission of the previous lesson are at the gate on this second path as well.

```kotlin
// Platform code: OrderMessageReceiver
import android.util.Log
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage

class OrderMessageReceiver : FirebaseMessagingService() {
    override fun onNewToken(token: String) {
        Log.d("orders", "token refreshed")
        sendTokenToServer(token)
    }

    override fun onMessageReceived(message: RemoteMessage) {
        val orderId = message.data["order"]
        showOrder(orderId)
    }
}
```

The service's receiver class defines two methods. `onNewToken` is called when the token is refreshed, and `onMessageReceived` hands a message that arrived with a data payload to the app. This part is platform code, and we claim no output, duration or arrival count for it here. The model builds this rule of the platform; the model is not the platform itself.

#### Data Payload and Notification Payload

The difference between the two payloads is who will handle the message. The data payload carries only key and value pairs; the platform does not try to show it, and hands it to the app's receiver class. The app decides for itself what to do at that moment: it can show a notification, update a local database, or do nothing at all. The notification payload carries a title and a text. While the app is in the background, the platform can show this payload on its own as a notification, and in that case the app's code does not run.

This distinction has a consequence for delivery. When a message with a data payload reaches the receiver, the receiver's code runs, and arrival means appearing in the receiver's list; the model of this lesson defines arrival this way too. On the path where the platform shows a notification payload, the message may never pass through the receiver's code. The channel and permission gates of the previous lesson apply on that path as well: a notification payload that falls on a closed channel or an ungranted permission does not reach the user, even if the message left the service. This is why there are three separate gates between "the server sent the message" and "the user saw the message": the service, the receiver class and the notification gates.

#### Collapse Key

While the device is offline, the server can keep sending messages. The service holds them for the device. If the sender has put a collapse key on a message, then of the pending messages that carry the same key, only the newest is delivered when the device comes online. This is useful for messages about earlier states: if an order was updated three times, the user does not need to see the first two.

In the model, a message is the `Message` class. If there is a collapse key, it is the key of the event. If there is not, the message is known only by its own ID and the ID becomes the key; so it has no counterpart to be collapsed.

```kotlin
// Message.kt — builds the message the server sends; with no collapse key, the ID becomes the key
class Message(val id: String, val seq: Int, val collapseKey: String? = null) {
    fun toSignal(): Signal = Signal(collapseKey ?: id, seq)
}
```

Among the messages that pile up while the device is offline, only the last of those with the same key remains.

```kotlin
// Offline.kt — keeps only the last message of each key among those piled up on an offline device
fun collapseOffline(pending: List<Signal>): List<Signal> =
    pending.filter { s -> pending.none { it.key == s.key && it.seq > s.seq } }
```

We send five messages. The order status three times, and a chat message and a daily news item once each.

```kotlin
// Main.kt — of the messages piled up under the same collapse key on an offline device, only the last arrives
fun main() {
    val messages = listOf(
        Message("m1", 1, "order.42"),
        Message("m2", 2, "message.deniz"),
        Message("m3", 3, "order.42"),
        Message("m4", 4, "order.42"),
        Message("m5", 5, "news.daily"),
    )
    val sent = messages.map { it.toSignal() }
    val received = collapseOffline(sent)
    println("received seqs: ${received.map { it.seq }}")

    val arrivals = sent.map { Arrival("event-${it.seq}", it, received) }
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
received seqs: [2, 4, 5]
event-1: key=order.42 | copies=0 -> superseded
event-2: key=message.deniz | copies=1 -> once
event-3: key=order.42 | copies=0 -> superseded
event-4: key=order.42 | copies=1 -> once
event-5: key=news.daily | copies=1 -> once
once: 3
duplicated: 0
superseded: 2
lost: 0
```

This lesson's delivery is **superseded**. Updates 1 and 3 of the order status were never delivered, but update 4 arrived with the same key and took their place. The user sees only the latest status. The two superseded events are two of the three messages under the order key.

The origin we measured in K04 told us from which step of the chain the answer to a read came. Superseded is not an origin: it does not say where an answer came from, it says that another event took the place of an event. There a read always found an answer; here an event may never arrive.

**SC1.** The model builds the interval in which the device is offline as a single moment of accumulation: the messages pile up and are delivered in one move. On the platform the device can connect intermittently, messages can enter a delivery round more than once, and which message goes when belongs to the service; none of these exist in the model.

#### Time to Live

A message has a time to live (TTL). When the time runs out, the service no longer delivers the message. If the device does not come online within this time, the message does not arrive. In the model, time is not a real clock but a step in the same unit as the sequence number: the message is sent at step `seq`, the device connects at step `onlineAt`, and the time to live is an integer number of steps. These numbers are made-up values of the model; they are not the real durations of the platform.

The function below drops the messages whose time to live has run out when the device connects.

```kotlin
// Main.kt — a message whose time to live runs out before the device comes online does not arrive
fun expire(sent: List<Signal>, onlineAt: Int, ttl: Int): List<Signal> =
    sent.filter { onlineAt - it.seq <= ttl }

fun main() {
    val messages = listOf(
        Message("m6", 6, "news.sports"),
        Message("m7", 7, "order.43"),
        Message("m8", 8, "reminder.medication"),
        Message("m9", 9, "message.nur"),
        Message("m10", 10, "order.43"),
    )
    val sent = messages.map { it.toSignal() }
    val received = collapseOffline(expire(sent, onlineAt = 13, ttl = 4))
    println("received seqs: ${received.map { it.seq }}")

    val arrivals = sent.map { Arrival("event-${it.seq}", it, received) }
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
received seqs: [9, 10]
event-6: key=news.sports | copies=0 -> lost
event-7: key=order.43 | copies=0 -> superseded
event-8: key=reminder.medication | copies=0 -> lost
event-9: key=message.nur | copies=1 -> once
event-10: key=order.43 | copies=1 -> once
once: 2
duplicated: 0
superseded: 1
lost: 2
```

Three messages had their time to live run out, and their results differ. News item 6 and reminder 8 were lost: neither a copy of them nor a later event with the same key arrived. Order 7, on the other hand, was counted as superseded even though its time ran out, because update 10 arrived with the same key. The rule does not see that the message expired; it looks only at whether it arrived and whether an event took its place.

**SC2.** The model builds the time to live as a single integer and removes the expired message from the list. On the platform the upper limit of the time to live depends on the service, and how a value above the limit is handled is not in the model.

#### At-Least-Once Delivery

Collapsing and the time to live reduce messages. A third behavior of the service can multiply them. When the service delivers a message to the device, it waits for an acknowledgement; if none comes, it sends the message again. This arrangement is called at-least-once delivery: the message is never lost, but it can arrive more than once. Its opposite is at-most-once delivery: the message arrives at most once, but it may never arrive. The service offers the first because it chooses to prevent loss, and leaves the cost of duplication to the receiver.

In the model, resending is a copy of the sequence numbers that received no acknowledgement being added to the end of the list.

```kotlin
// Retry.kt — the service sends once more the message that received no acknowledgement
fun retry(sent: List<Signal>, unacked: List<Int>): List<Signal> =
    sent + unacked.map { seq -> sent.first { it.seq == seq } }
```

We send four messages; two of them receive no acknowledgement. The messages have no collapse key, so their IDs become the keys.

```kotlin
// Main.kt — two messages with no acknowledgement arrive as two copies when resent
fun main() {
    val messages = listOf(
        Message("m16", 16),
        Message("m17", 17),
        Message("m18", 18),
        Message("m19", 19),
    )
    val sent = messages.map { it.toSignal() }
    val received = retry(sent, unacked = listOf(17, 18))
    println("received seqs: ${received.map { it.seq }}")

    val arrivals = sent.map { Arrival("event-${it.seq}", it, received) }
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
received seqs: [16, 17, 18, 19, 17, 18]
event-16: key=m16 | copies=1 -> once
event-17: key=m17 | copies=2 -> duplicated
event-18: key=m18 | copies=2 -> duplicated
event-19: key=m19 | copies=1 -> once
once: 2
duplicated: 2
superseded: 0
lost: 0
```

Messages 17 and 18 arrived as two copies and were counted as duplicated; 16 and 19 stayed once with a single copy. Although this lesson's delivery is superseded, the same service can also produce duplicated; the category depends not on the message but on the event the message went through while passing the service. The receiver's remedy is deduplication: it stores the message ID and does not process it the second time. This is the same arrangement that we will measure separately, with a handled marker, in the deep links lesson.

**SC3.** The model builds the missing acknowledgement as a certain event for a single sequence number, repeated once. On the platform the number and interval of resends belong to the service, and why the acknowledgement did not come is not in the model.

#### Common Misconceptions

**"A collapse key moves a message forward."** A collapse key changes neither the ordering nor the priority; it only decides which of the pending messages with the same key will remain. Priority is a separate setting.

**"A message whose time to live has run out still arrives late."** When the time to live runs out, the service does not deliver the message. This is why messages 6 and 8 were lost in the measurement; there was no copy arriving late. For a message that has to arrive, the time to live must be chosen longer than the time the device can stay offline.

**"A collapsed message has been deleted, so there is a bug."** The place of a superseded message has been taken by a newer message with the same key. This is not a bug but the behavior the sender asked for. The bug is giving the same key to two different topics: in that case, even if one of them is really needed, the other takes its place.

**"The device token is a permanent identity."** The token can change when the app is reinstalled or the user clears the data. If the server keeps the old token, the messages it sends reach no device, and on the server side this can look like a successful send. The receiver side's job is to report the new token to the server.

**"If the server sent the message, the user sees it."** A message that leaves the server has to pass the gates of the service, the receiver class and the notification. The rule does not see at which of these gates it was lost; it only counts the result.

#### The Bounding Measurement

Collapsing does not work for every message: it merges only the messages that carry a collapse key. When messages with no key pile up, none of them takes the place of another.

```kotlin
// Main.kt — messages that carry no collapse key each arrive after piling up
fun main() {
    val messages = listOf(
        Message("chat.1", 11),
        Message("status.1", 12, "status.online"),
        Message("chat.2", 13),
        Message("status.2", 14, "status.online"),
        Message("chat.3", 15),
    )
    val sent = messages.map { it.toSignal() }
    val received = collapseOffline(sent)
    val arrivals = sent.map { Arrival("event-${it.seq}", it, received) }
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-11: key=chat.1 | copies=1 -> once
event-12: key=status.online | copies=0 -> superseded
event-13: key=chat.2 | copies=1 -> once
event-14: key=status.online | copies=1 -> once
event-15: key=chat.3 | copies=1 -> once
once: 4
duplicated: 0
superseded: 1
lost: 0
```

The three chat messages, because their IDs differ, arrived one by one and once after piling up. Of the two status messages that carry the same collapse key, only the second arrived; the first was superseded. For chat this is the right behavior: every message wants to be read. For status it is right too: there is no point in the user hearing twice that someone is online. Which message gets a key is the sender's decision, and the rule counts only the result.

**SC4.** The model assumes the ID of a message without a key is unique. On the platform it is possible for the sender to give two different messages the same ID, and what the service would do in that case is not in the model.

#### Summary

- This lesson's delivery: superseded. Of the messages piled up on an offline device under the same collapse key, only the last arrives, and the earlier ones leave their place to another message.
- The device token identifies the installation of the app; the payload of a message is a data or notification payload, and the channel and permission gates of the previous lesson apply on the second path as well.
- Of five messages, two were superseded: the first two updates of the order status. This is a delivery, not an origin; an event's place has been taken by another event.
- Two messages whose time to live ran out were lost and one was superseded: lost 2, superseded 1. The rule sees whether a message arrived, not that its time ran out.
- Three messages with no collapse key arrived once; of two status messages with the same key, one was superseded.
- The service delivers at least once: two messages with no acknowledgement became duplicated, duplicated 2. The receiver's remedy is deduplication.

#### Next Step

In this lesson the event left the server and went to the device by a single path. In the location provider the event is produced inside the device, continuously and often; the receiver may not be able to process all of it. The next lesson, `Location and Maps`, measures which location update the minimum interval rule drops and what happens to later updates when the permission is revoked.

### Location and Maps

Source: https://academia.sh/en/courses/platform-services-and-integrations/location-and-maps

The location provider delivers location updates through a callback; the minimum interval rule decides which update reaches the receiver, and once the runtime permission is revoked, later updates stop arriving. The lesson measures the delivery of these two rules.

In the previous lesson the event came from a server to the device, and the service collapsed the messages that piled up. In location, the event is born inside the device: the provider measures the position of the user continuously and hands each measurement to the app as a location update. In a courier app this is a stream lasting seconds, and the receiver does not need all of it. In this lesson we ask the same question again: **how many copies of this sent event reached the receiver?**

```kotlin
// 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 classification is the same as in the first lessons. The rule counts the copies in the receiver's list that carry the same sequence number; if there is no copy, it checks whether a later update with the same key arrived. It does not see the content or the cause.

#### Permission and Provider

Location is a capability that asks for a runtime permission. Two permissions are declared in the manifest: precise location and approximate location. The user can grant only approximate location, or neither; and after granting, the permission can also be revoked from the settings. The app has to handle all three situations.

```xml
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
```

The location provider delivers updates through a callback. The app writes the minimum interval into the request, and the provider does not produce updates more often than that interval.

```kotlin
// Platform code: CourierTracker
import android.location.Location
import android.location.LocationListener
import android.location.LocationManager

class CourierTracker(private val manager: LocationManager) : LocationListener {
    fun start() {
        manager.requestLocationUpdates(
            LocationManager.GPS_PROVIDER, 5000L, 10f, this
        )
    }

    override fun onLocationChanged(location: Location) {
        publish(location.latitude, location.longitude)
    }
}
```

The snippet gives two numbers: the minimum time interval in milliseconds and the minimum distance in meters. Both are values the app requests; the snippet does not say how often the provider actually produces updates. The model builds this rule of the platform; the model is not the platform itself. The map view is outside this model: drawing is a question of display, not of delivery, and it is not measured in this lesson.

#### The Minimum Interval Rule

In the model, an update is a `Signal` and its key is the identity of the courier. The sequence number is the step at which the update was produced. The minimum interval rule drops an update if not enough steps have passed since the last accepted update. The number of the interval is a made-up value of the model; it is not the real interval of the provider.

```kotlin
// Sampling.kt — builds the minimum interval rule and the revocation of the permission
fun sample(sent: List<Signal>, minGap: Int): List<Signal> {
    val kept = mutableListOf<Signal>()
    for (s in sent) {
        if (kept.isEmpty() || s.seq - kept.last().seq >= minGap) kept.add(s)
    }
    return kept
}

fun revoke(delivered: List<Signal>, revokedAt: Int): List<Signal> =
    delivered.filter { it.seq < revokedAt }
```

We produce eight updates, with an interval of three steps.

```kotlin
// Main.kt — the minimum interval rule drops the location updates that fall in between
fun main() {
    val sent = (1..8).map { Signal("location.courier", it) }
    val received = sample(sent, minGap = 3)
    println("received seqs: ${received.map { it.seq }}")

    val arrivals = sent.map { Arrival("event-${it.seq}", it, received) }
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
received seqs: [1, 4, 7]
event-1: key=location.courier | copies=1 -> once
event-2: key=location.courier | copies=0 -> superseded
event-3: key=location.courier | copies=0 -> superseded
event-4: key=location.courier | copies=1 -> once
event-5: key=location.courier | copies=0 -> superseded
event-6: key=location.courier | copies=0 -> superseded
event-7: key=location.courier | copies=1 -> once
event-8: key=location.courier | copies=0 -> lost
once: 3
duplicated: 0
superseded: 4
lost: 1
```

This lesson's delivery is **superseded**. Updates 2 and 3 never arrived, but update 4 arrived with the same key and took their place; for 5 and 6, update 7 did the same. For location this is the desired behavior: the new position replaces the old one. The eighth update, however, was lost, because no update with the same key came after it. The rule does not count the dropping of the last update as superseded; the latest state was never shown to the user.

**SC1.** The model builds the courier with a single key and places the updates at equal steps. On the platform the provider produces updates at irregular intervals, can lengthen the interval to save battery, and the time and distance thresholds apply together, so no update arrives until both are met; none of these exist in the model.

#### Precise and Approximate Location

The two permissions are two separate levels of accuracy. Precise location uses satellite and network sources together and gives a street-level position; approximate location gives only a rough area. The user can choose one of the two on the permission screen. When the app of a user who chose only approximate location makes a call asking for precise location, the platform lowers the request to the approximate level. The call does not fail; updates arrive, only with lower accuracy.

This is a place the delivery model does not see. The rule counts whether an update arrived; it does not ask how precise the arriving update is. Four updates arriving with approximate location and four updates arriving with precise location give the same result in the eyes of the rule: once 4. Accuracy is a property that the app must check separately on the receiver side. A courier app that needs precise location should check the permission state before showing an update that arrived at the approximate level to the user, and should ask the user for precise location.

#### Location in the Background

A location update is one thing for an app in the foreground and another for an app in the background. When the app leaves the screen, the platform can lower the update frequency on its own to save battery, and may give no updates at all to an app that has no separate background location permission. The counterparts of these two situations in the delivery model are different.

Lowering the frequency resembles the interval rule working with a large value: the updates in between are never produced and are counted as superseded. Having no background permission resembles the revocation of the permission: later updates never arrive and are counted as lost. In both situations the app sees no error. The answer to the user's question of "why the location was not updated" depends on the app knowing which permission it holds.

#### More Than One Key

So far there was a single courier, and the minimum interval rule was applied to all updates like a single counter. This becomes wrong when two couriers receive updates from the same provider: the update of one courier can be dropped because it falls within the interval of the other courier's update, and the only update reporting the position of the other courier is lost. The correct approach is to apply the interval per key.

```kotlin
// Keys.kt — applies the minimum interval to each key separately
fun sampleByKey(sent: List<Signal>, minGap: Int): List<Signal> =
    sent.groupBy { it.key }.values.flatMap { sample(it, minGap) }
```

Two couriers send updates in alternation; the interval is four steps.

```kotlin
// Main.kt — the interval is applied to each courier separately and each courier keeps its own position
fun main() {
    val sent = (19..26).map { Signal(if (it % 2 == 1) "location.courier1" else "location.courier2", it) }
    val received = sampleByKey(sent, minGap = 4)
    println("received seqs: ${received.map { it.seq }.sorted()}")

    val arrivals = sent.map { Arrival("event-${it.seq}", it, received) }
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
received seqs: [19, 20, 23, 24]
event-19: key=location.courier1 | copies=1 -> once
event-20: key=location.courier2 | copies=1 -> once
event-21: key=location.courier1 | copies=0 -> superseded
event-22: key=location.courier2 | copies=0 -> superseded
event-23: key=location.courier1 | copies=1 -> once
event-24: key=location.courier2 | copies=1 -> once
event-25: key=location.courier1 | copies=0 -> lost
event-26: key=location.courier2 | copies=0 -> lost
once: 4
duplicated: 0
superseded: 2
lost: 2
```

Each courier advanced with its own counter: the first and third updates of both arrived once, the ones in between were superseded, and the last of each courier was lost. The update that takes the place of a superseded update has the same key; this is why the update of one courier is not counted as having taken the place of the other. The two lost updates, 25 and 26, are the cost of the interval: an update that does not come after the interval has no successor to be superseded by, so it never arrives.

**SC2.** The model builds the two couriers as two separate keys and distributes the updates in turn. On the platform each location request carries its own interval, and several listeners on the same provider can receive updates at independent intervals.

#### When the Interval Falls to One

Lowering the interval to one relaxes the rule: at least one step between two consecutive updates is enough, and this is always met. With the interval changed, the same rule shows that the measurement drops no update.

```kotlin
// Main.kt — when the interval falls to one, all updates arrive once
fun main() {
    val sent = (9..12).map { Signal("location.courier", it) }
    val received = sample(sent, minGap = 1)
    val arrivals = sent.map { Arrival("event-${it.seq}", it, received) }
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-9: key=location.courier | copies=1 -> once
event-10: key=location.courier | copies=1 -> once
event-11: key=location.courier | copies=1 -> once
event-12: key=location.courier | copies=1 -> once
once: 4
duplicated: 0
superseded: 0
lost: 0
```

All four updates arrived once. The cost of this lies with the receiver: the receiver now has to process every update. Which interval is suitable is the decision of the app; the rule only counts the result.

**SC3.** The model takes the processing time of the receiver as zero. On the platform a busy receiver can delay the callback, and the consequences of this delay are not in the model.

#### Two Thresholds and the Life of the Listener

The snippet in the platform code carried two thresholds: time and distance. The provider gives no update unless both are met; if the courier moved only two meters in five seconds, no update arrives because the distance threshold is not met, even if the time threshold has been met. The model using a single step counter is therefore a simplification. For a courier standing still, the real provider produces no updates at all, and the distinction between superseded and lost in the model raises the question of whether standing still counts as a loss. Standing still is not a loss: there is no sent event.

The life of the listener is a second source. An app that registers the callback and does not remove the registration when the screen closes keeps the provider producing updates, and the receiver works on a screen the user does not see. Conversely, an app that removes the registration too early receives no updates until it opens a new registration when the user returns, and the updates in this interval are never produced. The difference between the loss we measured throughout the lesson and this loss is what the rule can see: the rule counts only sent events; an update that is never produced because the registration was removed has, from the point of view of the model, never been sent.

The practical consequence of this distinction is as follows: an update not arriving is not always a delivery failure. The app should first look at whether the update was produced, and then at whether the produced update reached the receiver. The first question belongs to the provider and the registration, and the second to the rules this lesson measures.

#### Common Misconceptions

**"The minimum interval guarantees the update frequency."** The interval is a lower bound, not a promise. The provider can give updates less often than the interval; saving battery, a weak signal or moving to the background does this. Reducing the interval does not increase the number of updates; it only reduces the number of updates to be dropped.

**"More updates mean a better location."** Every update is processed on the receiver. An interval smaller than needed tires the receiver and the battery; in the measurement, when the interval fell to one all updates arrived once, but the cost was that the receiver processed every one of them.

**"The user granted the permission once, so it is always valid."** The permission can be revoked from the settings and, in some versions, can reset on its own after a certain time. The app should check the permission state before every update request. Waiting for the callback while there is no permission gives nothing but accumulating lost updates, as in the measurement.

**"If the user stopped, the update was lost."** For a user who stands still the provider produces no new update; since there is no sent event, the rule sees no loss. The same silence is seen when the registration is removed, and telling the two apart is the job of the app.

**"The map view delivers the location update."** The map only draws the position it holds. If the update did not reach the receiver, the map shows the old place and this appears as a drawing error, although the loss happened earlier, between the provider and the receiver.

#### The Bounding Measurement

The interval rule only thins the arrival of updates. The permission, on the other hand, is the precondition of arrival. When the user revokes the permission from the settings, the provider gives no update after that step, and later updates do not arrive whatever the interval is.

```kotlin
// Main.kt — once the permission is revoked, later updates do not arrive
fun main() {
    val sent = (13..18).map { Signal("location.courier", it) }
    val received = revoke(sample(sent, minGap = 2), revokedAt = 16)
    println("received seqs: ${received.map { it.seq }}")

    val arrivals = sent.map { Arrival("event-${it.seq}", it, received) }
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
received seqs: [13, 15]
event-13: key=location.courier | copies=1 -> once
event-14: key=location.courier | copies=0 -> superseded
event-15: key=location.courier | copies=1 -> once
event-16: key=location.courier | copies=0 -> lost
event-17: key=location.courier | copies=0 -> lost
event-18: key=location.courier | copies=0 -> lost
once: 2
duplicated: 0
superseded: 1
lost: 3
```

Updates 13 and 15 arrived, and 14 was counted as superseded because 15 took its place. Since the permission was revoked at step 16, updates 16, 17 and 18 were lost: no update with the same key came after them. The rule does not see that the cause of the loss was the revoked permission. To know this, the app has to check the permission state separately.

**SC4.** The model builds the revocation of the permission as a single step and as certain. On the platform, when the permission is revoked the app can be terminated, the precise permission can be lowered to approximate, and the user can grant the permission again later; none of these exist in the model.

#### Summary

- This lesson's delivery: superseded. The minimum interval rule drops the location updates that fall in between, and the later update with the same key takes their place.
- Of eight updates, four were superseded, three arrived once and the last was lost: once 3, superseded 4, lost 1.
- When the interval is applied to each key separately, the first and third updates of both couriers arrived once, the ones in between were superseded and the last of each was lost: once 4, superseded 2, lost 2.
- When the interval fell to one, all four updates arrived once; the cost is that the receiver processes every update.
- The three updates produced after the permission was revoked were lost: lost 3. The rule does not see the cause.
- Precise and approximate location are separate permissions and the rule does not see accuracy; the map view is not a delivery question and was not measured.

#### Next Step

In this lesson the event was a continuous stream and the rule dropped the ones in between. In the camera the event is born from a single user action, and each action should yield a single result. The next lesson, `Camera and Media`, measures how two separate events arrive when the shutter is touched twice.

### Camera and Media

Source: https://academia.sh/en/courses/platform-services-and-integrations/camera-and-media

The camera library returns a single result for each capture request; when storage is full, no result arrives. The lesson measures the delivery of capture requests and shows that when the shutter is touched twice in a row, two separate results arrive.

In the previous lesson the location updates were a continuous stream and the rule dropped the ones in between. In the camera the event is born from a user action: the user touches the shutter and the app expects a photo. One touch is expected to give one result. In this lesson we ask the same question again: **how many copies of this sent event reached the receiver?**

```kotlin
// 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 classification is the same as in the previous lessons. The rule counts the copies in the receiver's list that carry the same sequence number; if there is no copy, it checks whether a later event with the same key arrived. It does not see the content or the cause.

#### Permission and Capture Request

The camera is also a capability that asks for a runtime permission. It is declared in the manifest and the user approves it on first use.

```xml
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
```

The camera library splits a capture into two parts. The app gives a capture request; the library returns the result through a callback. For a capture that ends successfully it reports that the file was saved, and for one that fails it reports an error object.

```kotlin
// Platform code: ShutterController
import androidx.camera.core.ImageCapture
import androidx.camera.core.ImageCaptureException

class ShutterController(private val capture: ImageCapture) {
    fun onShutterTap(options: ImageCapture.OutputFileOptions) {
        capture.takePicture(options, executor, object : ImageCapture.OnImageSavedCallback {
            override fun onImageSaved(result: ImageCapture.OutputFileResults) = openPreview(result)
            override fun onError(error: ImageCaptureException) = logFailure(error)
        })
    }
}
```

Every `takePicture` call produces a result or an error. The model builds this rule of the platform; the model is not the platform itself. This snippet claims no output or duration.

#### Every Touch Is One Result

In the model, each capture request is a `Signal`. The key is the name of the photo, and the sequence number is the step at which the request was given. The model of the camera library returns a single result for each request.

```kotlin
// Camera.kt — returns a single result for each capture request
fun shoot(requests: List<Signal>): List<Signal> =
    requests.map { Signal(it.key, it.seq) }
```

```kotlin
// Main.kt — each capture request arrives once as a single result
fun main() {
    val sent = listOf(
        Signal("photo.morning", 1),
        Signal("photo.noon", 2),
        Signal("photo.evening", 3),
        Signal("photo.night", 4),
    )
    val received = shoot(sent)
    val arrivals = sent.map { Arrival("event-${it.seq}", it, received) }
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=photo.morning | copies=1 -> once
event-2: key=photo.noon | copies=1 -> once
event-3: key=photo.evening | copies=1 -> once
event-4: key=photo.night | copies=1 -> once
once: 4
duplicated: 0
superseded: 0
lost: 0
```

This lesson's delivery is **once**. All four requests arrived as a single copy. In the previous two lessons the rule counted an event taking the place of another as superseded; here the keys differ from one another, so no event can take the place of another.

**SC1.** The model takes every capture request as successful. On the platform a capture can fail while the camera is busy, while the permission is revoked or while the library restarts; the error callback is not in the model.

#### The Error Callback and the Result Contract

The contract of the library is this: for each capture request, only one of the success callback or the error callback runs. Both do not run, and neither running is a violation of the contract. The "a single result for every request" rule of the model is a plain form of this contract: the result is either a file or an error, but it is always single.

For the app this means the error callback is not a luxury. A screen that writes the success path and leaves the error path empty produces a loss that tells the user nothing: the user touched the shutter and nothing on the screen changed. In the measurement, the loss of two captures is only a number for the rule; for the user it is two separate disappointments. The information the error object carries decides what can be told to the user: a redirect to the settings for a missing permission, a suggestion to free space for full storage.

#### The Lifecycle of the Screen

The camera preview is bound to the lifecycle of the screen. The camera opens when the screen becomes visible and closes when the screen is hidden. The capture request of the library carries meaning at a time when the camera is open; a request given while the camera is closed falls to the error callback.

The relation of this to delivery lies in the moment at which the request is given. If the user touches the shutter and immediately switches to another app, the request has been given but the camera closes as the screen is hidden. For a camera that closes before the capture is completed, the library reports an error; the photo is never formed. In the model this corresponds to a request being lost, as when storage fills up. The difference is that the cause is the lifecycle rather than storage. The rule does not tell these two causes apart; to tell them apart one has to look at the error object.

#### A Request Made Without Permission

A capture request made while the permission has not been granted also falls to the error callback. The requests the app gave until the user answers the permission dialog are not processed again once the permission is granted. In the model below the permission is granted at step 14 and the requests before it produce no result. The first three requests carry the same photo name: the user is trying to capture a document.

```kotlin
// Permission.kt — drops the result of capture requests made before the permission was granted
fun requirePermission(sent: List<Signal>, grantedAt: Int): List<Signal> =
    sent.filter { it.seq >= grantedAt }
```

```kotlin
// Main.kt — requests made without permission produce no result
fun main() {
    val sent = listOf(
        Signal("photo.document", 12),
        Signal("photo.document", 13),
        Signal("photo.document", 14),
        Signal("photo.page", 15),
    )
    val received = requirePermission(shoot(sent), grantedAt = 14)
    println("received seqs: ${received.map { it.seq }}")

    val arrivals = sent.map { Arrival("event-${it.seq}", it, received) }
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
received seqs: [14, 15]
event-12: key=photo.document | copies=0 -> superseded
event-13: key=photo.document | copies=0 -> superseded
event-14: key=photo.document | copies=1 -> once
event-15: key=photo.page | copies=1 -> once
once: 2
duplicated: 0
superseded: 2
lost: 0
```

Requests 12 and 13 produced no result, but request 14 gave a result with the same key. For this reason the rule counted the two as superseded rather than lost. This count is consistent with the definition of the rule, but it is misleading from the point of view of the photo: the requests without permission did not take the place of any photo, because no photo was ever taken. In this case the superseded label says only that "a result with the same key came later"; it should be read as a loss that the user did not see and that was made up for afterwards. Had the keys been different, the same situation would have been counted as lost. Reading the rule like a textbook requires remembering that the choice of key changes the number.

**SC2.** The model takes the permission as granted at a single step and as certain. On the platform the permission dialog can be shown to the user several times, if "do not ask again" is chosen later calls are rejected without showing a dialog, and the permission can be granted later from the settings.

#### When Storage Is Full

The result of a capture is a file, and there has to be a place for the file to be written. When storage is full, the library cannot save the result and falls to the error callback. In the model, the number of results the storage can take is an integer capacity; the number is a made-up value of the model.

```kotlin
// Storage.kt — drops later results once the storage capacity is full
fun store(results: List<Signal>, capacity: Int): List<Signal> =
    results.take(capacity)
```

```kotlin
// Main.kt — once storage is full, later captures do not arrive
fun main() {
    val sent = listOf(
        Signal("photo.a", 5),
        Signal("photo.b", 6),
        Signal("photo.c", 7),
        Signal("photo.d", 8),
    )
    val received = store(shoot(sent), capacity = 2)
    println("received seqs: ${received.map { it.seq }}")

    val arrivals = sent.map { Arrival("event-${it.seq}", it, received) }
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
received seqs: [5, 6]
event-5: key=photo.a | copies=1 -> once
event-6: key=photo.b | copies=1 -> once
event-7: key=photo.c | copies=0 -> lost
event-8: key=photo.d | copies=0 -> lost
once: 2
duplicated: 0
superseded: 0
lost: 2
```

The first two captures arrived once; captures 7 and 8 were lost. Because their keys differ, none of them took the place of another and the rule did not count any as superseded. The rule does not see that the cause of the loss is full storage; to know this, the app has to listen to the error callback.

**SC3.** The model builds the storage with a fixed capacity. On the platform the free space changes according to the other work of the user and the capacity can run out during a capture; this is not in the model.

#### Media Picker and Shared Storage

Not every photo has to be the app's own capture. When the user picks an existing photo from the gallery, the selection comes from a shared media store, and the app is given only the address of the selected file. The picker asks for no separate permission, because the app reaches not all the media but only what the user selected. This is a separate case in the delivery model: a selection is an event, but the owner of the event is the user, and the selected file can be counted as a result that was sent and arrived.

The difference between a capture and a selection lies in the responsibility of the error path. In a capture, storage can fill up and the permission can be missing. In the picker the user can give up, and this is not counted as an error: an empty result returns. Giving up is neither lost nor superseded, because no event was sent. This distinction shows the limit of the measurement model: the rule counts sent events, not unsent ones.

#### The Preview Is Not a Capture

The camera library offers two separate streams: the preview and the capture. The preview is an image updated continuously on the screen that shows the user the frame; the capture produces a single file. Every frame of the preview looks like an event, but it is not a result delivered to the app: frames are drawn on the screen and discarded. In the model of this lesson there is only the capture, because in the preview there is no frame that has to reach a receiver. Because frames can be discarded, the preview is the visual counterpart of the superseded behavior in the location and sensor lessons: a slow screen silently drops old frames, and nobody counts this as a loss.

A capture, on the other hand, cannot be dropped. When the user asks for a photo, the result is either a file or an error. Writing the file takes time, and the success callback does not run until the writing finishes. If the screen is hidden while the file is being written, the writing is mostly completed but the success callback now reaches a screen that is no longer visible. In this case the file exists and the user sees it in the gallery; but the reaction of the screen to the result, for example updating the preview thumbnail, does not happen. For the rule this event counts as arrived; for the user half of the result is lost. This difference, which stays outside the model, requires keeping the result somewhere independent of the screen, for example in a view model.

#### Common Misconceptions

**"The second touch is a copy of the first touch."** A copy is the same sequence arriving more than once. Two touches are two separate requests and the results are two separate ones. If the user wants a single photo, the solution is to block the second touch before the request is given; deduplication on the result side leaves both photos taken.

**"If the capture request was given, the photo exists."** The request is only an intention. The photo exists when the success callback runs. If the error callback ran, it does not exist.

**"The permission was granted once, the camera always opens."** The camera may be in use by another app, the device may have turned the camera off to save power, or the user may have revoked the permission later. The error path must be ready at every capture request.

**"If the file was saved, the user has seen it."** The success callback reports that the file was written, not that the user saw the result. If the screen is hidden at that moment, the result is saved but not shown. If the app does not keep the result somewhere it can be read when the screen becomes visible again, the user finds the photo only in the gallery.

**"Full storage cannot be known before the capture."** The free space can be queried before the capture. A check made before the capture makes it possible to tell the user about the two losses in the measurement before the shutter is even touched; the error callback is the last resort.

**"If the preview is visible, the camera is ready."** While the preview runs, a capture can still fail: storage may be full, the permission revoked, or a previous capture may still be being written. Readiness is decided by the result of the capture request, not by the preview.

**"Superseded applies only to location and sensors."** Superseded, in the definition of the rule, means that an event is replaced by a newer event with the same key. As in the measurement of the request without permission, it can be seen in a capture too. The category depends not on the type of the event but on the keys and the sequences.

**"The media picker also asks for a permission."** The picker gives access to the file the user selected; a broad storage permission is not needed.

#### The Bounding Measurement

What happens if the shutter is touched twice in a row? When the user touches the shutter twice quickly, the app gives two separate capture requests. These are not two copies of the same event: they are two separate requests and each carries its own sequence.

```kotlin
// Main.kt — two touches on the shutter arrive once each as two separate sequences
fun main() {
    val sent = listOf(
        Signal("photo.cover", 9),
        Signal("photo.cover", 10),
        Signal("photo.detail", 11),
    )
    val received = shoot(sent)
    val arrivals = sent.map { Arrival("event-${it.seq}", it, received) }
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-9: key=photo.cover | copies=1 -> once
event-10: key=photo.cover | copies=1 -> once
event-11: key=photo.detail | copies=1 -> once
once: 3
duplicated: 0
superseded: 0
lost: 0
```

Captures 9 and 10 carry the same key, but because their sequences differ the rule counts each by its own sequence. Both arrived once; neither was counted as duplicated. Duplicated is the same sequence arriving more than once. If the user asked for two photos, the user gets two photos. If we want the second touch to be ignored, this is the app's own rule and must be applied before the capture request is given.

**SC4.** The model builds the two touches as two independent requests. On the platform the library may allow only one capture to be processed at a time and may queue or reject the second request; this is not in the model.

#### Summary

- This lesson's delivery: once. Each capture request arrived as a single result: once 4.
- Captures whose keys differ do not take each other's place; this is why there is no superseded in this lesson.
- Once storage filled up, the next two captures were lost: lost 2. The rule does not see the cause.
- Two requests made without permission were counted as superseded because a later request with the same key arrived: once 2, superseded 2. The choice of key changes the number.
- Two touches on the shutter are two separate sequences and both arrive once; duplicated is the same sequence arriving more than once.
- The camera permission is a runtime permission; the result callback separates success from error.

#### Next Step

In this lesson the event was born from a single action and each request received a single result. In sensors the event is produced in great numbers every second, and a slow receiver cannot keep up with all of it. The next lesson, `Sensors`, measures the delivery of the sensor events that a slow receiver skips.

### Sensors

Source: https://academia.sh/en/courses/platform-services-and-integrations/sensors

Sensor events arrive quickly from device hardware such as the accelerometer; a slow receiver cannot keep up with all of them. The lesson measures which sensor event arrives when the buffer overflows and when the receiver takes only the last reading, and what happens once the listener is unregistered.

In the previous lesson the event was born from a user action and each action gave a single result. In a sensor the event comes from hardware and is produced continuously, independent of the user. The accelerometer reads the motion of the device, the light sensor reads the ambient light. The readings can come faster than the receiver can process them. In this lesson we ask the same question again: **how many copies of this sent event reached the receiver?**

```kotlin
// 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 classification is the same as in the previous lessons. The rule counts the copies in the receiver's list that carry the same sequence number; if there is no copy, it checks whether a later event with the same key arrived. It does not see the content or the cause.

#### The Sensor Manager and the Listener

The platform reaches sensors through the sensor manager. The app selects a sensor, registers a listener and tells the manager the sampling rate. The rate is a request: the platform gives readings at a frequency close to this rate but does not guarantee the count. Each reading falls to the callback of the listener as a sensor event. It is also possible for the platform to deliver readings in batches; in that case several readings can arrive at the same time.

```kotlin
// Platform code: MotionWatcher
import android.hardware.Sensor
import android.hardware.SensorEvent
import android.hardware.SensorEventListener
import android.hardware.SensorManager

class MotionWatcher(private val manager: SensorManager) : SensorEventListener {
    fun start() {
        val accel = manager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
        manager.registerListener(this, accel, SensorManager.SENSOR_DELAY_UI)
    }

    fun stop() = manager.unregisterListener(this)

    override fun onSensorChanged(event: SensorEvent) = process(event.values)
    override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) = Unit
}
```

`start` registers the listener and `stop` removes the registration. In the model, a listener whose registration has been removed no longer receives readings. This snippet writes no rate number and claims no output. The model builds this rule of the platform; the model is not the platform itself.

#### The Sampling Rate Is a Request

The rate constant given while registering the listener is a hint about how often readings will come. The platform offers four separate levels: fastest, fast for games, medium for the user interface, and normal; normal is the slowest. A faster level produces more readings, but this is an approach of the platform, not a contract. Depending on the current load of the device, the battery state and the hardware limits of the sensor, the real frequency can deviate from this.

For this reason there are two separate delivery questions, and they must not be confused with each other. The first is how many readings the sensor produces; the rate level decides that. The second is how many of the produced readings reach the receiver; the rule of this lesson measures that. Raising the rate level increases production, but if the receiver is slow the count that arrives does not change: the extra readings produced only become superseded. The battery and processor cost grows in proportion to production. Raising the rate is useful when the receiver can process that many readings.

A high sampling rate also asks for a separate permission in some versions. If a listener is registered without declaring the permission, the platform silently pulls the rate to an upper limit; if a higher rate is requested by another route, a security error occurs. The app must check for itself whether it got the frequency it asked for.

#### A Slow Receiver

In the model, each reading is a `Signal` and its key is the name of the sensor. If the receiver is slow, in a single processing turn it takes only the last of the readings that have piled up; the earlier ones are skipped. The number of readings a turn covers is a made-up value of the model.

```kotlin
// Sampling.kt — a slow receiver takes only the last reading in each turn
fun latestOnly(sent: List<Signal>, perTurn: Int): List<Signal> =
    sent.chunked(perTurn).map { it.last() }
```

```kotlin
// Main.kt — a slow receiver takes only the last reading in each turn
fun main() {
    val sent = (1..8).map { Signal("sensor.accel", it) }
    val received = latestOnly(sent, perTurn = 3)
    println("received seqs: ${received.map { it.seq }}")

    val arrivals = sent.map { Arrival("event-${it.seq}", it, received) }
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
received seqs: [3, 6, 8]
event-1: key=sensor.accel | copies=0 -> superseded
event-2: key=sensor.accel | copies=0 -> superseded
event-3: key=sensor.accel | copies=1 -> once
event-4: key=sensor.accel | copies=0 -> superseded
event-5: key=sensor.accel | copies=0 -> superseded
event-6: key=sensor.accel | copies=1 -> once
event-7: key=sensor.accel | copies=0 -> superseded
event-8: key=sensor.accel | copies=1 -> once
once: 3
duplicated: 0
superseded: 5
lost: 0
```

This lesson's delivery is **superseded**. Five of the eight readings did not arrive, but after each of them a newer reading with the same key arrived. For tracking motion this is the right trade: the current value is worth more than the old one. The eighth reading arrived because it was the last of its turn; even if the last turn is not complete, the last reading is taken.

**SC1.** The model builds the turns with equal length and counts the end of each turn as the moment the receiver reads. On the platform the length of a turn changes with the processing load and the readings can be grouped several times in batched delivery; none of these exist in the model.

#### Batching and Flushing

To save battery, the platform can accumulate readings in hardware and deliver them together. The app states the longest delay allowed between the production of a reading and its delivery; the platform holds the readings until that time runs out or the buffer fills, and then delivers all of them together. This arrangement is what makes it possible to read the sensor even when the screen is off.

The rule of batching is short: a batch is either completed and delivered, or it waits. In the model, a batch that is waiting does not come on its own when the app removes the registration. The way not to lose readings in delayed delivery is to flush the waiting batch explicitly before removing the registration. In the model the batch size is three, and the last readings, whose batch is incomplete, are not delivered because they are not flushed.

```kotlin
// Batch.kt — the last incomplete batch is not delivered unless flushed
fun deliverBatched(sent: List<Signal>, size: Int): List<Signal> =
    sent.chunked(size).filter { it.size == size }.flatten()
```

```kotlin
// Main.kt — the last incomplete batch is not delivered
fun main() {
    val sent = (21..27).map { Signal("sensor.step", it) }
    val received = deliverBatched(sent, size = 3)
    println("received seqs: ${received.map { it.seq }}")

    val arrivals = sent.map { Arrival("event-${it.seq}", it, received) }
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
received seqs: [21, 22, 23, 24, 25, 26]
event-21: key=sensor.step | copies=1 -> once
event-22: key=sensor.step | copies=1 -> once
event-23: key=sensor.step | copies=1 -> once
event-24: key=sensor.step | copies=1 -> once
event-25: key=sensor.step | copies=1 -> once
event-26: key=sensor.step | copies=1 -> once
event-27: key=sensor.step | copies=0 -> lost
once: 6
duplicated: 0
superseded: 0
lost: 1
```

Six readings were delivered with two complete batches and arrived once. The seventh reading stayed in the incomplete batch and, because no reading with the same key arrived after it, it was lost. The rule does not see this as an error of the sensor or of the registration; it only counts that it did not arrive. The measure on the receiver side is to explicitly ask for the waiting readings before removing the registration.

**SC2.** The model builds the batch with a fixed size and without delay. On the platform the batch size depends on the reading rate, the delay limit and the buffer capacity, and the flush request is also completed through a callback; none of these exist in the model.

#### When the Buffer Overflows

A buffer can stand in front of the receiver. When the buffer is full there are two options: to drop the oldest or to drop the newly arriving reading. The receiver in the previous section kept the newest reading; a buffer that drops the newcomer does the opposite.

```kotlin
// Overflow.kt — drops newly arriving readings once the buffer is full
fun overflow(sent: List<Signal>, capacity: Int): List<Signal> =
    sent.take(capacity)
```

```kotlin
// Main.kt — when the buffer overflows, the newest readings do not arrive
fun main() {
    val sent = (9..14).map { Signal("sensor.light", it) }
    val received = overflow(sent, capacity = 3)
    println("received seqs: ${received.map { it.seq }}")

    val arrivals = sent.map { Arrival("event-${it.seq}", it, received) }
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
received seqs: [9, 10, 11]
event-9: key=sensor.light | copies=1 -> once
event-10: key=sensor.light | copies=1 -> once
event-11: key=sensor.light | copies=1 -> once
event-12: key=sensor.light | copies=0 -> lost
event-13: key=sensor.light | copies=0 -> lost
event-14: key=sensor.light | copies=0 -> lost
once: 3
duplicated: 0
superseded: 0
lost: 3
```

The first three readings arrived once; readings 12, 13 and 14 were lost. Because no new reading with the same key arrived after the dropped readings, the rule does not count them as superseded. The same overflow would have ended as superseded in a buffer that drops the oldest. Which reading is dropped depends on the value to the receiver; the rule counts only the result.

**SC3.** The model builds the buffer capacity as a fixed integer. On the platform the buffer size depends on the hardware and the version, and which reading is dropped during an overflow can vary with the sensor; none of these exist in the model.

#### Sensor Types and Permission

Not all sensors are at the same permission level. Motion and light sensors ask for no permission. Sensors about the body, such as heart rate, ask for a runtime permission, and if the user does not grant it the sensor may not appear in the list or may give no readings. A missing permission produces no error at registration; the listener is registered, but the callback never runs.

This is a loss familiar from the previous two lessons. In location, updates did not come after the permission was revoked; in the camera, requests before the permission was granted produced no result. In a sensor too, a listener without permission is silent. The silence can arise from three separate situations: no permission, no such sensor in the hardware, or the sensor exists but produces no events. To tell them apart, the app looks first at the existence of the sensor, then at the state of the permission, and last at whether readings are flowing.

#### The Accuracy Callback and the Timestamp

The listener has two callbacks. The first gives the reading, and the second runs when the accuracy level of the sensor changes. The snippet in the platform code left the second empty. This is enough for most apps, but it can be a wrong choice for some sensors: sensors such as the compass need calibration, and when the accuracy is low the readings come but are meaningless.

The relation of this situation to the delivery model is as follows. A reading that comes with low accuracy has reached the receiver and the rule counts it as once. Whether the reading is usable is a property the rule does not see. The count arrived correctly, the value stayed unreliable. The receiver must keep these two questions apart: did it arrive, and if it arrived, is it reliable.

Each reading also carries a timestamp. The stamp shows the moment the reading was produced in hardware, not the moment it was delivered to the receiver. In batched delivery or with a slow receiver the two moments move apart: the reading comes late but its stamp is old. If the receiver looks at the stamp while processing a reading, it does not present an old reading as current. In the model the sequence number takes this role: a larger sequence means a newer production, and the superseded rule rests on this sequence.

#### Common Misconceptions

**"If I raise the rate, I get more readings."** Production increases, but the count that reaches the receiver is limited by the speed of the receiver. On a slow receiver the extra readings are counted only as superseded; in the measurement five of the eight readings were superseded for this reason.

**"A superseded reading is a loss, it is an error."** The place of a superseded reading has been taken by a newer reading with the same key. For an app tracking motion the current value is worth more than the old one, and this behavior is desired. An error is looked for where no reading comes after a reading, that is, in a loss.

**"If the registration is removed, the waiting readings still come."** In the model, removing the registration silences the listener. In batched delivery the waiting batch does not come on its own; the app must first flush it explicitly.

**"A sensor event is real time."** An event can wait from the moment it is produced to the moment it is delivered. In batched delivery and with a slow receiver this wait grows longer. The timestamp tells when the event was produced; it does not tell when it was processed.

**"A sensor event is always processed on the main thread."** The callback runs on the main thread if no `Handler` is given, and on the looper of that `Handler` if one is; so it depends on how the registration was made. An app that does heavy work in the callback slows the receiver, and a slow receiver supersedes readings as in the first measurement of this lesson. Taking only the value in the callback and leaving the work to another thread means choosing superseding rather than loss.

**"Leaving the listener always open is harmless."** An open listener produces readings and spends battery even when the screen is off. Removing the registration when readings are no longer needed both saves energy and prevents readings that nobody processes from piling up.

**"The number of readings means accuracy."** A large number of readings does not show that the values are reliable. The accuracy level is separate information and changes independently of the count.

#### The Bounding Measurement

Even if the receiver is fast enough to process the readings, the readings stop the moment the listener registration is removed. If the app forgets to call `stop` when it leaves the screen, it spends battery; if it calls it, the readings after that moment do not arrive.

```kotlin
// Main.kt — once the listener registration is removed, later readings do not arrive
fun main() {
    val sent = (15..20).map { Signal("sensor.accel", it) }
    val received = latestOnly(sent, perTurn = 2).filter { it.seq < 19 }
    println("received seqs: ${received.map { it.seq }}")

    val arrivals = sent.map { Arrival("event-${it.seq}", it, received) }
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
received seqs: [16, 18]
event-15: key=sensor.accel | copies=0 -> superseded
event-16: key=sensor.accel | copies=1 -> once
event-17: key=sensor.accel | copies=0 -> superseded
event-18: key=sensor.accel | copies=1 -> once
event-19: key=sensor.accel | copies=0 -> lost
event-20: key=sensor.accel | copies=0 -> lost
once: 2
duplicated: 0
superseded: 2
lost: 2
```

Readings 16 and 18 arrived; 15 and 17 were superseded. Readings 19 and 20 were lost: because the listener registration was removed before 19, no reading arrived after that. The rule does not see that the cause of the loss is the removed registration.

**SC4.** The model builds the removal of the registration as a single step and as immediate. On the platform readings that stayed in the buffer can arrive even after the removal call; this is not in the model.

#### Summary

- This lesson's delivery: superseded. A slow receiver takes only the last reading in each turn, and the earlier ones leave their place to a new reading with the same key.
- Of eight readings, five were superseded and three arrived once: superseded 5, once 3.
- When a buffer that drops the newly arriving reading overflowed, three readings were lost: lost 3. A buffer that drops the oldest would have made the same readings superseded.
- The two readings produced after the listener registration was removed were lost: lost 2.
- The last incomplete batch is not delivered unless flushed: six readings arrived once, the seventh was lost: once 6, lost 1.
- The sampling rate is a request; the platform does not guarantee to give readings at this frequency.

#### Next Step

In this lesson the event came from hardware and was superseded when the receiver could not keep up. In a deep link the event comes from outside as a link, and while the app is being recreated the same link can be processed once more. The next lesson, `Deep Links and Shortcuts`, measures a deep link that is processed again on rotation.

### Deep Links and Shortcuts

Source: https://academia.sh/en/courses/platform-services-and-integrations/deep-links-and-shortcuts

A deep link ties an address that comes from outside to a specific screen of the app; while the screen is being recreated, the same link can be processed once more. The lesson measures this duplicated delivery and the effect of keeping the handled marker in saved state or in memory.

In the previous lesson readings were skipped when the receiver could not keep up. In a deep link the problem comes from the opposite direction: a link reaches the receiver once, but the receiver can process it one more time. The user taps an address in an email and the app opens the related product screen. When the screen is rotated it is recreated, and if the same link is processed again, the user sees the product screen opened a second time. In this lesson we ask the same question again: **how many copies of this sent event reached the receiver?**

```kotlin
// 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 classification is the same as in the previous lessons. The rule counts the copies in the receiver's list that carry the same sequence number; if there is no copy, it checks whether a later event with the same key arrived. It does not see the content or the cause.

#### The Deep Link and the Shortcut

A deep link lets an address open a specific screen of the app. In the manifest the app defines, with an `intent` filter, which addresses it opens for. The verified app link goes one step further: the owner of the address verifies that the domain is tied to the app, and the platform hands the link to the app without asking the user. The shortcut is an entry that appears when the app icon is pressed and held and that leads to a specific screen; it also carries an `intent`.

```xml
<intent-filter android:autoVerify="true">
    <action android:name="android.intent.action.VIEW" />
    <category android:name="android.intent.category.DEFAULT" />
    <category android:name="android.intent.category.BROWSABLE" />
    <data android:scheme="https" android:host="shop.example" android:pathPrefix="/product" />
</intent-filter>
```

The link reaches the app as an `intent`, and the screen reads it on launch.

```kotlin
// Platform code: ProductActivity
import android.content.Intent
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity

class ProductActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val link = intent?.data
        if (link != null) openProduct(link.lastPathSegment)
    }
}
```

`onCreate` runs every time the screen is recreated, and the `intent` stays the same. This snippet claims no output or timing. The model builds this rule of the platform; the model is not the platform itself.

#### The Link That Is Processed Again

In the model each link is a `Signal` and its key is the product address. When the screen is recreated, the same link is processed again; this is the second arrival of the same sequence. In the model, processing again means that the given sequences are appended to the end of the list as a copy.

```kotlin
// Redeliver.kt — hands over the same link once more when the screen is recreated
fun redeliver(sent: List<Signal>, again: List<Int>): List<Signal> =
    sent + again.map { seq -> sent.first { it.seq == seq } }
```

```kotlin
// Main.kt — when the screen is recreated, two links are processed twice
fun main() {
    val sent = listOf(
        Signal("product.7", 1),
        Signal("product.9", 2),
        Signal("product.12", 3),
        Signal("product.15", 4),
    )
    val received = redeliver(sent, again = listOf(2, 3))
    val arrivals = sent.map { Arrival("event-${it.seq}", it, received) }
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=product.7 | copies=1 -> once
event-2: key=product.9 | copies=2 -> duplicated
event-3: key=product.12 | copies=2 -> duplicated
event-4: key=product.15 | copies=1 -> once
once: 2
duplicated: 2
superseded: 0
lost: 0
```

This lesson's delivery is **duplicated**. Links 2 and 3 arrived as two copies; links 1 and 4 stayed with a single copy and arrived once. The user sees the product page opened twice. The rule does not see whether the copy came from a rotation or from another cause.

**SC1.** The model builds processing again as a copy appended to the end of the list. On the platform, when the screen is recreated, the same `intent` is read again within the screen's own flow, and exactly when the copy comes depends on the lifecycle of the screen; this is not in the model.

#### A New Link for an Open Screen

If the user taps a second link while the app is open, the platform chooses one of two ways: it recreates the screen, or it gives the new link to the existing screen with a separate call. The launch mode of the screen decides which one is chosen. Recreating starts the lifecycle of the screen from the beginning, and the link is read at launch during this. Giving it to the existing screen passes the new link through a separate callback and does not touch the link read at launch.

This duality is one source of duplication. If the launch mode produces a new screen every time, the old screen stays on the back stack, and when the back button is pressed the old screen appears again with the link it still holds. The user sees the same product opened a second time. Which mode is chosen is the preference of the app; the measurement counts only the result, that is, how many copies were processed.

#### The Handled Marker

The way to prevent duplication is to keep a marker that the link has been handled. If the marker is kept in saved state, then even if the screen is recreated, it is known at the next read that the link has been handled. This is how deduplication works: if the same sequence comes a second time, it is not processed.

```kotlin
// Handled.kt — skips a link that was already processed if the handled marker is kept
fun dedupe(arrived: List<Signal>, kept: Boolean, restartAt: Int): List<Signal> {
    val handled = mutableSetOf<Int>()
    val processed = mutableListOf<Signal>()
    arrived.forEachIndexed { i, s ->
        if (i == restartAt && !kept) handled.clear()
        if (handled.add(s.seq)) processed.add(s)
    }
    return processed
}
```

`kept` shows whether the marker is preserved even if the process restarts, and `restartAt` shows before which arrival the process restarted.

```kotlin
// Main.kt — if the handled marker is kept in saved state, the links arrive once
fun main() {
    val sent = (5..8).map { Signal("product.${it * 2}", it) }
    val arrived = redeliver(sent, again = listOf(6, 7))
    val received = dedupe(arrived, kept = true, restartAt = 4)
    val arrivals = sent.map { Arrival("event-${it.seq}", it, received) }
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-5: key=product.10 | copies=1 -> once
event-6: key=product.12 | copies=1 -> once
event-7: key=product.14 | copies=1 -> once
event-8: key=product.16 | copies=1 -> once
once: 4
duplicated: 0
superseded: 0
lost: 0
```

The same two reprocessings went through deduplication, and all four links arrived once. The processed list carries a single copy.

**SC2.** The model accepts the cost of keeping the marker as zero. On the platform the data written to saved state is limited, and how long the marker is kept is a decision of the app; this is not in the model.

#### Shortcuts

The shortcut comes in three forms. The static shortcut ships with the app and is defined in a resource file; it does not change until the app is updated. The dynamic shortcut is added, updated and deleted by the app itself while it runs; when the app data is cleared, the dynamic shortcuts are deleted too. The pinned shortcut is placed on the home screen by the user; the app cannot remove this shortcut, it can only disable it.

The delivery-related side of the shortcut is its target. A shortcut carries an `intent` like a link and opens the same screen in the same way. For this reason the deduplication built for the deep link also holds for the shortcut: when the screen is recreated, the target of the shortcut is read again. The difference is that the shortcut is defined by the app, not by the user. A user who taps a removed shortcut sees a different error from a user who taps a link: the link never opens, while the shortcut opens but the target screen cannot be found.

#### Link Types and Verification

The addresses that open the app fall into three types. A custom scheme address carries only a prefix the app defines, and any app can define the same prefix. If more than one app recognizes an address, the platform asks the user which to open it with. With a web address the same question arises between the browser and the app. The verified app link removes this question: the owner of the address declares, with a file in its domain, that it ties the identity of the app to the domain, and the platform hands the link directly to that app.

If verification fails, the platform does not hand the link to the app. The app sees no error; the link opens in the browser or in the chooser screen. The user taps a product link and sees a web page instead of the product page of the app. This is an invisible loss of delivery: for the app it looks as if the event was never sent, while for the user a sent action has produced no result.

In the model, verification holds back from the app all the links that belong to a certain domain. The domain is the first part of the key, and the unverified domain is a made-up value of the model.

```kotlin
// Verify.kt — does not hand links of an unverified domain to the app
fun verifiedOnly(sent: List<Signal>, unverified: String): List<Signal> =
    sent.filter { !it.key.startsWith("$unverified.") }
```

```kotlin
// Main.kt — links of the unverified domain do not reach the app
fun main() {
    val sent = listOf(
        Signal("example.product7", 13),
        Signal("old.product3", 14),
        Signal("example.product9", 15),
        Signal("old.product3", 16),
    )
    val received = verifiedOnly(sent, unverified = "old")
    println("received seqs: ${received.map { it.seq }}")

    val arrivals = sent.map { Arrival("event-${it.seq}", it, received) }
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
received seqs: [13, 15]
event-13: key=example.product7 | copies=1 -> once
event-14: key=old.product3 | copies=0 -> lost
event-15: key=example.product9 | copies=1 -> once
event-16: key=old.product3 | copies=0 -> lost
once: 2
duplicated: 0
superseded: 0
lost: 2
```

The two links of the verified domain arrived once. The two links of the unverified domain did not arrive, and because no link with the same key came after them, they were lost. Although the same address was sent twice, this time it was not counted as duplicated: duplicated is the number of copies that arrived, not the number of copies sent.

**SC3.** The model builds verification as all or nothing per domain. On the platform verification depends on the download of the domain file, the signature of the app and the path prefix, and partial verification is also possible; these are not in the model.

#### Cold Start and Link Data

The link can find the app in two separate states. If the app is not running at all, the process starts from scratch, the screen is created and the link is read during launch. If the app is in the background, the process is alive, and the screen is either recreated or the new link is given to the existing screen. The handled marker helps a lot in the second state; in the first state the marker does not exist yet and the link arrives as a single copy.

The data that comes from the link comes from the outside world and is not trustworthy. If the last part of the address is a product number, the app must check its format before converting it to a number, and for a missing or invalid product it must show an error screen. From the delivery point of view this check concerns the content, not the copies, and stays outside the measurement. The rule counts that the link arrived; it does not look at whether the link that arrived is valid. An invalid link can arrive once and still fail to show the user a product.

#### The Back Button and the Screen Stack

When a link opens the app directly on an inner screen, where the back button takes the user is a design question. If the app was not running and only the product screen is on the stack, the back button takes the user out of the app. If the app also places the home screen beneath the product screen, the user stays inside the app. This choice does not change delivery, but it makes duplication visible: if there are two product screens on the stack, the back button shows the first, and the user sees the same product a second time. The rule still counts these two screens either as one copy or as two copies; which one it is depends on how many times the link was processed.

#### Common Misconceptions

**"A verified link always opens the app."** It opens it if verification succeeds. When verification fails or is not yet complete, the link goes to the browser. The app does not see this as an error, because no event has been sent to it.

**"If the same link came twice, the platform made a mistake."** The platform hands the link over once. The second copy comes from the recreation of the screen and from reading the link again at launch. The source is the lifecycle of the screen, not the platform.

**"The handled marker can be kept only in memory."** A marker in memory protects against rotation, but it disappears when the process is ended. In the measurement the difference was only the value of `kept`, and its cost was two duplicated links. The marker should be kept in saved state or in a durable store.

**"Deduplication increases the risk of loss."** Deduplication only does not process the same sequence a second time. It does not put links with different sequences in place of one another. Deduplication with the wrong key, however, counts two different products as the same and discards the second after the first; this is a loss caused by an app error, not a superseded one.

**"A shortcut and a link are the same thing."** Both carry an `intent`, but their owners differ. Someone from outside produces the link and the app must check its content; the app itself defines the shortcut and can trust its content more. As far as duplication goes, the two behave the same.

**"A duplicated link is harmless."** Opening a product screen twice is only a visual flaw; but if the link triggers a payment or a confirmation, the second copy is a second operation and is hard to undo.

#### The Bounding Measurement

If the marker is kept only in memory, the memory is erased when the process is ended. The system can end the app in the background under memory pressure, and when the user returns the screen is recreated. Because the marker in memory is gone, the same link is processed again.

```kotlin
// Main.kt — if the marker is kept in memory, the link is duplicated again after the process ends
fun main() {
    val sent = (9..12).map { Signal("product.${it * 2}", it) }
    val arrived = redeliver(sent, again = listOf(10, 11))
    val received = dedupe(arrived, kept = false, restartAt = 4)
    val arrivals = sent.map { Arrival("event-${it.seq}", it, received) }
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-9: key=product.18 | copies=1 -> once
event-10: key=product.20 | copies=2 -> duplicated
event-11: key=product.22 | copies=2 -> duplicated
event-12: key=product.24 | copies=1 -> once
once: 2
duplicated: 2
superseded: 0
lost: 0
```

The input is the same as in the previous measurement, but because the marker stayed in memory, links 10 and 11 were processed again after the restart and became duplicated. The difference is only in the value of `kept`. A marker kept in memory protects against rotation; it does not protect against the process ending.

**SC4.** The model builds the end of the process at a single point. On the platform the end can happen at any moment, and the restoring of saved state varies with the type of ending; these are not in the model.

#### Summary

- This lesson's delivery: duplicated. When the screen is recreated, the same deep link is processed twice.
- Of four links, two arrived as two copies: duplicated 2, once 2.
- When the handled marker was kept in saved state, all four links arrived once.
- When the marker was kept in memory, two links became duplicated again after the process ended: duplicated 2.
- The two links of the unverified domain did not reach the app: once 2, lost 2. The app does not see this as an error.
- The verified app link confirms that the owner of the address ties the domain to the app; the shortcut carries an `intent` that leads to a screen.

#### Next Step

In this lesson the event came from outside and the same event was processed twice. In a widget the event is the update of a small view on the home screen, and if the view does not exist at all, the update finds no receiver. The next lesson, `Widgets`, measures the last update that is drawn when the widget appears and a widget that has been removed from the home screen.

### Widgets

Source: https://academia.sh/en/courses/platform-services-and-integrations/widgets

A widget is a small view that lives on the home screen and holds data updates until the widget is opened. The lesson measures that of the accumulated updates only the last one is drawn, and that updates do not reach a widget that has been removed from the home screen.

In the previous lesson the event came from outside and the screen could process it one more time. In a widget the update is born inside the app, but the view that will draw it lives on the home screen, outside the app's own process. When the app updates the data, the view may not be drawn at that moment. In this lesson we ask the same question again: **how many copies of this sent event reached the receiver?**

```kotlin
// 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 classification is the same as in the previous lessons. The rule counts the copies in the receiver's list that carry the same sequence number; if there is no copy, it checks whether a later event with the same key arrived. It does not see the content or the cause.

#### The Provider and the Layout

A widget has three parts. The definition in the manifest introduces the widget to the platform, the layout says how the view is drawn, and the widget provider receives the update requests. The provider is called when the platform asks for an update, and it gives the new view back to the platform.

```kotlin
// Platform code: WeatherWidget
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.widget.RemoteViews

class WeatherWidget : AppWidgetProvider() {
    override fun onUpdate(context: Context, manager: AppWidgetManager, ids: IntArray) {
        for (id in ids) {
            val views = RemoteViews(context.packageName, R.layout.widget_weather)
            views.setTextViewText(R.id.temperature, latestTemperature())
            manager.updateAppWidget(id, views)
        }
    }
}
```

`onUpdate` writes no number: the refresh interval is given in the definition of the widget, and the platform treats this interval as a request. This snippet claims no output or timing. The model builds this rule of the platform; the model is not the platform itself.

The postponement of an update is not a separate delivery. In M12/K04 the question was whether a job ran or was postponed; in this course, if a postponed update finally arrives it is once, and if it does not arrive it is lost. The silent channel in M12/K01 is not a loss either: a notification on a silent channel arrives and is once, it only makes no sound.

#### The Refresh Interval and Drawing

A refresh interval is written in the definition of the widget, but this interval is a request. To save battery, the platform can lengthen the interval on its own, and it pulls an interval given as very short up to a certain lower limit. The app does not have to settle for this interval: when the data changes it can trigger the update itself. The difference between the two ways is who keeps the clock. In a widget refreshed by interval the platform keeps the clock, and the view waits for the next interval even if the data has changed. In an update triggered by the app the clock is in the app, and the widget is refreshed the moment the data changes.

Its relation to delivery is this: the home screen does not always draw the widget. If the widget is not visible, for example because it is on another home screen page or the screen is off, the platform only stores the view. When the widget becomes visible, the last stored view is drawn. The views in between are not drawn, and a later view that takes the place of the same widget means superseded. This is the platform counterpart of the model's rule that "of the updates accumulated until the widget opens, only the last one is drawn." For this reason it is wrong to expect every intermediate state to be shown in a widget; the goal is to show the correct state when it becomes visible.

#### The Last Update Drawn When the Widget Opens

In the model each data update is a `Signal`, and its key is the name of the widget. When the widget is not open, the app gives the updates one after another. When the widget opens, only the last state is drawn; each update that comes while it is open is drawn one by one. The step at which the widget opens is a made-up value of the model.

```kotlin
// Drawn.kt — of the updates accumulated until the widget opens, only the last of each key is drawn
fun drawn(sent: List<Signal>, openAt: Int): List<Signal> {
    val before = sent.filter { it.seq < openAt }
    val waiting = before.filter { s -> before.none { it.key == s.key && it.seq > s.seq } }
    return waiting + sent.filter { it.seq >= openAt }
}
```

```kotlin
// Main.kt — of the updates accumulated until the widget opens, only the last is drawn
fun main() {
    val sent = (1..6).map { Signal("widget.weather", it) }
    val received = drawn(sent, openAt = 4)
    println("received seqs: ${received.map { it.seq }}")

    val arrivals = sent.map { Arrival("event-${it.seq}", it, received) }
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
received seqs: [3, 4, 5, 6]
event-1: key=widget.weather | copies=0 -> superseded
event-2: key=widget.weather | copies=0 -> superseded
event-3: key=widget.weather | copies=1 -> once
event-4: key=widget.weather | copies=1 -> once
event-5: key=widget.weather | copies=1 -> once
event-6: key=widget.weather | copies=1 -> once
once: 4
duplicated: 0
superseded: 2
lost: 0
```

This lesson's delivery is **superseded**. The widget opened at step 4. Updates 1 and 2 were not drawn, because update 3 with the same key took their place. Updates 4, 5 and 6, which came while the widget was open, each arrived once.

**SC1.** The model counts all the updates before the opening as a single waiting set. On the platform the refresh of a widget depends on the platform's own timing, and how long the waiting updates are held is not in the model.

#### Two Widgets

If two widgets live on the home screen, the key of each belongs to itself. The update of one does not take the place of the other; superseding happens only among updates with the same key.

```kotlin
// Main.kt — with two widgets, superseding happens only within the same key
fun main() {
    val sent = listOf(
        Signal("widget.weather", 7),
        Signal("widget.calendar", 8),
        Signal("widget.weather", 9),
        Signal("widget.calendar", 10),
        Signal("widget.weather", 11),
        Signal("widget.calendar", 12),
    )
    val received = drawn(sent, openAt = 10)
    println("received seqs: ${received.map { it.seq }}")

    val arrivals = sent.map { Arrival("event-${it.seq}", it, received) }
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
received seqs: [8, 9, 10, 11, 12]
event-7: key=widget.weather | copies=0 -> superseded
event-8: key=widget.calendar | copies=1 -> once
event-9: key=widget.weather | copies=1 -> once
event-10: key=widget.calendar | copies=1 -> once
event-11: key=widget.weather | copies=1 -> once
event-12: key=widget.calendar | copies=1 -> once
once: 5
duplicated: 0
superseded: 1
lost: 0
```

The widget opened at step 10. Of the three updates before the opening, only the weather update 7 was superseded: update 9 took its place. The calendar update 8 arrived once, because there was no other update with the same key.

**SC2.** The model builds the two widgets independently of each other. On the platform the same provider can serve more than one widget, and which instance an update goes to depends on its identifier number; this is not in the model.

#### The Configuration Screen

Some widgets ask the user for a setting: which city's weather, which note to show. When the user adds the widget to the home screen, the platform first opens the configuration screen of the app and holds the widget until the user completes the setting. Updates that come before the configuration is finished have no setting to draw, and they drop. In the model below, the widget that shows a note is configured at step 23, while the weather widget is ready from the start.

```kotlin
// Configured.kt — drops updates that come before the configuration is finished
fun configured(sent: List<Signal>, key: String, doneAt: Int): List<Signal> =
    sent.filter { it.key != key || it.seq >= doneAt }
```

```kotlin
// Main.kt — updates that come before the configuration is finished are not drawn
fun main() {
    val sent = listOf(
        Signal("widget.note", 19),
        Signal("widget.weather", 20),
        Signal("widget.note", 21),
        Signal("widget.weather", 22),
        Signal("widget.note", 23),
    )
    val received = configured(sent, key = "widget.note", doneAt = 23)
    println("received seqs: ${received.map { it.seq }}")

    val arrivals = sent.map { Arrival("event-${it.seq}", it, received) }
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
received seqs: [20, 22, 23]
event-19: key=widget.note | copies=0 -> superseded
event-20: key=widget.weather | copies=1 -> once
event-21: key=widget.note | copies=0 -> superseded
event-22: key=widget.weather | copies=1 -> once
event-23: key=widget.note | copies=1 -> once
once: 3
duplicated: 0
superseded: 2
lost: 0
```

The two updates of the weather widget arrived once. Updates 19 and 21 of the note widget were not drawn, because the configuration finished at step 23 and update 23, which came with the same key, took their place. The rule counted these as superseded; from the user's point of view, however, they were updates sent to a widget that had no setting yet, and so they carried no meaning at all. If no update with the same key had come after the configuration finished, the same two updates would have been counted as lost.

**SC3.** The model assumes the configuration finishes in a single step and for certain. On the platform the user can give up the configuration screen, and in that case the widget is never added; also, how the platform treats update requests while the configuration screen is open is not in the model.

#### Why Every Update Carries the Whole View

A widget is drawn not in the process of the app but in the process of the home screen app. The app cannot change the view directly; instead it produces a recipe for the view, and the platform passes this recipe to the home screen. The recipe is usually given not as the change of a single field but as the new form of the whole view. As a result each update replaces the previous one completely.

This is the natural place of the superseded delivery in this lesson. Two updates cannot be merged, because the second update is not added on top of the first, it takes its place. Between a first view containing a temperature value and a second view containing a humidity value, it is not possible to keep the first and add the second. If the second view does not contain the temperature, the temperature becomes invisible in the widget. For this reason every update must carry the whole state to be shown.

The same rule tells the app this too: the superseding of an update is not a loss of information, as long as the next update carries the whole state. The case where information is lost is the one where the next update carries only the changed field. The model does not tell these apart; it counts every update as a sequence with the same key and says superseded even if the successor carries something incomplete. Whether the successor is complete is a design decision of the app.

#### What Happens When the Size Changes

When the user resizes the widget, the platform tells the provider the new size and expects a new view from the app. This call is an update request and does not depend on the interval; it comes whenever the user changes the size. If the app does not produce a recipe suited to the new size, the old recipe may not fit the new size: the text is cut off, the image overflows, or the widget looks empty.

This is a second source of the superseded delivery. The update from the interval and the update from the size carry the same key, because both are the state of the same widget. If an update from the interval comes right after the size change, only the last of the two is drawn. If the last one does not know the new size, the user sees that the resizing had no effect. For this reason every recipe must be produced according to the current size; an app that reads the size once and stores it uses the old size in later updates.

#### Common Misconceptions

**"The refresh interval is a promise."** The interval is a request. The platform can lengthen it, and if the widget is not visible it only stores the view. An app that expects every update to be drawn finds superseded updates, as in the first block of the measurement.

**"A superseded update is an error."** For a widget the last state is worth more than the old one. An intermediate state that is not drawn is something the user would never see, and it shows the design, not a loss. The error is the case where the last state is never drawn: that is a loss.

**"A postponed update is a separate delivery."** This course has four deliveries. A postponed update is once if it arrives, superseded if it is replaced by the next update, and lost if it never arrives. Postponement is not a type of delivery; it is a property of the time of arrival.

**"Sending updates to a removed widget is harmless."** The update does no harm but it is in vain: there is no view left to draw. To stop these updates, which spend battery and reach nowhere, the app must listen for the removal call.

**"A widget cannot be updated while the app is closed."** The provider works regardless of whether the app is in the foreground. The platform wakes the provider for the update, and when the work is done the process can go back to sleep. For this reason the data needed for the update must be ready in the call of the provider or must be obtainable quickly.

**"Every widget shows the same view."** Two widgets added from the same provider can have different sizes and different settings; each has its own key and its own sequence of updates.

#### The Bounding Measurement

When the user removes the widget from the home screen, the provider is no longer a receiver of updates. The app keeps producing updates, but there is no view left to draw.

```kotlin
// Removed.kt — updates do not reach a widget removed from the home screen
fun removed(drawn: List<Signal>, removedAt: Int): List<Signal> =
    drawn.filter { it.seq < removedAt }
```

```kotlin
// Main.kt — updates do not reach a widget removed from the home screen
fun main() {
    val sent = (13..18).map { Signal("widget.weather", it) }
    val received = removed(drawn(sent, openAt = 15), removedAt = 17)
    println("received seqs: ${received.map { it.seq }}")

    val arrivals = sent.map { Arrival("event-${it.seq}", it, received) }
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
received seqs: [14, 15, 16]
event-13: key=widget.weather | copies=0 -> superseded
event-14: key=widget.weather | copies=1 -> once
event-15: key=widget.weather | copies=1 -> once
event-16: key=widget.weather | copies=1 -> once
event-17: key=widget.weather | copies=0 -> lost
event-18: key=widget.weather | copies=0 -> lost
once: 3
duplicated: 0
superseded: 1
lost: 2
```

Update 13 was superseded, and 14, 15 and 16 arrived. Because the widget was removed before step 17, updates 17 and 18 were lost: no update with the same key arrived after them. The rule does not see that the cause of the loss is the removed widget; to know this, the app must separately ask which widget identifiers are alive.

**SC4.** The model builds the removal as immediate and in a single step. On the platform, the delete call for a removed widget reaches the provider, and the app can stop the updates at that point; this is not in the model.

#### Summary

- This lesson's delivery: superseded. Of the updates accumulated until the widget opens, only the last of each key is drawn.
- Of six updates, two were superseded and four arrived once: superseded 2, once 4.
- With two widgets, superseding happened only within the same key: one update was superseded and five arrived once.
- Two updates were lost to the widget removed from the home screen: lost 2.
- A postponed update is not a separate category; a notification on a silent channel arrives and is once.
- Two updates of the widget whose configuration was not finished were counted as superseded because of the update that came with the same key after the end: once 3, superseded 2.

#### Next Step

With this lesson we have reached the end of the system capabilities: the notification, cloud messaging, location, camera, sensors, the deep link and the widget were measured with the same question. The next topic discusses the app's own data and keys: the lesson `Encrypted Storage and the Keystore` deals with whose hands the stored data and the key can fall into.

## Security and External Services

Encrypted storage and the Keystore, network security configuration, authentication flows, app signing, analytics and crash reporting, remote configuration, and in-app purchases and ads.

### Encrypted Storage and the Keystore

Source: https://academia.sh/en/courses/platform-services-and-integrations/encrypted-storage-and-keystore

A secret the app writes is encrypted with a key in the keystore; if the key is bound to user authentication, it is invalidated when the lock screen is removed and the record can no longer be read. The lesson measures how many copies of a written secret reach the next read.

In the previous lesson, events travelled inside the device as far as a screen. In this lesson the event does not go to a screen but to a store: the app produces a secret, saves it, and reads it back in the next session. The writer and the reader are the same app, but a span of time and a key sit between them. In the course's opening lesson, the distribution of eight events was measured as exactly 3 once, 1 duplicated, 2 superseded, and 2 lost. The question in this lesson is the same: **how many copies of this sent event reached the receiver?** The event is the secret that was written, and the receiver is the next read.

```kotlin
// 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 looks at the copy count. If one copy with the same `seq` arrived, the delivery is **once**, and if more than one arrived, it is **duplicated**. If no copy arrived and one with a greater `seq` arrived under the same key, the event is **superseded**. If neither is the case, it is **lost**. The rule does not see the content, the cause, or whether the record is encrypted.

#### The Keystore and the Alias

The platform's keystore (`KeyStore`) keeps encryption keys apart from the app's memory and files. The app never sees the key itself; it refers to it by a **key alias** and asks for an operation without seeing the key. An alias is like the row header of a registry: one key is found under the same name, and every encrypted record knows which alias's key it was encrypted with.

A condition can be attached when the key is generated: the key can be used only after the user has been authenticated.

```kotlin
// Platform code: KeyGenParameterSpec
val spec = KeyGenParameterSpec.Builder(
    "lock",
    KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
    .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
    .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
    .setUserAuthenticationRequired(true)
    .build()

val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore")
generator.init(spec)
val key = generator.generateKey()
```

`setUserAuthenticationRequired(true)` binds the key to user authentication. According to the platform's documentation, such a key is irreversibly invalidated when the secure lock screen is removed or forcibly reset. Code that starts an operation with an invalidated key receives an exception.

```kotlin
// Platform code: KeyPermanentlyInvalidatedException
try {
    cipher.init(Cipher.DECRYPT_MODE, key)
} catch (e: KeyPermanentlyInvalidatedException) {
    keyStore.deleteEntry("lock")
}
```

Encrypted preference storage wraps this key in a ready-made shell. The app writes the value, and the shell encrypts it using the key.

```kotlin
// Platform code: EncryptedSharedPreferences
val masterKey = MasterKey.Builder(context)
    .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
    .build()

val prefs = EncryptedSharedPreferences.create(
    context,
    "secrets",
    masterKey,
    EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
    EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
)
prefs.edit().putString("secret.card", "secret-value").apply()
```

This encrypted preference library is deprecated and should not be copied into new code. The current path is to generate the key directly in the keystore and have the app encrypt the data itself, or to use the data store together with an encryption library.

These three pieces are platform code, and here we make no claim about output, duration, or counts. The model builds the platform's rule; the model is not the platform itself. In the model, the vault ties records to an alias, and when the key of an alias is invalidated, the records of that alias can no longer be read.

```kotlin
// Vault.kt — ties records to a key alias, hides the records of an invalidated key
class Vault {
    private val records = mutableListOf<Pair<String, Signal>>()
    private val invalid = mutableSetOf<String>()

    fun write(alias: String, secret: Signal) {
        records.add(alias to secret)
    }

    fun invalidateKey(alias: String) {
        invalid.add(alias)
    }

    fun migrate(from: String, to: String) {
        records.filter { it.first == from }.forEach { records.add(to to it.second) }
    }

    fun readAll(): List<Signal> = records.filter { it.first !in invalid }.map { it.second }
}
```

The receiver is the return value of `readAll`. In each measurement the app writes the secrets and then reads them back.

```kotlin
// Probe.kt — counts the list read from the vault as the receiver for the secrets that were sent
fun probe(vault: Vault, sent: List<Signal>): List<Arrival> {
    val received = vault.readAll()
    return sent.mapIndexed { i, s -> Arrival("event-${i + 1}", s, received) }
}
```

#### While the Key Is Valid

Three secrets are written under the same alias, and the key is left untouched. As long as the key is valid, a read returns every record.

```kotlin
// Main.kt — as long as the key is valid, every record is read
fun main() {
    val vault = Vault()
    val sent = listOf(Signal("secret.session", 1), Signal("secret.card", 2), Signal("secret.note", 3))
    sent.forEach { vault.write("lock", it) }
    val arrivals = probe(vault, sent)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=secret.session | copies=1 -> once
event-2: key=secret.card | copies=1 -> once
event-3: key=secret.note | copies=1 -> once
once: 3
duplicated: 0
superseded: 0
lost: 0
```

All three writes are read. The assurance that encrypted storage provides is not present here: the vault does not model whether a record is encrypted, only whether it can be read.

#### When the Lock Screen Is Removed

Four secrets sit under two aliases. Some are encrypted with the `lock` key, which is bound to user authentication, and some with the `general` key, which is not. The user removes the lock screen and the `lock` key is invalidated.

```kotlin
// Main.kt — the records of an invalidated key cannot be read
fun main() {
    val vault = Vault()
    val sent = listOf(
        Signal("secret.session", 1),
        Signal("secret.card", 2),
        Signal("secret.note", 3),
        Signal("secret.setting", 4),
    )
    vault.write("general", sent[0])
    vault.write("lock", sent[1])
    vault.write("lock", sent[2])
    vault.write("general", sent[3])
    vault.invalidateKey("lock")
    val arrivals = probe(vault, sent)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=secret.session | copies=1 -> once
event-2: key=secret.card | copies=0 -> lost
event-3: key=secret.note | copies=0 -> lost
event-4: key=secret.setting | copies=1 -> once
once: 2
duplicated: 0
superseded: 0
lost: 2
```

The records stay on disk, but the read does not return them. The rule counts this as lost: the event was written, no copy reached the receiver, and no greater `seq` arrived under the same key either. This lesson's delivery: lost. The reason is that a record existing and a record being readable are not the same thing.

#### When a Migration Leaves Copies Behind

The app wants to move the records that are not bound to user authentication to a new alias. The migration copies the records to the new name and does not delete the old ones. Because the old key stays valid, the records under both names can be read.

```kotlin
// Main.kt — if the migration does not delete the old record, a read returns two copies
fun main() {
    val vault = Vault()
    val sent = listOf(Signal("secret.session", 1), Signal("secret.card", 2), Signal("secret.note", 3))
    vault.write("general", sent[0])
    vault.write("general", sent[1])
    vault.write("lock", sent[2])
    vault.migrate("general", "general-2")
    val arrivals = probe(vault, sent)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=secret.session | copies=2 -> duplicated
event-2: key=secret.card | copies=2 -> duplicated
event-3: key=secret.note | copies=1 -> once
once: 1
duplicated: 2
superseded: 0
lost: 0
```

The two migrated secrets arrived as two copies. If the read code does not merge these copies, the app sees the same secret twice. A duplicated result is a sign that the migration was left half done; the rule only gives the count.

**SE1.** The model associates each record with the key of a single alias. On the platform, a record also depends on the version of the key, the encryption mode, and the initialization vector; these are not in the model.

**SE2.** The model builds the invalidation of a key as a single call. On the platform, invalidation is noticed when the key is about to be used after an event such as the lock screen being removed or forcibly reset.

#### The Limits of Invalidation

To understand why invalidation produces a loss, three distinctions have to be kept apart. First, the key and the record are separate things: the record sits in the app's file, while the key sits in the keystore. When the key is gone, the record stays where it is, but nothing is left that can open it. Second, even though the app created the key, it cannot bring it back; it can only generate a new key under the same alias, and the new key does not open the old record. Third, invalidation is not an event under the app's control. What triggers it is something the user does with the lock screen, and the app notices only at the next read, when it tries to use the key.

The counterpart of these three distinctions in the model is simple. The vault marks each record with an alias. `invalidateKey` counts an alias as invalid. `readAll` does not return the records of invalid aliases. In the model the content of a record is never visible; the rule looks only at the returned list.

Here the difference between what the platform says and what the model says has to stay clear. The platform documents that a key bound to user authentication is invalidated when the lock screen is removed. Under which other conditions a key can be invalidated depends on the key's generation parameters, and this is not measured in this lesson. The model knows only the `invalidateKey` call.

#### The Bounding Measurement

The vault does not carry the cause of a loss. When the lock screen is removed, the `lock` key is invalidated and two secrets become unreadable. The app notices this, generates a new key, and rewrites only the card: the same key name `secret.card`, this time with a new `seq` and a new alias.

```kotlin
// Main.kt — a secret rewritten under the same key supersedes the old record
fun main() {
    val vault = Vault()
    val sent = listOf(
        Signal("secret.session", 1),
        Signal("secret.card", 2),
        Signal("secret.note", 3),
        Signal("secret.card", 4),
    )
    vault.write("general", sent[0])
    vault.write("lock", sent[1])
    vault.write("lock", sent[2])
    vault.invalidateKey("lock")
    vault.write("lock-2", sent[3])
    val arrivals = probe(vault, sent)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=secret.session | copies=1 -> once
event-2: key=secret.card | copies=0 -> superseded
event-3: key=secret.note | copies=0 -> lost
event-4: key=secret.card | copies=1 -> once
once: 2
duplicated: 0
superseded: 1
lost: 1
```

Both of the unreadable secrets are unreadable for the same reason, because of the same invalidated key. Even so, by the rule one is superseded and the other is lost. `secret.card` is superseded because a greater `seq` arrived under the same key; `secret.note` is lost because no such copy exists. The rule does not see the cause of the loss; it sees only whether a copy that took its place exists.

This distinction matters from the app's point of view. A superseded secret does not count as gone for the user; its new version can be read. A lost secret, on the other hand, is data that has to be entered again. The rule separates the two but does not say which of them costs the user more.

A loss can turn into superseded through the user's action. After the invalidation, the user enters the card and the note again; the app writes both under the new alias with new `seq` values.

```kotlin
// Main.kt — if the user re-enters the secrets, the losses turn into superseded
fun main() {
    val vault = Vault()
    val sent = listOf(
        Signal("secret.session", 1),
        Signal("secret.card", 2),
        Signal("secret.note", 3),
        Signal("secret.card", 4),
        Signal("secret.note", 5),
    )
    vault.write("general", sent[0])
    vault.write("lock", sent[1])
    vault.write("lock", sent[2])
    vault.invalidateKey("lock")
    vault.write("lock-2", sent[3])
    vault.write("lock-2", sent[4])
    val arrivals = probe(vault, sent)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=secret.session | copies=1 -> once
event-2: key=secret.card | copies=0 -> superseded
event-3: key=secret.note | copies=0 -> superseded
event-4: key=secret.card | copies=1 -> once
event-5: key=secret.note | copies=1 -> once
once: 3
duplicated: 0
superseded: 2
lost: 0
```

`secret.note`, which was lost in the previous measurement, is now superseded, because a greater `seq` arrived to take its place. The old records that cannot be read stay where they are; the count changed because the rule now sees a copy that took its place. Having the user enter the data again is a way to recover secrets that can be regenerated. For a secret that cannot be entered again, the same measurement stays lost.

**SE3.** The model does the rewrite under a new alias and does not lay the new secret over the old one. On the platform, it is also possible to generate a new key under the same alias; the model does not build this distinction.

#### What the Reading Code Has to Do

What the receiving side has to do follows from the measurement. If the result of a read is shorter than expected, it is accepted that this is a delivery outcome and not an error, and three things are done.

- **Recognize what is missing.** The reading code must know which secrets it expects. A loss is not noticed without comparing the expected list with the list that was read. In the model, `probe` does this comparison.
- **Merge the copies.** If more than one record comes back for the same key, the one with the greatest `seq` is taken. Because the transport can leave copies behind, the reading code does not treat a copy as an error, but it does not process it twice either.
- **Separate what can be regenerated.** A session key that can be fetched again from the server and a password that only the user knows are not in the same loss class. The first is renewed silently, the second is asked from the user. This distinction has to be made at write time, before the secret enters the store.

An empty result from a read and an error from a read are also handled separately. An empty result can say that no record was ever written, while an error can say that the record exists but could not be opened; code that puts both in the same branch mistakes an invalidation for a first launch.

There is also something the reading code must not do: silently delete a record that cannot be read. If the record is deleted, the next measurement can no longer tell superseded from lost; no trace is left.

#### Common Misconceptions

**An encrypted record can always be read.** Encryption protects the record, but it does not guarantee that it can be read. If the key is lost, the record is protected and unreachable. Two measurements showed this: while the key was valid, three records were once, and after it was invalidated, two records were lost.

**A record that sits on disk has been delivered.** Delivery is the copy that reaches the receiver. An unreadable record on disk does not reach the receiver. That is why the rule looks not at the bytes on disk but at what the read returns.

**Superseded is bad news.** Superseded is often good news, because its replacement has arrived. What is bad is when the superseding copy never arrives; in that case the same event is counted as lost. The last measurement shows the two side by side.

**A duplicated result means the data is corrupted.** Duplicated only means the same copy arrived more than once. A copy left behind during transport is not corruption; it is a cleanup that was not finished.

**If the record is backed up, the key is backed up too.** It is not. The record is only encrypted bytes; the key that opens it stays separate from the record. In a setup that carries the record but not the key, a read gives the same result as the invalidation in the measurement: the records exist, they are not returned, and the rule counts them as lost. The platform's backup behavior is not measured in this lesson.

**Regenerating the key brings the record back.** It does not. Generating a new key under the same alias does not open a record encrypted with the old key; it only leaves a working key for the records that will be written from now on.

#### Summary

- This lesson's delivery: lost. When the lock screen is removed, the key bound to user authentication is invalidated, and a record encrypted with that key cannot be read.
- The key alias ties the record to the key; the records under the same alias become unreadable together.
- With a valid key, all three writes arrive as once. When the two secrets bound to user authentication are invalidated, the result is lost 2, once 2.
- If the migration does not delete the old record, two secrets are read as two copies: duplicated 2, once 1.
- A secret rewritten under the same key makes the old record superseded; a secret that is not rewritten is lost. If the user enters both secrets again, the result is superseded 2, once 3. The rule sees the copies, not the cause.

#### Next Step

In this lesson the record was written to disk and could not be read once the key was invalidated. In the next lesson the event goes out to the network: the app sends a request, but if the request is blocked as cleartext, no copy reaches the server. Network security configuration writes the rule for this block.

### Network Security Configuration

Source: https://academia.sh/en/courses/platform-services-and-integrations/network-security-configuration

Network security configuration decides on cleartext traffic, trusted certificate sources, and domain-specific rules. The lesson measures how many copies of a request blocked as cleartext reached the server, and the outcome of a connection that does not match a pinned certificate.

In the previous lesson the secret sat on disk, and what made it unreadable was a key. In this lesson the event leaves the device: the app sends a request to a server. Before the request goes out to the network, it passes through the platform's configuration, and that configuration can block it. The question is the course's question: **how many copies of this sent event reached the receiver?** The event is the request, and the receiver is the server.

```kotlin
// 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 that reach the server. It does not know why a request did not arrive, which rule blocked it, or whether the block was correct.

#### The Configuration's Three Decisions

Network security configuration is an XML file, and the app's manifest refers to it by name. It decides three things. The first is **cleartext traffic**: whether a request may go to an address that starts with `http://`. The second is the **trust anchor**: which root certificates the server's certificate chain is verified against. The third is **certificate pinning**: for a given domain, only a connection that matches previously declared certificate digests is accepted.

```xml
<!-- Platform code: network-security-config -->
<network-security-config>
    <base-config cleartextTrafficPermitted="false">
        <trust-anchors>
            <certificates src="system" />
        </trust-anchors>
    </base-config>
    <domain-config cleartextTrafficPermitted="true">
        <domain includeSubdomains="false">local.test</domain>
    </domain-config>
    <domain-config>
        <domain includeSubdomains="true">api.example.test</domain>
        <pin-set>
            <pin digest="SHA-256">AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=</pin>
        </pin-set>
    </domain-config>
</network-security-config>
```

This piece is platform configuration; it is not run, and here we make no claim about output. The digest value is an example placeholder. `base-config` writes the default for all traffic: cleartext off, trust anchors set to the system's certificates. The first `domain-config` opens a single address, `local.test`, to cleartext. The second pins the certificate digest for `api.example.test` and its subdomains.

The model builds the platform's rule; the model is not the platform itself. In the model, a policy holds the set of addresses open to cleartext and an optional pinned certificate digest. If the request is not HTTPS, only the open addresses pass. If it is HTTPS and the policy has pinned a digest, the certificate on the request must match it.

```kotlin
// Policy.kt — applies the cleartext and pinning rules to requests
class Request(val signal: Signal, val url: String, val cert: String = "shared")

class NetworkPolicy(
    private val cleartextHosts: Set<String> = emptySet(),
    private val pin: String? = null,
) {
    private fun host(url: String) = url.substringAfter("://").substringBefore("/")

    fun passes(request: Request): Boolean {
        if (!request.url.startsWith("https://")) return host(request.url) in cleartextHosts
        return pin == null || request.cert == pin
    }
}

fun rejectCleartext(policy: NetworkPolicy, requests: List<Request>): List<Signal> =
    requests.filter { policy.passes(it) }.map { it.signal }
```

The list that reaches the server is the return value of `rejectCleartext`. In the measurements, this list gives the copy count.

```kotlin
// Probe.kt — counts the list that reached the server as the receiver for the requests that were sent
fun probe(policy: NetworkPolicy, requests: List<Request>, sent: List<Signal>): List<Arrival> {
    val received = rejectCleartext(policy, requests)
    return sent.mapIndexed { i, s -> Arrival("event-${i + 1}", s, received) }
}
```

#### The Two Gates of a Request

The `passes` function sends a request through two gates, and the gates are independent of each other. The first gate looks at the form of the address. If the request does not start with `https://`, the decision depends entirely on the cleartext list: if the address is on the list, the request passes, and if not, it drops. In this branch the certificate is never looked at, because a cleartext request has no certificate.

The second gate works only for HTTPS requests. If the policy has not pinned a digest, the request passes; if it has, the request's certificate must match that digest. Here the cleartext list carries no meaning: if an address on the list is reached over HTTPS, pinning is still checked.

This distinction has two consequences. First, pinning does not protect a cleartext request; an `http://` request going to an open address is not compared with any digest. Second, lifting the cleartext block does not lift pinning; the two decisions are written separately and give results separately. In the measurements, a loss sometimes comes from the first gate and sometimes from the second, and the rule does not tell them apart: it sees only that no copy reached the server.

On the platform, the order in which rules are applied and the details of domain matching depend on how the configuration file is written; what this lesson measures is only the model's two gates.

#### When Everything Is HTTPS

All three requests go to HTTPS addresses. The policy has no open address and no pinned digest.

```kotlin
// Main.kt — https requests are not caught by the block
fun main() {
    val sent = listOf(Signal("order.41", 1), Signal("order.42", 2), Signal("stock.7", 3))
    val requests = listOf(
        Request(sent[0], "https://api.example.test/order"),
        Request(sent[1], "https://api.example.test/order"),
        Request(sent[2], "https://api.example.test/stock"),
    )
    val arrivals = probe(NetworkPolicy(), requests, sent)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=order.41 | copies=1 -> once
event-2: key=order.42 | copies=1 -> once
event-3: key=stock.7 | copies=1 -> once
once: 3
duplicated: 0
superseded: 0
lost: 0
```

All three requests reached the server as one copy each. When cleartext is off, HTTPS traffic is not caught by this block.

#### A Request Blocked as Cleartext

Two of the four requests start with `http://`. One of them goes to `local.test`, which is in the open address set; the other does not.

```kotlin
// Main.kt — a cleartext request is blocked outside the open address
fun main() {
    val sent = listOf(
        Signal("order.41", 1),
        Signal("order.42", 2),
        Signal("stock.7", 3),
        Signal("rate.3", 4),
    )
    val requests = listOf(
        Request(sent[0], "http://api.example.test/order"),
        Request(sent[1], "https://api.example.test/order"),
        Request(sent[2], "http://local.test/stock"),
        Request(sent[3], "http://rate.example.test/rate"),
    )
    val arrivals = probe(NetworkPolicy(setOf("local.test")), requests, sent)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=order.41 | copies=0 -> lost
event-2: key=order.42 | copies=1 -> once
event-3: key=stock.7 | copies=1 -> once
event-4: key=rate.3 | copies=0 -> lost
once: 2
duplicated: 0
superseded: 0
lost: 2
```

The two blocked requests never reached the server. This lesson's delivery: lost. The request was written and sent, but before it went out to the network, the platform's configuration stopped it. Because the `local.test` address was opened with a domain-specific rule, that request arrived once. The rule does not see the difference between `http://` and `https://`, only the outcome.

#### When a Retry Leaves Copies

A network client can resend a request that timed out. The request goes twice over HTTPS, and both pass through the configuration.

```kotlin
// Main.kt — if a retried request passes twice, the server receives two copies
fun main() {
    val sent = listOf(Signal("order.41", 1), Signal("order.42", 2), Signal("stock.7", 3))
    val requests = listOf(
        Request(sent[0], "https://api.example.test/order"),
        Request(sent[1], "https://api.example.test/order"),
        Request(sent[1], "https://api.example.test/order"),
        Request(sent[2], "http://api.example.test/stock"),
    )
    val arrivals = probe(NetworkPolicy(), requests, sent)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=order.41 | copies=1 -> once
event-2: key=order.42 | copies=2 -> duplicated
event-3: key=stock.7 | copies=0 -> lost
once: 1
duplicated: 1
superseded: 0
lost: 1
```

The configuration does not filter out the duplicated request: because both satisfy the same rules, both pass. Preventing duplication is the job of the client or the server. That the third event is lost is a separate matter: that request was cleartext, so it never passed and was not retried. In the same measurement, one event can come out duplicated and another lost.

#### The Scope of an Open Address

An address opened to cleartext covers only as much as the name that was written. In the XML example, `includeSubdomains="false"` is written for `local.test`; that is, only this name is freed, not the names beneath it. Its counterpart in the model is an exact match against the `cleartextHosts` set: if the set contains `local.test`, it does not contain `table.local.test`.

There are three requests. The first goes to the open address itself over cleartext. The second goes to a subdomain of the same name over cleartext. The third goes to that subdomain over HTTPS.

```kotlin
// Main.kt — the open address does not cover subdomains, https still passes
fun main() {
    val sent = listOf(Signal("table.1", 1), Signal("table.2", 2), Signal("table.3", 3))
    val requests = listOf(
        Request(sent[0], "http://local.test/table"),
        Request(sent[1], "http://table.local.test/table"),
        Request(sent[2], "https://table.local.test/table"),
    )
    val arrivals = probe(NetworkPolicy(setOf("local.test")), requests, sent)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=table.1 | copies=1 -> once
event-2: key=table.2 | copies=0 -> lost
event-3: key=table.3 | copies=1 -> once
once: 2
duplicated: 0
superseded: 0
lost: 1
```

The open address itself passed, its subdomain did not, and HTTPS was not caught by the cleartext rule whatever the address. Opening an address in a development environment and assuming its subdomains are open too is therefore a common mistake. The measurement shows it in three lines: the loss is only in the second request.

**SE1.** The model builds a retry as the same request appearing twice in the list. On the platform, a retry happens in the client's intermediate layer, and the configuration does not see it separately.

**SE2.** The model builds pinning with a single digest and for all HTTPS addresses. On the platform, pinning is written per domain and with more than one digest.

#### The Bounding Measurement

The policy has pinned a digest. There are four sends. The first request is blocked as cleartext. The app resends the same request, with the same key, to an HTTPS address. The third request is HTTPS, but the server's certificate does not match the pinned digest.

```kotlin
// Main.kt — a resent request supersedes the first, a pinning mismatch is lost
fun main() {
    val sent = listOf(
        Signal("order.41", 1),
        Signal("order.42", 2),
        Signal("stock.7", 3),
        Signal("order.41", 4),
    )
    val requests = listOf(
        Request(sent[0], "http://api.example.test/order"),
        Request(sent[1], "https://api.example.test/order", "pinned-a"),
        Request(sent[2], "https://api.example.test/stock", "other"),
        Request(sent[3], "https://api.example.test/order", "pinned-a"),
    )
    val arrivals = probe(NetworkPolicy(pin = "pinned-a"), requests, sent)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=order.41 | copies=0 -> superseded
event-2: key=order.42 | copies=1 -> once
event-3: key=stock.7 | copies=0 -> lost
event-4: key=order.41 | copies=1 -> once
once: 2
duplicated: 0
superseded: 1
lost: 1
```

The first request did not go out to the network; but a greater `seq` under the same key, that is, the resent request, reached the server. For this reason the rule counts the first request as superseded, not lost. `stock.7`, on the other hand, is lost because it does not match the pinned certificate, and since no other request reached the server under the same key, it is not superseded either. The cause of the first request is cleartext, and the cause of the third is a pinning mismatch. The rule does not tell these two causes apart: both mean no copy. The cause is learned only by looking at the configuration and the log.

**SE3.** The model builds the certificate mismatch with a label on the request. On the platform, a mismatch appears as the chain failing to verify during the handshake, and the connection fails with an error before the request is ever sent.

#### What the Client Has to Do

Because the block is at the configuration level, responsibility is spread over two places. The code that sends the request must know that the request may have dropped before it went out to the network. The person who writes the configuration must document which addresses are open.

- **Separate the block from a network error.** A cleartext block and a dropped connection appear to the client as different errors. If the code puts both into the same retry loop, it repeats a blocked request for nothing; the model shows this, because however many times a blocked request is tried, its copies stay zero.
- **Deduplicate the retry.** Because the configuration does not filter out copies, adding a repeat identifier to the request is the client's job. If the server sees the same identifier twice, it ignores the second. In this case the delivery is still duplicated, but the result is not repeated; the identifier limits how many times the result is processed, not how many times it is delivered.
- **Prepare pinning for renewal.** The model builds a single digest. If the server's certificate changes, until the digest is updated all HTTPS requests are lost. In a real configuration more than one digest can be written, but which digests will be written and when they will change is not what this lesson measures.
- **Do not show the error message to the user as is.** The text shown to the user for a request that dropped because of the configuration must not say the network is down. The network is up; the request was turned back by the configuration.

#### The Cost of Pinning

Pinning is a decision that raises security, but its cost is visible in the measurement. A pinned digest is tied to the server's current certificate, and when the certificate changes, the client and the server no longer expect the same thing. In the bounding measurement this appeared as the loss of a single request; in a real app, every HTTPS request going to the same domain is caught at the same gate, and the loss is not limited to a single request.

The cost is managed in two ways. First, the digest of the new certificate is added to the configuration before the certificate changes; the old and new certificates are then accepted together for a while. Second, the time it takes for the app to be updated is taken into account: the configuration ships with the app, while the certificate on the server can change immediately. If the user stays on an old version, the moment the new certificate arrives, all pinned requests in the old version are lost. Because the model in this lesson builds a single digest, it measures only this second case. The result is this: pinning gains security, but it ties the certificate renewal schedule to the configuration schedule. This tie requires, in the release plan, that a certificate change be prepared before the app version; otherwise, on the day the new certificate arrives, pinned requests in old versions silently drop.

#### Common Misconceptions

**Every HTTPS request reaches the server.** If pinning exists, it does not. In the bounding measurement, `stock.7` went over HTTPS, but because its certificate did not match the pinned digest, it was lost.

**The configuration prevents repeats.** It does not. When two copies of the same request satisfy the same rules, both pass. The duplicated measurement showed this: the configuration only allows or does not allow; it does not count how many times something passed.

**Opening an address opens its subdomains too.** The measurement above disproved this: an open address is an exact match. Covering subdomains is a separate setting, and this lesson's model does not build it.

**Pinning also protects a cleartext request.** It does not. A cleartext request has no certificate; in this branch the model never looks at the digest, and the request passes or drops only according to the address list.

**A blocked request is at least recorded somewhere.** There is no record on the server side, because the request never went to the server. A lost event leaves a trace only in the client's own log; this is why the client log is the only source for this kind of loss.

#### Summary

- This lesson's delivery: lost. A request blocked as cleartext never reaches the server.
- The configuration decides on cleartext traffic, the trust anchor, and certificate pinning; a domain-specific rule can free a single address.
- When two of four requests are cleartext, two requests arrive once and two are lost: once 2, lost 2.
- If a retried request passes twice, the server receives two copies; the configuration does not filter out duplication.
- When a request with the same key is resent over HTTPS, the first request becomes superseded. A pinning mismatch is lost; two causes are one category.

#### Next Step

The request can now go out to the network. But the server asks for an access token to accept the request, and when the token expires it has to be refreshed. In the next lesson, two concurrent requests get an unauthorized response at the same time and both start a refresh; the same refresh event reaches the server as two copies.

### Authentication Flows

Source: https://academia.sh/en/courses/platform-services-and-integrations/authentication-flows

Biometric authentication opens a gate on the device, while account-based sign-in gives the server an access token and a refresh token. The lesson measures how many copies reach the server when concurrent requests start the same refresh twice, and how single flight changes that.

In the previous lesson, the request could go out to the network. Before accepting a request, though, the server asks who sent it. This lesson deals with the two faces of that question: biometric authentication, which recognizes the user on the device, and account-based sign-in, which recognizes the user on the server. Where the two meet is tokens, and the problem arises there, when a token expires. The question is the course's question: **how many copies of this sent event reached the receiver?** The event is the refresh request, and the receiver is the server.

```kotlin
// 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 }}")
    }
}
```

#### Two Authentications, Two Tokens

Biometric authentication (`BiometricPrompt`) is local: a fingerprint or a face shows that the user is authorized to unlock the device, and it tells the server nothing. The app uses this result as a gate; for example, it reads the refresh token only after the authentication.

```kotlin
// Platform code: BiometricPrompt
val prompt = BiometricPrompt(
    activity,
    ContextCompat.getMainExecutor(activity),
    object : BiometricPrompt.AuthenticationCallback() {
        override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
            startRefresh()
        }
    },
)
val info = BiometricPrompt.PromptInfo.Builder()
    .setTitle("Verify your identity")
    .setNegativeButtonText("Cancel")
    .build()
prompt.authenticate(info)
```

Account-based sign-in, on the other hand, happens with the server and returns two tokens. The **access token** is short-lived and is attached to every request. The **refresh token** is long-lived and is used only to obtain a new access token. When the access token expires, the server answers the request with unauthorized; the client sends the refresh token to the server and receives a new access token.

```kotlin
// Platform code: Authenticator
class TokenAuthenticator(private val store: TokenStore) : Authenticator {
    override fun authenticate(route: Route?, response: Response): Request? {
        val fresh = store.refresh() ?: return null
        return response.request.newBuilder()
            .header("Authorization", "Bearer $fresh")
            .build()
    }
}
```

The two pieces are platform code, and here we make no claim about output, duration, or counts. The model builds the platform's rule; the model is not the platform itself. In the model, a call carries the access token it holds and the refresh event it will send if it gets an unauthorized response. Every call without a valid token sends a refresh.

```kotlin
// Flow.kt — every call with an invalid token sends its own refresh event
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

class Call(val name: String, val token: String, val refresh: Signal)

fun refreshAll(valid: String, calls: List<Call>): List<Signal> =
    calls.filter { it.token != valid }.map { it.refresh }

fun singleFlight(valid: String, calls: List<Call>): List<Signal> = runBlocking {
    val mutex = Mutex()
    val inFlight = mutableSetOf<String>()
    val started = mutableListOf<Signal>()
    calls.filter { it.token != valid }.map { call ->
        async {
            mutex.withLock {
                if (inFlight.add(call.refresh.key)) started.add(call.refresh)
            }
        }
    }.awaitAll()
    started
}
```

`refreshAll` is unprotected: every unauthorized call sends a refresh. `singleFlight`, on the other hand, drops the second refresh of the same key without waiting while one refresh is in progress; the check and the record are done under a `Mutex`, so that two calls do not both see "not yet" at the same time.

```kotlin
// Probe.kt — counts the list that reached the server as the receiver for the refreshes that were sent
fun probe(sent: List<Signal>, received: List<Signal>): List<Arrival> =
    sent.mapIndexed { i, s -> Arrival("event-${i + 1}", s, received) }
```

#### Why the Race Arises

The refresh race arises because three steps leave gaps between them: the call receives an unauthorized response, reads the refresh token, and sends the refresh request. These steps look sequential, but when several requests of a screen set out at the same time, each request has its own order. When the first request receives the unauthorized response, a refresh starts; if the second request receives the unauthorized response before the new token has arrived, it starts a refresh too. No one knows that the other has started.

The model of the unprotected flow is the `refreshAll` function, and it builds this race in a single line: every call without a valid token sends its own refresh. The calls do not see each other.

The job of single flight is to make the calls show themselves to each other. It keeps a shared record: if a refresh is in progress, its key is written to the record, and if a later call sees the key in the record, it does not send its own refresh and waits for the result. What is critical here is that the question "is it in the record?" and the operation "write to the record" are not split. If they are separate steps, two calls both see "absent" in between and both send. The `Mutex` in the model prevents this split: once a call takes the lock, the check and the record are done together, and the others wait in line.

The lock has a cost too: the calls in line wait until the lock is released. In the model the wait is very short and does not affect the measurement; in a real app, if a network request is made inside the lock, the wait grows, which is why the lock is kept to the check and the record, and the network request is made outside the lock.

#### When Every Call Has Its Own Token

Two of three calls have an old access token and belong to two separate sessions. The third has a valid token and sends no refresh.

```kotlin
// Main.kt — the refreshes of separate sessions arrive as one copy each
fun main() {
    val a = Signal("refresh.a", 1)
    val b = Signal("refresh.b", 2)
    val c = Signal("refresh.c", 3)
    val calls = listOf(Call("screen-1", "old", a), Call("screen-2", "old", b), Call("screen-3", "new", c))
    val received = refreshAll("new", calls)
    val arrivals = probe(listOf(a, b), received)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=refresh.a | copies=1 -> once
event-2: key=refresh.b | copies=1 -> once
once: 2
duplicated: 0
superseded: 0
lost: 0
```

Both of the two refreshes that were sent arrived as one copy each. The call with a valid token sends no refresh, so it is not an event in the measurement.

#### Two Concurrent Requests

When a screen opens, several requests go out at the same time. If the access token is old, all of these requests receive an unauthorized response and all of them send the refresh token to the server. Because the refresh token of the same session is the same, the event that reaches the server is the same event.

```kotlin
// Main.kt — concurrent calls of the same session send the refresh more than once
fun main() {
    val a = Signal("refresh.a", 1)
    val b = Signal("refresh.b", 2)
    val c = Signal("refresh.c", 3)
    val calls = listOf(
        Call("screen-1", "old", a),
        Call("screen-2", "old", a),
        Call("screen-3", "old", b),
        Call("screen-4", "old", c),
        Call("screen-5", "old", c),
        Call("screen-6", "old", c),
    )
    val received = refreshAll("new", calls)
    val arrivals = probe(listOf(a, b, c), received)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=refresh.a | copies=2 -> duplicated
event-2: key=refresh.b | copies=1 -> once
event-3: key=refresh.c | copies=3 -> duplicated
once: 1
duplicated: 2
superseded: 0
lost: 0
```

This lesson's delivery: duplicated. A single session may have sent the same refresh twice and three times; the rule reads the copy count. If the server renews the refresh token on every use, this excess can cause problems, but the measurement does not show that; the rule says only how many copies arrived.

What happens if the same six calls run with single flight? Of the six calls, three carry different keys and three carry repeats; single flight leaves only one of each key.

```kotlin
// Main.kt — single flight reduces the copies of the same event to one copy
fun main() {
    val a = Signal("refresh.a", 1)
    val b = Signal("refresh.b", 2)
    val c = Signal("refresh.c", 3)
    val calls = listOf(
        Call("screen-1", "old", a),
        Call("screen-2", "old", a),
        Call("screen-3", "old", b),
        Call("screen-4", "old", c),
        Call("screen-5", "old", c),
        Call("screen-6", "old", c),
    )
    val received = singleFlight("new", calls)
    val arrivals = probe(listOf(a, b, c), received)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=refresh.a | copies=1 -> once
event-2: key=refresh.b | copies=1 -> once
event-3: key=refresh.c | copies=1 -> once
once: 3
duplicated: 0
superseded: 0
lost: 0
```

The result that was duplicated 2 in the unprotected flow became once 3 with single flight. This is the purpose of single flight: to reduce the copies of the same event to one copy. That it drops different events is a side effect, seen in the next section.

**SE1.** The model assumes that the calls of the same session carry the same refresh event. On the platform, a refresh token being renewed on every use can lead the second copy to a different result on the server; the model does not build this.

**SE2.** The model leaves biometric authentication out of the measurement. The authentication opens the gate; the refresh flow behind the gate is what is measured.

#### The Bounding Measurement

There are four calls. The first two carry a refresh with the same key but are different events: the second was sent later, with a greater `seq`. The last two share the same event. First, the unprotected flow.

```kotlin
// Main.kt — in the unprotected flow, the calls of the same session send separate events
fun main() {
    val a1 = Signal("refresh.a", 1)
    val a2 = Signal("refresh.a", 2)
    val b = Signal("refresh.b", 3)
    val calls = listOf(
        Call("screen-1", "old", a1),
        Call("screen-2", "old", a2),
        Call("screen-3", "old", b),
        Call("screen-4", "old", b),
    )
    val received = refreshAll("new", calls)
    val arrivals = probe(listOf(a1, a2, b), received)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=refresh.a | copies=1 -> once
event-2: key=refresh.a | copies=1 -> once
event-3: key=refresh.b | copies=2 -> duplicated
once: 2
duplicated: 1
superseded: 0
lost: 0
```

Now the same four calls run with single flight. While the first refresh is in progress, the second of the same key is dropped.

```kotlin
// Main.kt — single flight drops the later refresh of the same key
fun main() {
    val a1 = Signal("refresh.a", 1)
    val a2 = Signal("refresh.a", 2)
    val b = Signal("refresh.b", 3)
    val calls = listOf(
        Call("screen-1", "old", a1),
        Call("screen-2", "old", a2),
        Call("screen-3", "old", b),
        Call("screen-4", "old", b),
    )
    val received = singleFlight("new", calls)
    val arrivals = probe(listOf(a1, a2, b), received)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=refresh.a | copies=1 -> once
event-2: key=refresh.a | copies=0 -> lost
event-3: key=refresh.b | copies=1 -> once
once: 2
duplicated: 0
superseded: 0
lost: 1
```

Single flight reduced the two calls that share the same event to one copy: `refresh.b` went from duplicated to once. The second `refresh.a`, a different event, never reached the server and was lost. This event is not superseded. To be superseded, a greater `seq` under the same key would have to arrive; the only copy that arrived carries `seq` 1, which is smaller than the dropped event. The rule sees only a later event taking the place of the one before it; it does not see an earlier event taking the place of the later one.

The cost of this is real for the app. A dropped refresh means that the second call never sent its own token to the server; the call must wait for the result of the first refresh. The measurement shows not this wait but only which event arrived.

**SE3.** The model keeps single flight per key and never marks that a refresh has finished; while one refresh is in progress, the second of the same key is always dropped. On the platform, a refresh finishes and the next refresh can start again.

#### What the Receiving Side Has to Do

The receiver is the server, but most of reducing copies is done in the client. There are three jobs.

- **Start the refresh from a single point.** Not every call sends a refresh; a call that receives the unauthorized response goes to a shared refresh function, and the function applies the single flight rule. That way the rule is written in one place, and screens do not have to know it.
- **Put the dropped call on hold.** A call that does not send the refresh does not give up; it waits for the result of the refresh in progress and resends its own request with the new token. This step is not in the model, but the dropped event staying lost makes it necessary.
- **Know how the server treats the refresh token.** If the server changes the refresh token on every use, the second copy arrives with an invalid token and is rejected; whether this signs the user out is the server's rule and is not measured here. From the client's point of view, single flight is the cheapest way of not asking this question.

Keeping the refresh flow in a single place also makes debugging easier: when the copy count goes up, there is only one place to look. The screen to show the user is also decided in this function: if the refresh succeeds, the user sees nothing, and if it fails, the user is redirected to the sign-in screen. Because the result of the dropped call is tied to the result of the refresh, the call must not show a separate error screen.

On the server side, adding a request identifier to the refresh request makes it possible to deduplicate copies. This does not change the delivery: the copy has still arrived, but the result is not repeated.

#### Common Misconceptions

**Biometric authentication authenticates to the server.** The authentication is local; it does not tell the server who the user is. The server learns the identity from the token. That is why the authentication was not measured in the model, only the refresh after the gate.

**Single flight never loses an event.** It does. It dropped a different event of the same key, and the rule counted it as lost. The gain of single flight is that duplicated goes down to once; the cost is the dropped event.

**A dropped event is superseded.** To be superseded, a greater `seq` under the same key must arrive afterwards. The dropped event is the second one, and the copy before it has arrived; the rule counts this as lost, not superseded.

**A copy that arrives with the same token is harmless.** If the server renews the token on use, it is not. Whether it is harmless depends on the server's rule, not on the client's assumption.

**If every call renews its own token, there is no problem.** In the measurement, when every call had its own token, two refreshes arrived once, and the third call sent no refresh. But this result belongs to a flow in which every call carries a separate refresh event. When calls carry the same event of the same session, copies arise, as in the concurrent measurement.

**The duplicated count equals the number of calls.** It does not. The copy count depends on how many calls carry the same event. In the six-call measurement, the copies of one event were two and of another three; the number of calls was six, and the copy count equaled none of them.

**The user does not see the refresh.** The user does not see it, but sees its result. If the refresh fails, the user is signed out; this lesson does not measure failure, it only counts how many refreshes reached the server.

#### Summary

- This lesson's delivery: duplicated. Concurrent calls of the same session send the same refresh event to the server more than once.
- Biometric authentication is a gate on the device; account-based sign-in gives the server an access token and a refresh token.
- The refreshes of separate sessions arrive as one copy each: once 2. Two and three calls of the same session produce two and three copies: duplicated 2, once 1.
- Single flight reduces the copies of the same event to one copy: the six calls that were duplicated 2 in the unprotected flow become once 3 with single flight. But it drops the later, different event of the same key: once 2, lost 1.
- A dropped event is not superseded, because the copy that arrived carries a smaller `seq`.

#### Next Step

The token was refreshed and the requests are reaching the server. The next question is what the app itself is: who the package installed on the device came from. In the next lesson, we look at whether a released version updates the app installed on the device, as long as it is signed with the same certificate.

### App Signing

Source: https://academia.sh/en/courses/platform-services-and-integrations/app-signing

Every released version is signed with a signing key, and the device installs an update only if it comes from the same certificate as the installed app. The lesson measures how many copies of a released version reached the installed app on the device, and what happens to a version signed with a different key.

In the previous lesson, the event was a refresh going from the device to the server. In this lesson the direction reverses: a developer releases a version, and that version wants to reach the app installed on the device. The event is the released version, and the receiver is the installed app on the device. The gate in between is a signature. The question is the course's question: **how many copies of this sent event reached the receiver?**

```kotlin
// 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 }}")
    }
}
```

In this lesson the key is the identity of the app, and `seq` is the version order. Versions of the same app carry the same key and increasing `seq` values.

#### The Signing Key and the Certificate

An app package is signed with a **signing key**. The document that carries the public half of the key is the **certificate**, and it comes down to the device with the package. Before installing a new version, the device compares its certificate with the certificate of the installed app. If they match, the update is installed; if they do not, it is rejected and the installed version stays in place.

This comparison answers an identity question: is the sender of the new version the same party as the sender of the installed app? If the key changes, the device does not treat the new version as the same app as the old one, except under special arrangements such as key rotation. That is why the signing key is the one thing that must stay the same from version to version.

In some app store arrangements, the developer signs the package with an **upload key** and uploads it to the app store; the app store re-signs the package with the signing key it holds and distributes it to devices. In that case, the certificate the device sees is not the upload key's but the certificate of the signing key at the app store. From inside the app, you can look at the digest of its own certificate.

```kotlin
// Platform code: PackageManager.getPackageInfo
val info = packageManager.getPackageInfo(
    packageName,
    PackageManager.GET_SIGNING_CERTIFICATES,
)
val signers = info.signingInfo?.apkContentsSigners ?: emptyArray()
val digest = MessageDigest.getInstance("SHA-256").digest(signers[0].toByteArray())
```

This piece is platform code; we make no claim here about output or digest values. Which tool does the signing is not the subject of this lesson and is not written. The model builds the platform's rule; the model is not the platform itself.

In the model, a release carries the event and the certificate that signed it. The device holds a single certificate that it trusts, and with the `verifySignature` function it compares the release's certificate against it. The releases that pass go on to installation, and they are the receiver's list.

```kotlin
// Install.kt — the device does not install a version whose certificate does not match the one it trusts
class Release(val signal: Signal, val cert: String)

class Device(private val trusted: String) {
    fun verifySignature(release: Release): Boolean = release.cert == trusted
}

fun installUpdates(device: Device, releases: List<Release>): List<Signal> =
    releases.filter { device.verifySignature(it) }.map { it.signal }
```

```kotlin
// Probe.kt — the receiver counts the installed list for the released versions
fun probe(sent: List<Signal>, received: List<Signal>): List<Arrival> =
    sent.mapIndexed { i, s -> Arrival("event-${i + 1}", s, received) }
```

#### The Signature as Identity

To understand why the comparison is so strict, we need to look at what the device is trying to protect. The device has a package name, and a name is a string anyone can write. If a second package with the same name could take the place of the installed app, the user's data would be handed over to an app whose owner is unclear. The certificate closes this gap: the name claims an identity, and the certificate proves it.

This has two consequences. The first is that for an update to be installed, it is not enough for the package contents to be right; the signature must also come from the right key. The second is that even if the signature is right, if the developer loses or changes the key, later versions cannot reach the installed app. The device reads this not as a configuration error but as a package from a different party.

In the model, both consequences are a single line in the `Device` class: `release.cert == trusted`. The model does not know how a certificate is produced, with which algorithm it is signed, or arrangements such as key rotation. There is only a match or no match.

#### The Same Certificate

Three versions are released, and all three are signed with the certificate the device trusts.

```kotlin
// Main.kt — every update signed with the same certificate is installed
fun main() {
    val v1 = Signal("app.main", 1)
    val v2 = Signal("app.main", 2)
    val v3 = Signal("app.main", 3)
    val releases = listOf(
        Release(v1, "cert-a"),
        Release(v2, "cert-a"),
        Release(v3, "cert-a"),
    )
    val received = installUpdates(Device("cert-a"), releases)
    val arrivals = probe(listOf(v1, v2, v3), received)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=app.main | copies=1 -> once
event-2: key=app.main | copies=1 -> once
event-3: key=app.main | copies=1 -> once
once: 3
duplicated: 0
superseded: 0
lost: 0
```

All three versions are installed, and one copy of each arrives. This lesson's delivery: once. An update signed with the same certificate passes the device's comparison and reaches the installed app as it is.

#### The Same Version by Two Routes

An update can reach a device by two routes: from the app store and from a file loaded by hand. If both carry the same certificate, both pass the comparison.

```kotlin
// Main.kt — if the same version comes by two routes, two copies arrive
fun main() {
    val v1 = Signal("app.main", 1)
    val v2 = Signal("app.main", 2)
    val releases = listOf(
        Release(v1, "cert-a"),
        Release(v2, "cert-a"),
        Release(v2, "cert-a"),
    )
    val received = installUpdates(Device("cert-a"), releases)
    val arrivals = probe(listOf(v1, v2), received)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=app.main | copies=1 -> once
event-2: key=app.main | copies=2 -> duplicated
once: 1
duplicated: 1
superseded: 0
lost: 0
```

The signature comparison does not look at whether a version arrives a second time; it looks only at the certificate. On a real device, a second installation of the same version mostly does not change the outcome, but the rule counts copies and writes two as duplicated.

**SE1.** The model builds installation as appending to a list. On the platform, a second installation of the same version may leave the installed app in place or rewrite it; the model does not make this distinction.

**SE2.** The model builds the certificate comparison as a string equality. On the platform, the comparison varies with the signing scheme, the key rotation history, and multiple signers.

#### The Bounding Measurement

Four versions are released. The first and third are signed with the trusted certificate; the second and fourth are signed with a different key.

```kotlin
// Main.kt — a version signed with a different key is rejected
fun main() {
    val v1 = Signal("app.main", 1)
    val v2 = Signal("app.main", 2)
    val v3 = Signal("app.main", 3)
    val v4 = Signal("app.main", 4)
    val releases = listOf(
        Release(v1, "cert-a"),
        Release(v2, "cert-b"),
        Release(v3, "cert-a"),
        Release(v4, "cert-b"),
    )
    val received = installUpdates(Device("cert-a"), releases)
    val arrivals = probe(listOf(v1, v2, v3, v4), received)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=app.main | copies=1 -> once
event-2: key=app.main | copies=0 -> superseded
event-3: key=app.main | copies=1 -> once
event-4: key=app.main | copies=0 -> lost
once: 2
duplicated: 0
superseded: 1
lost: 1
```

The second and fourth versions were rejected for the same reason: their certificates do not match the one the device trusts. By the rule, though, one is superseded and the other is lost. After the second version, a greater `seq` arrived under the same key, the third version; so the second is superseded. After the fourth, no such copy arrived; the installed version, 3, is smaller than the fourth. Because no later event exists, the installed version does not count as having superseded the fourth, so the fourth is lost.

For the user, the difference is this. A superseded version is an old version that is no longer needed; a lost version is a new version that never arrives, and the user cannot receive its fixes. The rule shows this difference with a category, but it does not say the reason, that the key is wrong.

The most serious form of losing the key is that after one version, all updates are signed with a different key. The first version carries the trusted certificate, and the next three carry a new key.

```kotlin
// Main.kt — when the key changes, every later version is rejected
fun main() {
    val v1 = Signal("app.main", 1)
    val v2 = Signal("app.main", 2)
    val v3 = Signal("app.main", 3)
    val v4 = Signal("app.main", 4)
    val releases = listOf(
        Release(v1, "cert-a"),
        Release(v2, "cert-b"),
        Release(v3, "cert-b"),
        Release(v4, "cert-b"),
    )
    val received = installUpdates(Device("cert-a"), releases)
    val arrivals = probe(listOf(v1, v2, v3, v4), received)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=app.main | copies=1 -> once
event-2: key=app.main | copies=0 -> lost
event-3: key=app.main | copies=0 -> lost
event-4: key=app.main | copies=0 -> lost
once: 1
duplicated: 0
superseded: 0
lost: 3
```

The first version arrived, and the three new versions did not. None is superseded, because the newest version installed on the device is the first, and no greater `seq` arrived to displace them. Once the key changes, the loss is not limited to a single version; every later version snags on the same gate. In the previous measurement, when a new version with the trusted certificate came after the rejected one, superseded arose; here there is no such version.

The same setup reverses once the device moves to the new key. The user uninstalls the old app and does a clean install of the version signed with the new key; the certificate the device trusts is now `cert-b`.

```kotlin
// Main.kt — after a clean install, the device trusts the new certificate
fun main() {
    val v1 = Signal("app.main", 1)
    val v2 = Signal("app.main", 2)
    val v3 = Signal("app.main", 3)
    val releases = listOf(
        Release(v1, "cert-a"),
        Release(v2, "cert-b"),
        Release(v3, "cert-b"),
    )
    val received = installUpdates(Device("cert-b"), releases)
    val arrivals = probe(listOf(v1, v2, v3), received)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=app.main | copies=0 -> superseded
event-2: key=app.main | copies=1 -> once
event-3: key=app.main | copies=1 -> once
once: 2
duplicated: 0
superseded: 1
lost: 0
```

The first version, signed with the old key, is now rejected; but because newer versions that replace it arrived, it is superseded. The same three versions produced superseded instead of lost when the trusted certificate changed. This shows once more that the rule sees only the position of the copies, not the cause. The cost of the clean install to the user does not show in the measurement: the old app's data goes away with the uninstall, and the measurement does not count it.

**SE3.** The model treats a rejected version as an event separate from the installed version. On the platform, a rejected update ends with an error message, and the installed app does not change.

#### The Silence of Rejection

Looking at the `installUpdates` function once more, the model's most important property shows: nothing is produced for a rejected version. No error record is added to the list, no marker is left; the receiver is left with only what was installed. That is why `probe` sees a rejected version as "copies zero". The difference between lost and once is only whether there is a row in the list.

This design is deliberate, because the lesson's question is how many copies reached the receiver, not why they did not. But it has a consequence for the reader: a version being lost does not say where the loss happened. A certificate mismatch, a package corrupted on the way, and an installation never attempted all give the same zero. Three different causes collapse into a single number; to look for the cause, you have to look outside the measurement, at the publisher's own records.

The classification rule, though, splits zero into two branches. If a greater `seq` under the same key arrived, the zero is superseded; if not, lost. A certificate rejection does not make this distinction itself; later versions make it. The same rejected version is written as superseded when a version with the trusted certificate comes after it, and as lost when none does. So for a publisher the safest reading is this: if the lost count is greater than zero, nothing has reached the device after those versions, and this means the user is stuck on the old version.

**Versions do not have to arrive in order.** The model processes versions in the order they were sent, but a real device can skip a version and move on to the next. In that case the copy count for the skipped version is zero, and because the next version arrived, the classification is superseded, not lost. So, independent of certificate rejection, the difference between "the version never came down to the device" and "the version came down but the next one displaced it" depends only on the version that comes later.

The version number and the certificate are separate identities. The version number determines the direction of installation, and the certificate determines whether installation is allowed. Neither replaces the other; a version with a larger number that comes with the wrong certificate is rejected, and a version with a smaller number that comes with the right certificate is evaluated in the measurement only as an order.

#### What the Publisher Has to Do

The receiver of this lesson is the device, but it is the publisher who will prevent the loss. The checklist is short.

- **Keep the signing key independent of the version.** The key is not a part of a version; it is the identity of the app. If it is lost, the three-lost measurement above is what you live through. A backup of the key should be kept somewhere separate from the environment that uses it.
- **Compare the certificate digest before release.** If the new package's certificate digest does not match that of the previous version, the release is stopped. This comparison is the same comparison the device will make, only done before the user.
- **Do dual-route distribution deliberately.** If the same version is distributed both through the app store and by hand, the certificates of the two must be the same. Otherwise one is installed and the other is rejected, and which is which is not visible to the user.
- **Make rejection observable.** A rejected update reports nothing to the server. The publisher's way of noticing is to look at the installed version distribution: if there is an audience that never sees the new version, the loss is in that audience.

#### Common Misconceptions

**If the package name is the same, the update is installed.** The name is not enough; the certificate must match too. In the four-version measurement, all versions had the same name and two were rejected anyway.

**A rejected update is always lost.** It is not. If a newer version with the trusted certificate arrived afterwards, it is superseded. The difference between lost and superseded lies not in the cause of the rejection but in what came afterwards.

**A key change affects only one version.** It does not. When the key changed, every later version was rejected; all three versions became lost.

**The same version arriving twice is an error.** From the device's point of view it may not be an error; but the rule counts that two copies arrived and writes duplicated. Whether it is an error depends on the outcome of the reinstall, not on the count.

#### Summary

- This lesson's delivery: once. An update signed with the certificate the device trusts passes the comparison and reaches the installed app as one copy.
- The signing key is the one thing that must stay the same from version to version; the certificate comes down to the device with the package.
- All three versions arrive once with the same certificate: once 3.
- If the same version is installed by two routes, two copies are counted: once 1, duplicated 1.
- A version signed with a different key is rejected: superseded if a later version exists, lost if not. In the four versions: once 2, superseded 1, lost 1. If the key changes after one version, all three versions are lost: once 1, lost 3.

#### Next Step

The version is now on the device and running. To know what the app does while running, you need to collect events. In the next lesson, the app sends analytics events and crash reports; when the response to a send is lost, the same event is retried without deduplication.

### Analytics and Crash Reporting

Source: https://academia.sh/en/courses/platform-services-and-integrations/analytics-and-crash-reporting

Analytics events and crash reports travel from an upload queue to the server; if the response is lost, the client retries the same event. The lesson measures how many copies of a sent event reached the server, how a bounded queue drops the oldest events, and why deduplication does not change the delivery.

In the previous lesson, a version was installed on the device. The way to know what a running app is doing is to send events: a screen opened, an item was added to a cart, the app crashed. These events do not go to the server the moment they are produced; they accumulate in a queue and are sent when the network is suitable. The question is the course's question: **how many copies of this sent event reached the receiver?** The event is the reported event, and the receiver is the server.

```kotlin
// 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 }}")
    }
}
```

#### Event, Report, and Session

An **analytics event** is the record of a step the user took in the app: a screen opened, a button pressed. A **crash report** is the record produced when the app stops with an unexpected error; it carries the error type and the stack. A **crash-free session** is a session that ends without a crash, and it forms the denominator of the crash rate calculation. All three travel the same way: they enter a queue and are sent to the server.

The hook that produces the crash report lives in the platform's crash reporting framework. When the app crashes, a handler is called before the process closes, and the report is added to the queue.

```kotlin
// Platform code: Thread.UncaughtExceptionHandler
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, error ->
    reportQueue.enqueue(CrashReport(thread.name, error))
    previous?.uncaughtException(thread, error)
}
```

Emptying the queue is done in the background, while the network is connected.

```kotlin
// Platform code: WorkManager
val constraints = Constraints.Builder()
    .setRequiredNetworkType(NetworkType.CONNECTED)
    .build()
val request = OneTimeWorkRequestBuilder<UploadWorker>()
    .setConstraints(constraints)
    .build()
WorkManager.getInstance(context).enqueue(request)
```

The two pieces are platform code, and we make no claim about output, duration, or counts here. The model builds the platform's rule; the model is not the platform itself. In most clients, retry is done with exponential backoff: the wait grows after each failed attempt. This wait changes the interval between attempts, not their number; the model has no wait.

#### If the Response Is Lost

A send has two halves: the request reaches the server, and the server returns an acknowledgement response. If the response is lost on the way, the client cannot know whether the request arrived, and it resends the same event until an acknowledgement comes. In the model, each event carries how many times its acknowledgement will be lost; every send reaches the server, and only the acknowledgement is lost.

```kotlin
// Report.kt — an event whose acknowledgement is lost is resent until an acknowledgement arrives
class Report(val signal: Signal, val lostAcks: Int = 0)

fun retryUntilAck(reports: List<Report>): List<Signal> =
    reports.flatMap { r -> List(r.lostAcks + 1) { r.signal } }

fun boundedQueue(capacity: Int, queued: List<Signal>): List<Signal> = queued.takeLast(capacity)

fun dedupeById(received: List<Signal>): List<Signal> =
    received.distinctBy { it.key to it.seq }
```

```kotlin
// Probe.kt — the receiver counts the list that reached the server for the sent events
fun probe(sent: List<Signal>, received: List<Signal>): List<Arrival> =
    sent.mapIndexed { i, s -> Arrival("event-${i + 1}", s, received) }
```

#### If the Acknowledgement Comes Every Time

Four events are sent: two analytics events, a crash report, and a crash-free session record. None of their acknowledgements is lost.

```kotlin
// Main.kt — if the acknowledgement arrives, each event is sent once
fun main() {
    val open = Signal("event.open", 1)
    val cart = Signal("event.cart", 2)
    val crash = Signal("crash.session", 3)
    val clean = Signal("session.clean", 4)
    val reports = listOf(Report(open), Report(cart), Report(crash), Report(clean))
    val received = retryUntilAck(reports)
    val arrivals = probe(listOf(open, cart, crash, clean), received)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=event.open | copies=1 -> once
event-2: key=event.cart | copies=1 -> once
event-3: key=crash.session | copies=1 -> once
event-4: key=session.clean | copies=1 -> once
once: 4
duplicated: 0
superseded: 0
lost: 0
```

All four events arrived as one copy each. There was no need to retry.

#### If the Same Event Is Retried

Now the cart event's acknowledgement is lost once, and the crash report's acknowledgement is lost twice. The client sends until an acknowledgement comes, and the server takes each send as a separate copy.

```kotlin
// Main.kt — an event whose acknowledgement is lost is retried without deduplication
fun main() {
    val open = Signal("event.open", 1)
    val cart = Signal("event.cart", 2)
    val crash = Signal("crash.session", 3)
    val clean = Signal("session.clean", 4)
    val reports = listOf(Report(open), Report(cart, 1), Report(crash, 2), Report(clean))
    val received = retryUntilAck(reports)
    val arrivals = probe(listOf(open, cart, crash, clean), received)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=event.open | copies=1 -> once
event-2: key=event.cart | copies=2 -> duplicated
event-3: key=crash.session | copies=3 -> duplicated
event-4: key=session.clean | copies=1 -> once
once: 2
duplicated: 2
superseded: 0
lost: 0
```

This lesson's delivery: duplicated. The event whose response was lost reached the server a second and a third time. The client made no mistake in this behavior: retrying a send for which no acknowledgement came is safer than sending nothing. The cost is extra copies at the server. If a number such as the crash rate is computed at the server from copies, crashed sessions are counted more than they are.

A distinction is needed here. A receiver that processes the same copy twice has not duplicated the event. Duplicated means a copy reaching the receiver a second time; the receiver processing it twice does not change the number of copies. In this lesson, copies are counted as they reach the server, and what happens inside the server is not looked at.

**SE1.** The model assumes that every send reaches the server and only the acknowledgement is lost. On the platform, the request can also be lost on the way; in that case a retry does not duplicate the event, it delivers it for the first time.

**SE2.** The model continues the retry until an acknowledgement comes and sets no upper limit. On the platform, the number of attempts and the wait come from the work policy.

#### Why the Queue Is Bounded

The queue not being unbounded is a design necessity. The device's memory and storage are limited; if the network is absent for days and the app writes every screen opening to the queue, the queue can become larger than the app itself. That is why clients set an upper limit, and when the limit is reached they choose one of two ways: drop the oldest, or not accept the newest.

The results of the two choices are not the same. A queue that drops the oldest sends the recent past when the network returns and loses the distant past; a queue that rejects the newest keeps the distant past but does not know the current state when the network returns. For crash reports, the second choice is usually less harmful, because the oldest crash is often the first and the most explanatory one. The model builds the first choice: `takeLast(capacity)` keeps the newest events.

The loss appears as the cost of this design, and the client cannot see it by itself. Because no send was ever attempted for a dropped event, no acknowledgement is awaited, no error is produced, nothing is recorded. The receiver knows nothing either; only those that arrived are visible to it. The shortfall in the count is noticed only if the number of events produced is compared with the number of events that arrived.

Retry was measured above and the queue bound is measured below; in a real client both work on the same event stream. Some of the events entering the queue are dropped, the rest are sent, and some of those sent have their acknowledgements lost. In the measurement below, five events enter a queue of three places; the acknowledgement of the payment event remaining in the queue is lost once.

#### The Bounding Measurement

Duplication is not the only risk of retry. If the network is absent for a long time, events accumulate in the queue, and the queue's length is bounded. When the queue is full, the oldest events are dropped to make room for the new one. Five events accumulate, and the queue takes three.

```kotlin
// Main.kt — when the bounded queue is full, the oldest events are dropped
fun main() {
    val sent = listOf(
        Signal("event.open", 1),
        Signal("event.cart", 2),
        Signal("crash.session", 3),
        Signal("event.payment", 4),
        Signal("session.clean", 5),
    )
    val received = boundedQueue(3, sent)
    val arrivals = probe(sent, received)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=event.open | copies=0 -> lost
event-2: key=event.cart | copies=0 -> lost
event-3: key=crash.session | copies=1 -> once
event-4: key=event.payment | copies=1 -> once
event-5: key=session.clean | copies=1 -> once
once: 3
duplicated: 0
superseded: 0
lost: 2
```

The oldest two events were dropped and never reached the server. Because their keys are different, there is no other copy that displaced them; the rule counts them as lost. Because the queue throws out the oldest, the first opening events are lost and the newest events remain. This choice is a policy: if a crash report is old enough, it can be dropped too.

Now assume the server deduplicates by event identifier. The earlier retry scenario plays out as it did, but the server reduces copies arriving with the same key and sequence to a single record.

```kotlin
// Main.kt — a deduplicating server preserves the outcome, but the delivery remains duplicated
fun main() {
    val open = Signal("event.open", 1)
    val cart = Signal("event.cart", 2)
    val crash = Signal("crash.session", 3)
    val clean = Signal("session.clean", 4)
    val reports = listOf(Report(open), Report(cart, 1), Report(crash, 2), Report(clean))
    val received = retryUntilAck(reports)
    val arrivals = probe(listOf(open, cart, crash, clean), received)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
    println("arrived: ${received.size}")
    println("stored: ${dedupeById(received).size}")
}
```

```text
event-1: key=event.open | copies=1 -> once
event-2: key=event.cart | copies=2 -> duplicated
event-3: key=crash.session | copies=3 -> duplicated
event-4: key=session.clean | copies=1 -> once
once: 2
duplicated: 2
superseded: 0
lost: 0
arrived: 7
stored: 4
```

The server reduced seven copies to four records, so the outcome is right: the crash rate is not inflated by copies. But the delivery is still duplicated: seven copies reached the server, and the rule counts the copies that arrive. Deduplication is a job inside the receiver; delivery is a copy reaching the receiver. Neither changes the other.

When retry and queue work together, both effects show in the same stream.

```kotlin
// Main.kt — dropped events are lost, an event whose acknowledgement is lost becomes duplicated
fun main() {
    val sent = listOf(
        Signal("event.open", 1),
        Signal("event.cart", 2),
        Signal("crash.session", 3),
        Signal("event.payment", 4),
        Signal("session.clean", 5),
    )
    val queued = boundedQueue(3, sent)
    val reports = queued.map { Report(it, if (it.seq == 4) 1 else 0) }
    val received = retryUntilAck(reports)
    val arrivals = probe(sent, received)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=event.open | copies=0 -> lost
event-2: key=event.cart | copies=0 -> lost
event-3: key=crash.session | copies=1 -> once
event-4: key=event.payment | copies=2 -> duplicated
event-5: key=session.clean | copies=1 -> once
once: 2
duplicated: 1
superseded: 0
lost: 2
```

Three different deliveries were seen in the same stream. The loss comes from the queue's limit, the duplicated from the lost acknowledgement; the two are independent, and neither corrects the other. An event that does not enter the queue is never retried, so it never becomes duplicated; an event that is retried cannot have been dropped from the queue either, because sending is done for the events that remain in the queue.

**SE3.** The model does deduplication by the key and sequence pair. On the platform, the server may use an event identifier, a timestamp, or both together; the model builds only the first.

#### What the Server Has to Do

The receiver of this lesson is the server, and no one but it can correct the copies. Three jobs are listed.

- **Give every event an identifier and deduplicate by identifier.** The client produces the identifier, and the same identifier is sent on retry; when the server sees the same identifier a second time, it does not count the event. If the identifier is produced anew at every attempt, deduplication does not work, because the server sees two copies as two separate events.
- **Know the deduplication window.** The server cannot keep the identifiers it has seen forever. If the window is kept short, a late-arriving copy is counted as a new event; if it is kept long, memory swells. The window's length must be greater than the client's retry duration.
- **Count the copies separately.** Deduplication preserves the outcome but hides how many copies came. The retry rate is an indicator of a healthy network; if the copy count has risen, acknowledgements are being lost, and this, even if it does not change the outcome, is something that needs to be known.

To compare the copy count with a healthy network, the server needs a baseline: without knowing the proportion of events whose acknowledgements are not lost, an increase in copies cannot be interpreted.

The job on the client side is to produce an event once and send it with the same identifier. The moment the identifier is produced must be before the retry.

#### Common Misconceptions

**Backoff changes the interval, not the count.** Exponential backoff lets the client try without straining the network; but for a client that sends until an acknowledgement comes, the copy count is still one more than the number of lost acknowledgements. Backoff helps protect the server from copies arriving at the same time; it does not eliminate copies.

**An event written to the queue is not counted as having reached the server.** In the interval between production and arrival, the event is in the queue, and the process closing in that interval can destroy the event. The measurement assumes the queue stays in memory continuously; a non-persistent queue loses everything pending when the app closes.

**There is no duplicated on a deduplicating server.** There is. In the measurement, seven copies were reduced to four records, and the delivery remained duplicated 2. Deduplication corrects the outcome; it does not change the number of copies that arrived.

**Retry prevents an event from being lost.** It only prevents the loss of an event whose acknowledgement is lost. Because an event dropped from the queue was never sent, retry does not reach it; in the measurement, the two dropped events remained lost.

**The bigger the queue, the safer.** A big queue drops fewer events, but it increases the device's memory use and the load to be sent when the network returns. The limit is not a safety; it is a balance.

**An event's identifier depends on the server looking at it.** If only the client carries the identifier, the server cannot tell the copy apart. Deduplication is a contract applied by both sides together: the client sends the same identifier, and the server counts the same identifier once.

**A crash report is counted like other events.** It is counted, and that is why a crash report counted with copies inflates the crash rate. In the measurement, the crash report arrived as three copies; without deduplication, that session would have been counted as three crashes.

#### Summary

- This lesson's delivery: duplicated. An event whose acknowledgement is lost, retried without deduplication, reaches the server as multiple copies.
- An analytics event records a step, a crash report records a crash, a crash-free session records a sound ending; all three go through the same queue.
- If the acknowledgement comes every time, all four events arrive once. With two events whose acknowledgements are lost, the result is duplicated 2 and once 2.
- When five events enter a queue of three places, the oldest two are dropped: lost 2, once 3.
- When retry and queue work together, lost 2, once 2, and duplicated 1 are seen; neither corrects the other.
- A server that deduplicates by event identifier reduces seven copies to four records, but the delivery is still duplicated 2. A receiver that processes the same copy twice has not duplicated the event.

#### Next Step

Events reach the server, and how the app behaves in production is visible. Changing behavior does not require releasing a new version every time. In the next lesson, the app fetches a configuration from the server; when the configuration is fetched twice before activation, the last value of the same key wins.

### Remote Configuration

Source: https://academia.sh/en/courses/platform-services-and-integrations/remote-configuration

In remote configuration, the app fetches values from the server and activates them in a separate step. The lesson measures the last value winning for a key fetched twice before activation, the first value being superseded, and a value that is fetched but not activated being lost.

In the previous lesson, events went to the server. In this lesson the direction reverses: the server sends the app a value, for example whether a feature is on or off, or an upper limit. That way behavior changes without releasing a new version. The value enters the app in two steps: first it is fetched, then it is activated. The question is the course's question: **how many copies of this sent event reached the receiver?** Here the event is a configuration value, and the receiver is the running app.

```kotlin
// 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 }}")
    }
}
```

#### Fetch and Activation

**Fetching** is taking the value from the server and holding it on the device; the app does not read it yet. **Activation** is the pending value becoming the value the app reads. The reason the two steps are separate is so that the value of a feature running on screen does not change in the middle. Activation is done at a suitable moment, for example at the next launch.

On the platform, the plainest counterpart of this separation is holding the value somewhere and then writing it to the store the app reads.

```kotlin
// Platform code: SharedPreferences
val pending = context.getSharedPreferences("pending", Context.MODE_PRIVATE)
val active = context.getSharedPreferences("active", Context.MODE_PRIVATE)
pending.edit().putString("setting.theme", "dark").apply()
active.edit().putString("setting.theme", pending.getString("setting.theme", null)).apply()
```

This piece is platform code, and we make no claim here about output, duration, or counts. The model builds the platform's rule; the model is not the platform itself. The fetch frequency and the moment of activation are determined by the platform or the framework in use; in the model, both are a list.

In the model, the fetched values are an ordered list. Activation picks the last value in the list for each key. An older value of the same key does not affect a separate key.

```kotlin
// Config.kt — activation picks the last fetched value for each key
fun activateLatest(fetched: List<Signal>): List<Signal> =
    fetched.groupBy { it.key }.map { (_, values) -> values.maxBy { it.seq } }

fun activateUpTo(fetched: List<Signal>, count: Int): List<Signal> =
    activateLatest(fetched.take(count))
```

```kotlin
// Probe.kt — the receiver counts the list the app reads for the sent values
fun probe(sent: List<Signal>, received: List<Signal>): List<Arrival> =
    sent.mapIndexed { i, s -> Arrival("event-${i + 1}", s, received) }
```

#### If Each Key Is Fetched Once

Three separate keys are fetched once and activated: a theme, an upper limit, and a feature flag.

```kotlin
// Main.kt — if each key is fetched once, all are activated
fun main() {
    val theme = Signal("setting.theme", 1)
    val limit = Signal("setting.limit", 2)
    val flag = Signal("setting.flag", 3)
    val sent = listOf(theme, limit, flag)
    val arrivals = probe(sent, activateLatest(sent))
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=setting.theme | copies=1 -> once
event-2: key=setting.limit | copies=1 -> once
event-3: key=setting.flag | copies=1 -> once
once: 3
duplicated: 0
superseded: 0
lost: 0
```

All three values reached the app as one copy each. No key competed.

#### If It Is Fetched Twice Before Activation

Now the theme value is fetched twice before activation arrives: the light theme the first time, the dark theme the second. The server changed the value in between.

```kotlin
// Main.kt — if the same key is fetched twice, the last value wins
fun main() {
    val first = Signal("setting.theme", 1)
    val second = Signal("setting.theme", 2)
    val limit = Signal("setting.limit", 3)
    val sent = listOf(first, second, limit)
    val arrivals = probe(sent, activateLatest(sent))
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=setting.theme | copies=0 -> superseded
event-2: key=setting.theme | copies=1 -> once
event-3: key=setting.limit | copies=1 -> once
once: 2
duplicated: 0
superseded: 1
lost: 0
```

This lesson's delivery: superseded. The first theme value never reached the app; the second value of the same key, with the greater sequence, took its place. The rule does not see the reason: it does not know whether the first value was wrong or the second was a correction; it sees only that a newer value with the same key arrived. There is a difference from the duplicated of the previous lessons: there, multiple copies of the same event arrived; here, no copy of the first value arrived.

This behavior is mostly what is wanted for configuration. What the app needs is the most current value; seeing the old value is not counted as a gain. The cost is that the first value passes with zero copies; a party that ties the first value to a counter or an experiment must know that it was never applied.

**SE1.** The model assumes the sequence numbers are given correctly by fetch time. On the platform, the order of two fetches can get mixed up by network latency, and the version information from the server is used for this; in the model, this information is directly the sequence number.

**SE2.** The model does the activation at a single moment and activates all pending values together. On the platform, activation can be left half done, or some keys can be postponed to later.

#### The Fetch Loop

Remote configuration does not end with a single request; it is a loop. The app asks the server at certain intervals, the server returns the values of that moment, and the app writes the values to the pending store. Each turn of the loop is a fetch, and each turn is independent of the one before: it takes whatever value the server returns this turn, and the app does not have to tell the server what it received in the previous turn.

This independence has a consequence. If the value on the server changed between two turns, two values for the same key can accumulate in the pending store. Because activation has not yet been done, neither is the value the app reads. As long as the loop keeps fetching, the pending list grows, and the only thing the app sees is the value that activation left at that moment.

The reason the fetch frequency is limited also follows from here. Every turn is a network request; asking too often strains the battery and the network, and asking too rarely makes a change arrive late. The model does not have this balance, because the model carries no time: the fetched values are an ordered list, and the wait between two turns is not visible.

#### The Line Between Superseded and Lost

The line between the two outcomes is thin but exact. Superseded says that the value's copy did not arrive and a later value with the same key arrived and took its place; it does not say that the app is on the latest value. Lost says that the app could not reach the server's latest decision; the app is running with an older value or with the default. For a publisher, the first is often an expected outcome, and the second is a situation that needs to be monitored.

That is why the two numbers must be reported separately. A high superseded count shows that the server changes quickly, and a high lost count shows that activation is falling behind. If they are reduced to a single "value that did not arrive" count, two different problems hide in the same figure.

#### What We Do Not Count in the Measurement

What the rule does not see is as important for configuration as what it does. The rule says whether a value reached the app; it does not say that the app used that value correctly. An activated flag still turns nothing on if the code that reads the flag is faulty. The rule marks a value being superseded or lost, but it does not know why the value was changed on the server.

These limits do not weaken the measurement; they show its scope. The lesson answers a single question: did the value the server sent become the value the app reads? Whether the value is correct, whether it was activated at the right time, and what it showed the user are other questions.

#### When a Value Is Activated

The timing of activation determines which value reaches the app. If activation is done right after the previous turn, every fetched value is activated and no superseded arises; there is a single value at the end of each turn. If activation is done after several turns, only the last of the values accumulated for the same key is activated, and the earlier ones are superseded. If activation is never done, all of them stay lost.

So superseded is not an error in configuration; it is the natural result of delaying activation. When activation is left to the app's next launch, the last of the values fetched up to the launch wins. The same activation point changes, and the result changes: the model imitates this with the `count` parameter of the `activateUpTo` function. How many values activation covers produces superseded or lost depending on where it stands in the pending list.

A key is fetched three times, then another key comes, and activation covers only the first two.

```kotlin
// Main.kt — the activation point determines superseded and lost
fun main() {
    val a = Signal("setting.theme", 1)
    val b = Signal("setting.theme", 2)
    val c = Signal("setting.theme", 3)
    val limit = Signal("setting.limit", 4)
    val sent = listOf(a, b, c, limit)
    val arrivals = probe(sent, activateUpTo(sent, 2))
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=setting.theme | copies=0 -> superseded
event-2: key=setting.theme | copies=1 -> once
event-3: key=setting.theme | copies=0 -> lost
event-4: key=setting.limit | copies=0 -> lost
once: 1
duplicated: 0
superseded: 1
lost: 2
```

The second of the same key's three values was activated. The first was superseded by the second; the third did not arrive because it stayed outside the scope of activation, and since no greater sequence arrived, it was written as lost, not superseded. The rule looks only at what arrived: no sibling of the third value greater than itself came down to the app.

There is a trap here. The last fetched value is the server's most current decision; but if activation did not cover it, the app runs with the previous value. The value the app runs with may not be the value the server wants right now. In the measurement, the active value for the theme is the second, but the last value the server sent is the third.

#### The Bounding Measurement

Fetching alone is not delivery. If a value is fetched but not activated, the app cannot read it. Three values are fetched, activation lasts until it covers only the first, and the app closes.

```kotlin
// Main.kt — a value that is fetched but not activated does not reach the app
fun main() {
    val theme = Signal("setting.theme", 1)
    val limit = Signal("setting.limit", 2)
    val flag = Signal("setting.flag", 3)
    val sent = listOf(theme, limit, flag)
    val arrivals = probe(sent, activateUpTo(sent, 1))
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=setting.theme | copies=1 -> once
event-2: key=setting.limit | copies=0 -> lost
event-3: key=setting.flag | copies=0 -> lost
once: 1
duplicated: 0
superseded: 0
lost: 2
```

The limit and the flag were fetched but not activated; the rule counts them as lost. The difference from superseded is this: there is no newer value with the same key that displaced them. The value has only stayed in the pending store.

What does the app read in this case? A fixed fallback, that is, the in-app default. The default is not a value that comes from the server; it is a value the app carries within itself. That is why it does not count as delivery. The same happens when nothing is fetched at all.

```kotlin
// Main.kt — a value that is never fetched is lost, and the default does not count as delivery
fun main() {
    val theme = Signal("setting.theme", 1)
    val limit = Signal("setting.limit", 2)
    val sent = listOf(theme, limit)
    val arrivals = probe(sent, activateUpTo(sent, 0))
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=setting.theme | copies=0 -> lost
event-2: key=setting.limit | copies=0 -> lost
once: 0
duplicated: 0
superseded: 0
lost: 2
```

Both values are lost. The app still runs, because it has default values; but these values are not what the server sent. The measurement does not say whether the running app is compatible with the server; it says only whether the value the server sent arrived.

**SE3.** The model assumes the value is not lost between fetch and activation. On the platform, a pending value goes away if the store is cleared or the app data is deleted.

#### The Server's Role

The receiver is the app, but the source of the value is the server, and the choices the server makes affect the count at the receiver. If the server changes a key often, multiple values accumulate in a time as short as the fetch interval, and the superseded count rises. If the server changes the key rarely, a single value remains in the pending list for the same key, and no superseded arises.

That is why a high superseded is often a sign that the server is changing values too fast, not a problem of the app. A party wondering which value is active in which app should put the version history on the server next to the active value the app recorded. Without one, the other misleads: the server says it sent the value, and the app may never have seen it.

#### What the App Has to Do

The receiver of this lesson is the running app; for the configuration to reach the app correctly, four jobs are done on the app side.

- **Define a default for every key.** The default does not count as delivery, but it keeps the app on its feet when a fetch fails. Setting the default equal to the safe value the server is expected to send makes behavior predictable in the cases where the configuration does not arrive.
- **Choose the moment of activation deliberately.** If activation is done immediately, a feature on screen can change midway; if it is left to the next launch, the last value arrives a launch late. Both have a cost; which is acceptable depends on the feature.
- **Record which value is active.** Assuming that the value used in an experiment or a counter is the same as the value on the server is wrong. An app that records the active value separately can later tell superseded and lost values apart.
- **Do not leave fetch failure silent.** If a fetch could not be done at all, the app runs with the default and no one notices. Counting failed fetches makes the number of losses indirectly visible.

#### Common Misconceptions

**A fetched value is valid in the app.** It is not. In the measurement, two values were fetched but not activated, and were written as lost. Fetching only leaves the value on the device.

**Superseded is an error.** It is not. The last value of the same key won and the first value's copy stayed at zero; this is the behavior wanted for configuration. The error is tying a superseded value to a counter or an experiment without noticing it.

**A default value counts as delivered.** It does not. In the measurement with no fetch at all, two values were lost, and the app still ran. Its running does not mean the server's value arrived.

**Activation always applies the most current value.** It does not. The scope of activation can lag behind the fetch; in the measurement, the third theme value was fetched but not activated, and the app ran with the second.

#### Summary

- This lesson's delivery: superseded. For the same key fetched twice before activation, the last value wins and no copy of the first value arrives.
- Fetching holds the value on the device, and activation makes it the value the app reads; the two are separate steps.
- When three separate keys are fetched once, all three arrive once. For the theme fetched twice, the first value is superseded 1 and the second is once.
- A value that is fetched but not activated is lost: once 1, lost 2. A value that is never fetched is also lost.
- The activation point separates superseded and lost: when the second of three theme values is activated, the first is superseded and the third is lost.
- An in-app default does not count as delivery, because it does not come from the server.

#### Next Step

In this lesson the last value won and the old value was superseded, because in configuration only the current one is useful. Not every external service is like this. In the next lesson come in-app purchases and ads: a purchase that is redelivered until it is acknowledged does not resemble the result in this lesson. There we will again measure what arrived and what did not.

### In-App Purchases and Ads

Source: https://academia.sh/en/courses/platform-services-and-integrations/in-app-purchases-and-ads

In an in-app purchase, a purchase that is not acknowledged is redelivered every time the platform queries purchases. The lesson measures an unacknowledged purchase arriving duplicated, an acknowledged one arriving once, and a refunded one being lost; it then gathers the delivery of the 14 lessons in a single table.

In the previous lesson the last value won and the old value was superseded. A purchase does the opposite: the platform reports a purchase to the app and hands over the same update again until the app acknowledges it. The question is the course's question: **how many copies of this sent event reached the receiver?** Here the event is a purchase update, and the receiver is the app's code that processes purchases.

```kotlin
// 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 }}")
    }
}
```

#### Purchase and Acknowledgement

An in-app purchase is the user paying inside the app through the app store. The result of the payment reaches the app as an update. **Acknowledgement** is the app saying, "I processed this purchase and delivered the item." A purchase that is not acknowledged counts as unprocessed, and the platform returns it again every time the app queries purchases.

On the platform, acknowledgement is a call on the billing library's client class.

```kotlin
// Platform code: BillingClient
val params = AcknowledgePurchaseParams.newBuilder()
    .setPurchaseToken(purchase.purchaseToken)
    .build()
grantItem(purchase)
billingClient.acknowledgePurchase(params) { result ->
    if (result.responseCode != BillingClient.BillingResponseCode.OK) retryAcknowledge(purchase)
}
```

This piece is platform code, and we make no claim here about output, duration, or counts. The model builds the platform's rule; the model is not the platform itself. In the model, acknowledgement is a sequence number entering a set; redelivery is adding the unacknowledged purchases to the list again; the platform's querying of purchases is represented in the model by an open.

```kotlin
// Billing.kt — an unacknowledged purchase is redelivered at every open
fun redeliver(purchases: List<Signal>, acknowledged: Set<Int>, opens: Int): List<Signal> =
    (1..opens).flatMap { open ->
        if (open == 1) purchases else purchases.filter { it.seq !in acknowledged }
    }

fun expire(received: List<Signal>, refunded: Set<Int>): List<Signal> =
    received.filter { it.seq !in refunded }
```

```kotlin
// Probe.kt — the receiver counts the list the app processed for the sent purchases
fun probe(sent: List<Signal>, received: List<Signal>): List<Arrival> =
    sent.mapIndexed { i, s -> Arrival("event-${i + 1}", s, received) }
```

**SE1.** The model assumes that every purchase is delivered once at the first open. On the platform, delivery can also arrive while the app is running, and the number of opens varies in real use.

#### An Unacknowledged Purchase

Three purchases are made and the app acknowledges none of them. The app is opened twice.

```kotlin
// Main.kt — an unacknowledged purchase is redelivered at every open
fun main() {
    val coins = Signal("purchase.coins", 1)
    val premium = Signal("purchase.premium", 2)
    val adFree = Signal("purchase.adfree", 3)
    val sent = listOf(coins, premium, adFree)
    val arrivals = probe(sent, redeliver(sent, emptySet(), 2))
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=purchase.coins | copies=2 -> duplicated
event-2: key=purchase.premium | copies=2 -> duplicated
event-3: key=purchase.adfree | copies=2 -> duplicated
once: 0
duplicated: 3
superseded: 0
lost: 0
```

This lesson's delivery: duplicated. All three purchases arrived as two copies. In earlier lessons, duplicated came from a lost response or from a return; here the duplication is the platform's rule: the platform returns an unacknowledged purchase every time it is queried, and this continues until acknowledgement arrives. A difference from M30/K05: there, neutralizing a duplicated request and making the result single was measured; this course measures the copy count, not the result. A receiver that deduplicates the purchase or processes it idempotently has still received a duplicated event.

#### Acknowledgement Breaks the Chain

The same three purchases are made, but the app acknowledges the first two.

```kotlin
// Main.kt — an acknowledged purchase arrives once, an unacknowledged one at every open
fun main() {
    val coins = Signal("purchase.coins", 1)
    val premium = Signal("purchase.premium", 2)
    val adFree = Signal("purchase.adfree", 3)
    val sent = listOf(coins, premium, adFree)
    val arrivals = probe(sent, redeliver(sent, setOf(1, 2), 2))
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=purchase.coins | copies=1 -> once
event-2: key=purchase.premium | copies=1 -> once
event-3: key=purchase.adfree | copies=2 -> duplicated
once: 2
duplicated: 1
superseded: 0
lost: 0
```

The two acknowledged purchases did not come after the first open and were counted as once. The unacknowledged third purchase came again at the second open. The rule does not see the cause: it cannot tell whether the third was not processed or the acknowledgement was lost; it sees only that two copies arrived.

This behavior is protection. An unacknowledged purchase is a situation where the user's money has been taken but the item has not been delivered; redelivery is the app's second chance to deliver the item. The cost is the copy: the code that delivers the item must be ready to see the same purchase twice.

#### Subscription Renewal

A subscription is the same product renewed period by period. Each renewal is a new purchase update and carries its own sequence number. A renewal does not replace the earlier one, because an unacknowledged earlier period is still redelivered.

```kotlin
// Main.kt — unacknowledged subscription periods do not supersede the earlier one
fun main() {
    val first = Signal("subscription.pro", 1)
    val second = Signal("subscription.pro", 2)
    val third = Signal("subscription.pro", 3)
    val sent = listOf(first, second, third)
    val arrivals = probe(sent, redeliver(sent, setOf(1), 2))
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=subscription.pro | copies=1 -> once
event-2: key=subscription.pro | copies=2 -> duplicated
event-3: key=subscription.pro | copies=2 -> duplicated
once: 1
duplicated: 2
superseded: 0
lost: 0
```

The first period was acknowledged, so it is once; the next two periods are duplicated. A greater sequence of the same key arrived, but no superseded arose, because for superseded the smaller sequence must never arrive. Configuration behaved that way in the previous lesson; with a subscription, the earlier period keeps arriving too.

#### The Bounding Measurement

Redelivery does not go on forever. An unacknowledged purchase is refunded after some time, and the purchase is canceled without the item being delivered. The platform determines the duration; we write no number in the model or in this lesson. The update of a refunded purchase no longer comes.

```kotlin
// Main.kt — the delivery of a refunded purchase does not come
fun main() {
    val coins = Signal("purchase.coins", 1)
    val premium = Signal("purchase.premium", 2)
    val adFree = Signal("purchase.adfree", 3)
    val theme = Signal("purchase.theme", 4)
    val sent = listOf(coins, premium, adFree, theme)
    val received = expire(redeliver(sent, setOf(1), 2), setOf(2, 3))
    val arrivals = probe(sent, received)
    arrivals.forEach { it.line() }
    countArrivals(arrivals)
}
```

```text
event-1: key=purchase.coins | copies=1 -> once
event-2: key=purchase.premium | copies=0 -> lost
event-3: key=purchase.adfree | copies=0 -> lost
event-4: key=purchase.theme | copies=2 -> duplicated
once: 1
duplicated: 1
superseded: 0
lost: 2
```

The second and third purchases were refunded and never reached the app; since no copy with the same key took their place, they were written as lost, not superseded. The acknowledged one is once, and the unacknowledged fourth, which has not expired, stays duplicated. Lost here is not a loss for the user: the money has been returned. The rule does not tell this apart; it sees only that the copy did not arrive.

**SE2.** The model assumes the refund happened before the first delivery. On the platform, a refund can also come after delivery; in that case the copy has already arrived, and taking the item back is a separate job.

#### Ad Placement

An ad is a revenue path different from a purchase. The app reserves a slot, and the ad library loads the content into it. The loading comes from a server, can fail, and when it finishes is not in the app's hands.

```kotlin
// Platform code: ad placement
val slot = findViewById<FrameLayout>(R.id.ad_slot)
bannerView.loadAd(request)
slot.addView(bannerView)
```

This piece is also platform code, and we make no claim about output or counts. We do not measure ad impressions in this lesson: counting an impression depends on the ad network's definition and has no counterpart in the model. What we measure is the copy count of the purchase update.

#### What the App Has to Do

- **Acknowledge the purchase after processing it.** The item is delivered first, and the acknowledgement is sent afterwards. Acknowledging first leaves the user charged but without the item if the process dies before the item is delivered; delivering the item and never acknowledging keeps the copy coming.
- **Record the purchase identifier.** When the second copy of the same purchase arrives, the item must not be delivered again. An app that records the identifier neutralizes the copy; the measurement still counts two copies.
- **Process subscription periods separately.** A new period does not replace the earlier one; both arrive.
- **Prepare for ad loading to fail.** If the ad does not come, the screen must not stay empty.

#### Common Misconceptions

**The acknowledgement call delivers the purchase once.** It does, but only if the call succeeds. If the acknowledgement is lost, the purchase is duplicated again.

**A refunded purchase leaves a trace in the app.** It does not. In the measurement, two purchases never arrived and were written as lost.

**A subscription renewal supersedes the earlier period.** It does not. Two unacknowledged periods arrived as duplicated.

**Lost is always an error.** It is not. A refunded purchase is lost, and that is the right outcome.

#### Summary

- This lesson's delivery: duplicated. Three unacknowledged purchases were redelivered when the platform queried them again (in the model, the second open): duplicated 3.
- An acknowledged purchase arrives once. With two acknowledged and one unacknowledged purchase, the result was once 2, duplicated 1.
- A subscription renewal does not supersede the earlier period. Once 1, duplicated 2.
- A refunded purchase is lost: once 1, duplicated 1, lost 2.
- A receiver that deduplicates the purchase also receives a duplicated event; the course measures the copy count, not the result.
- Ad placement is platform code and is not measured in this lesson.

#### Course Wrap-Up

Across 14 lessons the course asked the same question: how many copies of this sent event reached the receiver? Each lesson's delivery is the first item in that lesson's `## Summary` section; the numbers in the bounding measurement column appear in the source lesson's output.

| Lesson | Measured event | Delivery | Bounding measurement |
|---|---|---|---|
| Notifications | notification | lost | a second notification with the same identifier replaces the first: superseded 1 |
| Cloud Messaging | message from the server | superseded | messages carrying no collapse key: once 4, superseded 1 |
| Location and Maps | location update | superseded | once the permission is revoked, later ones do not come: lost 3 |
| Camera and Media | captured photo | once | two taps, two separate sequences: once 3 |
| Sensors | sensor reading | superseded | once the listener is removed, readings stop: lost 2 |
| Deep Links and Shortcuts | deep link | duplicated | if the flag stays in memory, duplicated 2 |
| Widgets | widget update | superseded | no update comes to a removed widget: lost 2 |
| Encrypted Storage and the Keystore | stored secret | lost | a rewritten secret supersedes the old one: superseded 1, lost 1 |
| Network Security Configuration | outgoing network request | lost | a resent request supersedes the first: superseded 1, lost 1 |
| Authentication Flows | token refresh | duplicated | single flight drops the second refresh: lost 1 |
| App Signing | app version | once | versions with different keys: once 1, lost 3 |
| Analytics and Crash Reporting | analytics event | duplicated | when the queue fills, old events are dropped: lost 2 |
| Remote Configuration | configuration value | superseded | a value that is not activated: once 1, lost 2 |
| In-App Purchases and Ads | purchase update | duplicated | refunded purchase: lost 2 |

Of the 14 lessons, 2 close with delivery once, 4 with duplicated, 5 with superseded, and 3 with lost. The distribution of the core in the opening lesson is different: eight events came out as 3 once, 1 duplicated, 2 superseded, 2 lost. The two distributions do not count the same thing; the core counts eight events, and the table counts fourteen lessons.

The next course, M31/K06, moves on to the Testing, Performance and Distribution topic area.

