---
title: 'Android Platform and Tools'
source: 'https://academia.sh/en/courses/android-platform-and-tools'
language: en
updated: '2026-10-21T05:02:35+00:00'
license: 'CC BY-SA 4.0'
lessons: 15
outcomes:
    - 'Explains application components and their lifecycles'
    - 'Uses the manifest file and the intent system'
    - 'Manages build configuration and the resource system'
    - 'Distinguishes whether the platform fulfills a request in full or with a substitute, whether the request is waiting on a condition, or whether it remains unmet'
---

# Android Platform and Tools

An Android app constantly asks the platform for something, and the platform does not fulfill every request the same way: an activity named in the manifest opens as asked, one that is not named never opens, where precise location is requested the user may grant approximate location, if no text matches the device's language the default text comes back, and a job whose condition is not met waits for that condition. The course measures every mechanism with a single question — how does the platform fulfill this request? Architecture layers, activities and their lifecycles, fragments, services, broadcast receivers, content providers, intents, tasks and the back stack, the manifest file, the resource system, the build system, product flavors, the development environment and the permission model all pass through the same frame.

## Application Components

Android architecture layers from the kernel to the application framework, activities and their lifecycles, fragments, services, broadcast receivers, content providers, explicit and implicit intents with intent filters, and tasks and the back stack.

### Android Architecture Layers

Source: https://academia.sh/en/courses/android-platform-and-tools/android-architecture-layers

Measures whether a request an app makes to the platform is fulfilled as full, substitute, pending, or unmet, and how that request passes through the layers from the Linux kernel to the application framework.

The previous course measured what makes two values equal in Kotlin and set up the difference between `==` and `===` at the language level. This course moves from the same language to the platform itself. An Android app constantly asks the platform for something: to start an activity, to get a permission, to find the text that matches the device's language, to run a job at a suitable moment. The platform gives every request an answer, but not always the answer that was wanted. An activity named in the manifest file opens as requested, and one that is not named never opens. Where precise location was wanted, the user may give approximate location. If no text matches the device's language, the default text arrives. A job whose condition is not met waits on that condition.

The question of the course is therefore a single sentence: **how does the platform fulfill this request?** There are four answers, and their names do not change throughout the course. The category's umbrella name is **fulfillment**.

- **full**: the platform gives a value, and the given value is the wanted one itself.
- **substitute**: the platform gives a value, but the given value is something other than the wanted one.
- **pending**: the platform gives no value yet; the request waits on a condition.
- **unmet**: the platform refuses the request; no value arrives and there is no condition to wait on.

In this course the `==` of the previous course does one job: it says whether the given value equals the wanted one, which separates full from substitute. The criterion of equality is not a category here; the core compares two strings, and the equality of strings is their content.

#### The Fulfillment Rule

The rule, not a label, gives the fulfillment. The helper file below sets up the rule and is repeated verbatim as the first Kotlin block of every lesson in the course. The `Ask` class carries three things: the label of the request, the wanted value, and the result returned by the platform model. The result is one of three subtypes of the sealed `Outcome` interface: `Given` says a value was given, `Waiting` says a condition is awaited, `Refused` says the request was refused.

```kotlin
// Fulfillment.kt — classifies how the platform fulfills a request
sealed interface Outcome {
    data class Given(val value: String) : Outcome
    data class Waiting(val on: String) : Outcome
    data class Refused(val reason: String) : Outcome
}

class Ask(val label: String, val wanted: String, val outcome: Outcome) {
    val fulfillment: String = when (outcome) {
        is Outcome.Waiting -> "pending"
        is Outcome.Refused -> "unmet"
        is Outcome.Given -> if (outcome.value == wanted) "full" else "substitute"
    }

    fun report() {
        val detail = when (outcome) {
            is Outcome.Given -> "given=${outcome.value}"
            is Outcome.Waiting -> "waiting-on=${outcome.on}"
            is Outcome.Refused -> "reason=${outcome.reason}"
        }
        println("$label: wanted=$wanted | $detail -> $fulfillment")
    }
}

fun tally(asks: List<Ask>) {
    for (name in listOf("full", "substitute", "pending", "unmet")) {
        println("$name: ${asks.count { it.fulfillment == name }}")
    }
}
```

`fulfillment` asks the question in order. If the result is `Waiting`, the fulfillment is pending. If it is `Refused`, it is unmet. If it is `Given`, it is full when the given value equals the wanted one and substitute otherwise. `tally` counts the four categories in a fixed order and also prints a category whose count is zero.

The measurement has one condition: each request carries a single wanted value, and the result is never written by hand. A line that writes the result directly, in the form `Ask("...", "X", Outcome.Given("X"))`, is not a measurement; it does no more than show the rule itself. The wanted value is written in the request's own word, while the result comes from a small Kotlin function that sets up how the platform fulfills that mechanism.

#### Eight Requests, Four Fulfillments

The measurement below builds eight requests from the four topics of the course: two from app components, three from the permission model, two from the resource system, and one from platform services. Four functions imitate four mechanisms of the platform. `startExplicit` opens the activities declared in the manifest file. `grant` first separates a permission into normal or dangerous, and for a dangerous permission waits on the user's choice. `pickResource` looks for the resource directory that matches the device's language and falls back to the default directory if there is none. `schedule` gives a job once its conditions are met, and collects the unmet conditions in sorted order.

```kotlin
// Main.kt — eight requests, four fulfillments
fun startExplicit(target: String, declared: Set<String>): Outcome =
    if (target in declared) Outcome.Given(target) else Outcome.Refused("not-in-manifest")

fun grant(permission: String, dangerous: Boolean, choice: String?): Outcome = when {
    !dangerous -> Outcome.Given(permission)
    choice == null -> Outcome.Waiting("user")
    choice == "deny" -> Outcome.Refused("user-denied")
    else -> Outcome.Given(choice)
}

fun pickResource(qualifier: String, available: List<String>): Outcome = when {
    "values-$qualifier" in available -> Outcome.Given("values-$qualifier")
    "values" in available -> Outcome.Given("values")
    else -> Outcome.Refused("no-resource")
}

fun schedule(work: String, needs: Set<String>, met: Set<String>): Outcome {
    val missing = (needs - met).sorted()
    return if (missing.isEmpty()) Outcome.Given(work) else Outcome.Waiting(missing.joinToString(","))
}

fun main() {
    val declared = setOf("MainActivity", "ProfileActivity")
    val folders = listOf("values", "values-de")
    val asks = listOf(
        Ask("explicit-start", "ProfileActivity", startExplicit("ProfileActivity", declared)),
        Ask("undeclared", "SettingsActivity", startExplicit("SettingsActivity", declared)),
        Ask("normal-permission", "INTERNET", grant("INTERNET", dangerous = false, choice = null)),
        Ask("dangerous-permission", "CAMERA", grant("CAMERA", dangerous = true, choice = null)),
        Ask("precise-location", "ACCESS_FINE_LOCATION",
            grant("ACCESS_FINE_LOCATION", dangerous = true, choice = "ACCESS_COARSE_LOCATION")),
        Ask("german-text", "values-de", pickResource("de", folders)),
        Ask("turkish-text", "values-tr", pickResource("tr", folders)),
        Ask("scheduled-work", "sync", schedule("sync", setOf("network", "charging"), setOf("charging"))),
    )
    for (ask in asks) {
        ask.report()
    }
    tally(asks)
}
```

```
explicit-start: wanted=ProfileActivity | given=ProfileActivity -> full
undeclared: wanted=SettingsActivity | reason=not-in-manifest -> unmet
normal-permission: wanted=INTERNET | given=INTERNET -> full
dangerous-permission: wanted=CAMERA | waiting-on=user -> pending
precise-location: wanted=ACCESS_FINE_LOCATION | given=ACCESS_COARSE_LOCATION -> substitute
german-text: wanted=values-de | given=values-de -> full
turkish-text: wanted=values-tr | given=values -> substitute
scheduled-work: wanted=sync | waiting-on=network -> pending
full: 3
substitute: 2
pending: 2
unmet: 1
```

Of the eight requests, three came out full, two substitute, two pending, and one unmet. This distribution is the binding number of the course; later lessons refer to it and do not change it. It is easy to read which question each line passed through. `explicit-start` asked for a declared activity and received the same one: full. `undeclared` asked for an undeclared activity and was refused; because there is no condition to wait on, it is unmet. `dangerous-permission` asked for a permission the user has not yet chosen and is waiting on `user`: pending. `precise-location` asked for precise location and the user chose approximate location; a value was given, but a different one: substitute. `turkish-text` asked for the Turkish resource directory, and because the device carries only `values` and `values-de`, it was given the default directory: substitute again. `scheduled-work` requires two conditions and one of them is met; the model collects the unmet conditions in sorted order and prints the first one, `network`.

**AC1.** The four functions simplify the rules of the platform: whether a real permission counts as normal or dangerous depends on the permission's definition, and the order of resource selection depends on all the qualifiers of the device configuration. The model sets up these rules of the platform; the platform itself was not measured.

#### Architecture Layers

Where does a request go? The `startActivity` an app writes, or a permission query, does not stay in app code alone; it passes through a chain of layers. The architecture of Android is ordered from the bottom up as follows.

- **Linux kernel.** It manages processes, memory, device drivers, and the foundation of the security mechanism. Each app runs in a separate process with a separate user ID; this separation is the basis of the permission mechanism above it. This is the kernel described in the Linux and System Administration curriculum.
- **Hardware abstraction layer (HAL).** It exposes hardware such as the camera, sensors, and audio to the upper layers through standard interfaces. The code above does not know a specific piece of hardware; it knows the interface.
- **Android runtime (ART).** It runs the compiled code of the app. Each app runs in its own runtime instance.
- **Native libraries.** These are libraries written in C and C++ that do work such as graphics, media, and databases; the framework often uses them.
- **Application framework.** This is the layer the app calls directly: activities, services, broadcast receivers, content providers, and resources are the app components this layer offers. The topics of this course live here.
- **System apps.** These are the apps that come with the device, such as phone, messaging, contacts, and settings. They are often the first face an app shows the user.

The app hands the request to the framework, the framework descends into the native libraries when needed, the runtime runs the code, and everything that touches hardware passes through the HAL and the kernel. This chain also explains why a fulfillment is not always in the app's hands: a request can be refused, made to wait, or turned into another value at one of the layers. A requested permission is asked of the user in the framework, requested hardware depends on the interface the HAL offers, and a requested API depends on the version of the framework on the device.

#### The Framework API and the Device's API Level

The framework arrives with the device's system version. Each version carries an **API level**, and the level at which an API was added is known. If an app calls an API on a device older than the level at which it was added, the call is not fulfilled. One solution to this problem is the compatibility library: it carries a counterpart of the framework API along with the app, and on the older device that counterpart runs. The counterpart often does not give the wanted thing exactly; it gives a similar behavior.

On the platform this decision is made in app code with a version check. The fragment below is a simplified example. Because it imports a platform class, it does not compile and no output is claimed for it.

```kotlin
// Platform code: framework call or compatibility counterpart by device API level
import android.os.Build

fun applyFeature() {
    if (Build.VERSION.SDK_INT >= 33) {
        // the framework API is called directly
    } else {
        // the counterpart in the compatibility library is called
    }
}
```

The model sets up this rule of the platform. The helper file below takes three inputs: the level at which the API was added, the device's level, and the APIs for which the compatibility library offers a counterpart. If the device's level is equal or higher, the framework API is given in the wanted form. If it is lower and a counterpart exists, what is given is the counterpart. If neither exists, the request is refused. The file has no output and is compiled together with the later measured blocks.

```kotlin
// Levels.kt — builds the framework call by device API level
fun callFramework(name: String, since: Int, level: Int, compat: Set<String>): Outcome = when {
    level >= since -> Outcome.Given(name)
    name in compat -> Outcome.Given("$name-compat")
    else -> Outcome.Refused("no-api")
}
```

Three APIs are requested and the device's level is 31. The first API was added at 26, the second was added at 33 and has a compatibility counterpart, and the third was added at 34 and has no counterpart.

```kotlin
// Main.kt — framework API and device API level
fun main() {
    val level = 31
    val compat = setOf("ApiB")
    val asks = listOf(
        Ask("low-level-api", "ApiA", callFramework("ApiA", since = 26, level = level, compat = compat)),
        Ask("compatible-api", "ApiB", callFramework("ApiB", since = 33, level = level, compat = compat)),
        Ask("missing-api", "ApiC", callFramework("ApiC", since = 34, level = level, compat = compat)),
    )
    for (ask in asks) {
        ask.report()
    }
    tally(asks)
}
```

```
low-level-api: wanted=ApiA | given=ApiA -> full
compatible-api: wanted=ApiB | given=ApiB-compat -> substitute
missing-api: wanted=ApiC | reason=no-api -> unmet
full: 1
substitute: 1
pending: 0
unmet: 1
```

The same device and the same rule gave three different fulfillments. The first API exists on the device and was given as wanted. The second API does not exist on the device, but because the compatibility library carries its counterpart, what was given is `ApiB-compat`; the value is different from the wanted one, so it is substitute. The third API exists neither on the device nor in the compatibility library, and the request stayed unmet. The device's level and the presence of the compatibility library together determined the fulfillment, which is this lesson's criterion.

**AC2.** `ApiA`, `ApiB`, and `ApiC` are example names; their levels are model input. At which level a real API was added cannot be derived from this measurement.

#### The Bounding Measurement

The fulfillment rule sees the result and the equality, not the reason. This sentence has two sides, and the measurement below sets up both together. The first side is that the same fulfillment can come from two separate reasons: an activity declared in the manifest file and a normal permission are both full, but the first comes from the activity having been declared and the second from the permission being normal. The second side is that two different fulfillments can come from the same missing API: the same API is absent at the same level, and in one case a compatibility counterpart exists and in the other it does not.

```kotlin
// Main.kt — the rule does not see the reason
fun openDeclared(name: String, declared: Set<String>): Outcome =
    if (name in declared) Outcome.Given(name) else Outcome.Refused("not-in-manifest")

fun usePermission(name: String, normal: Set<String>): Outcome =
    if (name in normal) Outcome.Given(name) else Outcome.Waiting("user")

fun main() {
    val asks = listOf(
        Ask("declared-activity", "ProfileActivity", openDeclared("ProfileActivity", setOf("ProfileActivity"))),
        Ask("normal-permission", "INTERNET", usePermission("INTERNET", setOf("INTERNET"))),
        Ask("compat-present", "ApiD", callFramework("ApiD", since = 34, level = 31, compat = setOf("ApiD"))),
        Ask("compat-absent", "ApiD", callFramework("ApiD", since = 34, level = 31, compat = emptySet())),
    )
    for (ask in asks) {
        ask.report()
    }
    println(asks.filter { it.fulfillment == "full" }.map { it.label })
}
```

```
declared-activity: wanted=ProfileActivity | given=ProfileActivity -> full
normal-permission: wanted=INTERNET | given=INTERNET -> full
compat-present: wanted=ApiD | given=ApiD-compat -> substitute
compat-absent: wanted=ApiD | reason=no-api -> unmet
[declared-activity, normal-permission]
```

The last line shows the rule's view: it gathers the two full requests in a single list and does not say for which reason each one is full. In the same way, `compat-present` and `compat-absent` fail for the same `ApiD` at the same level; the only difference between them is whether the compatibility library carries a counterpart, and the rule sees only whether the given value exists and whether it equals the wanted one. The reason is written in the `reason=` and `waiting-on=` fields; the category does not use it. Knowing this limit helps when reading the measurement: if two lines have the same fulfillment, it does not follow that their reasons are the same.

**AC3.** This measurement covers two full requests, one substitute, and one unmet. Two pending requests from the same reason, or two unmet requests from different reasons, were not measured.

#### Summary

- This lesson's criterion: **fulfillment**. The platform answers every request with full, substitute, pending, or unmet, and the rule reads this from the given value. This lesson's fulfillment: **full**; the lesson's focus requests, the declared activity and the normal permission, were fulfilled in full; the compatibility fallback is a substitute and is not part of the focus request.
- The distribution of the eight requests came out 3/2/2/1: three full, two substitute, two pending, one unmet. The later lessons of the course refer to this number.
- The architecture layers, from the bottom up, are the Linux kernel, the hardware abstraction layer, the Android runtime together with the native libraries, the application framework, and the system apps.
- The device's API level leaves a framework API full, substitute if a compatibility library exists, and unmet if that is missing too.
- The fulfillment rule sees the result and the equality, not the reason: the same fulfillment can come from two reasons, and two different fulfillments can come from the same missing API.

#### Next Step

One of the eight requests stayed unmet: an undeclared activity never opened. The next lesson, Activities, reads that request closely. It measures with the same rule what an activity is, how it is declared in the manifest file, and whether another app can open it.

### Activities

Source: https://academia.sh/en/courses/android-platform-and-tools/activities

Measures what an activity is, how it is declared in the manifest file, and how the platform fulfills a request for an activity that is undeclared or closed to other apps.

In the previous lesson one of the eight requests stayed unmet: an activity whose name does not appear in the manifest file never opened. This lesson reads the mechanism behind that line. An activity is the app component that interacts with the user and usually owns a single screen. An app can carry several activities: a sign-in screen, a profile screen, a settings screen. The platform learns which of them exist not by looking at the app's code but by looking at the manifest file.

The criterion of the lesson is again **fulfillment**: when an activity is requested, how does the platform fulfill the request? The same core rule runs. If the given value equals the wanted one, the fulfillment is full; if it does not, substitute; if the platform waits on a condition, pending; if it refuses, unmet.

```kotlin
// Fulfillment.kt — classifies how the platform fulfills a request
sealed interface Outcome {
    data class Given(val value: String) : Outcome
    data class Waiting(val on: String) : Outcome
    data class Refused(val reason: String) : Outcome
}

class Ask(val label: String, val wanted: String, val outcome: Outcome) {
    val fulfillment: String = when (outcome) {
        is Outcome.Waiting -> "pending"
        is Outcome.Refused -> "unmet"
        is Outcome.Given -> if (outcome.value == wanted) "full" else "substitute"
    }

    fun report() {
        val detail = when (outcome) {
            is Outcome.Given -> "given=${outcome.value}"
            is Outcome.Waiting -> "waiting-on=${outcome.on}"
            is Outcome.Refused -> "reason=${outcome.reason}"
        }
        println("$label: wanted=$wanted | $detail -> $fulfillment")
    }
}

fun tally(asks: List<Ask>) {
    for (name in listOf("full", "substitute", "pending", "unmet")) {
        println("$name: ${asks.count { it.fulfillment == name }}")
    }
}
```

#### An Activity in the Manifest File

Every activity is declared in the manifest file with an `<activity>` element. Before starting an activity, the platform looks at this declaration. If the activity is not declared, the call ends with an exception and the activity does not open. The launcher screen also comes from the same file: an intent filter that carries the `MAIN` action and the `LAUNCHER` category makes the activity visible in the launcher.

```xml
<application>
    <activity android:name=".MainActivity" android:exported="true">
        <intent-filter>
            <action android:name="android.intent.action.MAIN" />
            <category android:name="android.intent.category.LAUNCHER" />
        </intent-filter>
    </activity>
    <activity android:name=".ProfileActivity" android:exported="false" />
</application>
```

In code, starting an activity is a single line. The platform compares the call with the manifest file.

```kotlin
// Platform code: starts an activity in the same app with an explicit intent
import android.content.Intent

fun openProfile() {
    startActivity(Intent(this, ProfileActivity::class.java))
}
```

The model sets up this rule of the platform. The measurement below works with three declared activities and five requested ones. `launchActivity` looks at one thing only: whether the requested name is in the set of declared names. If it is, the wanted activity is given; if not, the request is refused and the reason is written as `not-in-manifest`.

```kotlin
// Main.kt — requests for declared and undeclared activities
fun launchActivity(target: String, declared: Set<String>): Outcome =
    if (target in declared) Outcome.Given(target) else Outcome.Refused("not-in-manifest")

fun main() {
    val declared = setOf("MainActivity", "ProfileActivity", "DetailActivity")
    val asks = listOf(
        Ask("main-screen", "MainActivity", launchActivity("MainActivity", declared)),
        Ask("profile", "ProfileActivity", launchActivity("ProfileActivity", declared)),
        Ask("detail", "DetailActivity", launchActivity("DetailActivity", declared)),
        Ask("settings", "SettingsActivity", launchActivity("SettingsActivity", declared)),
        Ask("typo", "ProfilActivity", launchActivity("ProfilActivity", declared)),
    )
    for (ask in asks) {
        ask.report()
    }
    tally(asks)
}
```

```text
main-screen: wanted=MainActivity | given=MainActivity -> full
profile: wanted=ProfileActivity | given=ProfileActivity -> full
detail: wanted=DetailActivity | given=DetailActivity -> full
settings: wanted=SettingsActivity | reason=not-in-manifest -> unmet
typo: wanted=ProfilActivity | reason=not-in-manifest -> unmet
full: 3
substitute: 0
pending: 0
unmet: 2
```

Of the five requests, three came out full and two unmet. The three declared activities were given as wanted. The `SettingsActivity` class may exist in code, but because it is not written in the manifest file, the platform does not recognize it. `ProfilActivity` is a typo and is refused by the same path. The fulfillment of both is unmet; neither substitute nor pending appears, because the platform does not say "let me open a similar activity" or "let a condition be met first". For an undeclared activity there is also no condition to wait on.

**AC1.** The model does not throw the exception of the real call; it returns the refusal as `Refused`. On the platform the start call ends with an exception, and in the model the same event becomes an unmet line.

#### The Launcher Filter

Which activity opens when the user taps the app icon? The answer does not come from code; it comes from the filters in the manifest file. The activity that carries the `LAUNCHER` category is the entry point of the launcher. The activity a developer means as "this is the main screen" and the activity that actually opens in the launcher can differ: if the filter is placed on the wrong activity, or on none, the fulfillment changes.

In the measurement below the wanted activity is always `MainActivity`; the placement of the filters changes. `launcherEntry` sorts by name the activities whose categories include `LAUNCHER` and gives the first. If there is none, it refuses the request with the reason `no-launcher`.

```kotlin
// Main.kt — fulfillment by the placement of the launcher filter
fun launcherEntry(filters: Map<String, Set<String>>): Outcome {
    val entries = filters.filter { "LAUNCHER" in it.value }.keys.sorted()
    return if (entries.isEmpty()) Outcome.Refused("no-launcher") else Outcome.Given(entries.first())
}

fun main() {
    val onMain = mapOf("MainActivity" to setOf("MAIN", "LAUNCHER"), "ProfileActivity" to emptySet<String>())
    val none = mapOf("MainActivity" to setOf("MAIN"), "ProfileActivity" to emptySet<String>())
    val onProfile = mapOf("MainActivity" to setOf("MAIN"), "ProfileActivity" to setOf("MAIN", "LAUNCHER"))
    val asks = listOf(
        Ask("filter-on-main", "MainActivity", launcherEntry(onMain)),
        Ask("no-filter", "MainActivity", launcherEntry(none)),
        Ask("filter-on-profile", "MainActivity", launcherEntry(onProfile)),
    )
    for (ask in asks) {
        ask.report()
    }
    tally(asks)
}
```

```text
filter-on-main: wanted=MainActivity | given=MainActivity -> full
no-filter: wanted=MainActivity | reason=no-launcher -> unmet
filter-on-profile: wanted=MainActivity | given=ProfileActivity -> substitute
full: 1
substitute: 1
pending: 0
unmet: 1
```

The same request got three different fulfillments from three placements of the filter. With the filter on `MainActivity`, the request was fulfilled fully. With the filter on no activity, the app does not appear in the launcher and the request stayed unmet. With the filter on `ProfileActivity`, the launcher opens an activity, but not the wanted one, another; this is substitute. The third line shows why the activity the developer takes to be the main screen and the activity the user sees can come apart.

**AC2.** The model looks only at the category of the filters. A real filter matches the action, the category, and the data together; that matching is measured in the lesson Intents and Intent Filters.

#### The Result Returned to the Caller

An activity can be opened at another activity's request and, when its work is done, can return a result to the caller. Picker screens, confirmation screens, and form screens work this way. The calling side receives the result in a callback; the result has two parts: a result code and an optional data intent. The result code is one of two ready-made values: `RESULT_OK` or `RESULT_CANCELED`. If the user leaves the activity with the back button, the platform returns `RESULT_CANCELED` when the activity has set nothing.

```kotlin
// Platform code: starts an activity that returns a result
import androidx.activity.result.contract.ActivityResultContracts

val pick = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
    if (result.resultCode == RESULT_OK) {
        showSelection(result.data)
    }
}
```

What the caller wants is a selection. What is given depends on what the user does. In the measurement below the wanted value is always `ok`. The user saves, presses the back button, the activity crashes, or the user has not yet decided. `resultOf` turns these four cases into four results: if saved, `ok`; with the back button, `cancelled`; on a crash, the refusal reason `activity-crashed`; with no decision, `user` is awaited.

```kotlin
// Main.kt — four states of an activity result
fun resultOf(event: String): Outcome = when (event) {
    "save" -> Outcome.Given("ok")
    "back" -> Outcome.Given("cancelled")
    "crash" -> Outcome.Refused("activity-crashed")
    else -> Outcome.Waiting("user")
}

fun main() {
    val events = listOf("save", "back", "crash", "thinking")
    val asks = events.map { Ask("result-$it", "ok", resultOf(it)) }
    for (ask in asks) {
        ask.report()
    }
    tally(asks)
}
```

```text
result-save: wanted=ok | given=ok -> full
result-back: wanted=ok | given=cancelled -> substitute
result-crash: wanted=ok | reason=activity-crashed -> unmet
result-thinking: wanted=ok | waiting-on=user -> pending
full: 1
substitute: 1
pending: 1
unmet: 1
```

The four cases produced each of the four fulfillments once. The point to take from this for the lesson is that `cancelled` is not an error. The platform answers a user who presses the back button with a result code, and the calling code has to treat this as a normal path. A caller that writes only the `RESULT_OK` branch is code that does not handle the substitute line. A crashed activity returns not even a result to the caller; what the caller sees is "no result", and this is modeled in the unmet line. The pending line is not a real result; it says the user has not yet decided.

**AC3.** The model joins the result code and the given value in a single string. The separate `resultCode` and `data` pair of the real platform, and the delivery of the result when the calling activity is recreated after process death, were not measured.

#### When the exported Declaration Is Missing

The bounding measurement takes the `android:exported` value as an input. The real rule has one more step: if an activity carries an intent filter, the `exported` value has to be written explicitly in the declaration. If there is a filter and the value is not written, the build system rejects the manifest file and the app cannot be installed. If there is no filter and the value is not written, the activity is counted as closed to other apps.

The logic of the rule is this: an activity that carries a filter is written to be found by others, but whether that findability is really wanted is left to the author's explicit decision. The measurement below takes the wanted thing as `install`. What is given is whether the declaration passes the rule.

```kotlin
// Main.kt — the exported declaration and the filter
fun checkEntry(hasFilter: Boolean, exportedWritten: Boolean?): Outcome =
    if (hasFilter && exportedWritten == null) Outcome.Refused("exported-missing")
    else Outcome.Given("install")

fun main() {
    val asks = listOf(
        Ask("filter-present-exported-missing", "install", checkEntry(true, null)),
        Ask("filter-present-exported-true", "install", checkEntry(true, true)),
        Ask("filter-absent-exported-missing", "install", checkEntry(false, null)),
        Ask("filter-absent-exported-false", "install", checkEntry(false, false)),
    )
    for (ask in asks) {
        ask.report()
    }
    tally(asks)
}
```

```text
filter-present-exported-missing: wanted=install | reason=exported-missing -> unmet
filter-present-exported-true: wanted=install | given=install -> full
filter-absent-exported-missing: wanted=install | given=install -> full
filter-absent-exported-false: wanted=install | given=install -> full
full: 3
substitute: 0
pending: 0
unmet: 1
```

The only refused case is an activity that carries a filter and does not write the `exported` value. This refusal comes not while the app runs but at install or build time; so the error reaches the developer before it reaches the user. The habit this rule brings: for every activity with a filter, write the `exported` value deliberately. The launcher activity has to be `true`, because the launcher is a system component outside the app.

**AC4.** The model does not tell from which version the build system and the platform perform this check. The lowest target version at which the rule is applied, and the implicit default in older versions, were not measured.

#### The Bounding Measurement

The fulfillment rule does not see the reason, but there is a place where the reason changes: `android:exported`. This attribute says whether the activity can be called from other apps. If it is `false`, only the code of the same app can open the activity. A declared activity that is closed to other apps gets two different fulfillments depending on who the caller is. That is, the same activity, the same declaration, a different caller: the difference between full and unmet comes from the caller's package.

The measurement below sets this up with the `openFrom` function. If the activity is not in the manifest file, the refusal reason is `not-in-manifest`; if it is closed to other apps and the caller is another app, the reason is `not-exported`. Apart from these, the wanted activity is given.

```kotlin
// Main.kt — an activity closed to other apps and the calling app
fun openFrom(target: String, exported: Map<String, Boolean>, caller: String, owner: String): Outcome {
    val isExported = exported[target] ?: return Outcome.Refused("not-in-manifest")
    return if (!isExported && caller != owner) Outcome.Refused("not-exported") else Outcome.Given(target)
}

fun main() {
    val exported = mapOf("ProfileActivity" to false, "ShareActivity" to true)
    val owner = "app.main"
    val asks = listOf(
        Ask("same-app", "ProfileActivity", openFrom("ProfileActivity", exported, "app.main", owner)),
        Ask("other-app", "ProfileActivity", openFrom("ProfileActivity", exported, "app.other", owner)),
        Ask("exported-open", "ShareActivity", openFrom("ShareActivity", exported, "app.other", owner)),
        Ask("undeclared", "SettingsActivity", openFrom("SettingsActivity", exported, "app.other", owner)),
    )
    for (ask in asks) {
        ask.report()
    }
    println(asks.filter { it.fulfillment == "unmet" }.map { it.label })
}
```

```text
same-app: wanted=ProfileActivity | given=ProfileActivity -> full
other-app: wanted=ProfileActivity | reason=not-exported -> unmet
exported-open: wanted=ShareActivity | given=ShareActivity -> full
undeclared: wanted=SettingsActivity | reason=not-in-manifest -> unmet
[other-app, undeclared]
```

`same-app` and `other-app` request the same activity; the first is full, the second unmet, and the difference is only the caller's package. `exported-open` requests an exported activity from another app and is fulfilled fully. The last line gathers the two unmet requests in one list: their fulfillment is the same, their reasons are different. The first comes from an activity that is in the manifest file but closed to other apps, the second from an activity that is not in the manifest file at all. The fulfillment category does not carry this distinction; the distinction is in the `reason=` field.

**AC5.** The model takes the `exported` attribute value as an input. On the real platform the default value depends on whether a filter is present, and that was not measured here.

#### Summary

- This lesson's criterion: **fulfillment**. An activity is the app component that interacts with the user, and the platform recognizes it from the `<activity>` element in the manifest file. This lesson's fulfillment: **unmet**; the measured request is an undeclared or non-exported activity, and the platform does not open a nearby activity instead.
- A request for an undeclared activity stays unmet; a typo is refused by the same path. Five requests gave 3 full and 2 unmet.
- The placement of the launcher filter decides the fulfillment: full on `MainActivity`, unmet on none, substitute on `ProfileActivity`.
- An activity with `android:exported="false"` is fulfilled fully from the same app and unmet from another app.
- Two unmet requests can come from different reasons; the rule does not see the reason, and the reason is kept in a separate field.

#### Next Step

A declared activity that has opened does not end its life there: the user rotates the screen, switches to another app, comes back. The next lesson, The Activity Lifecycle, measures with the same rule how the state of the activity is kept and how it is lost across these transitions.

### The Activity Lifecycle

Source: https://academia.sh/en/courses/android-platform-and-tools/activity-lifecycle

Measures the lifecycle callbacks of an activity, which state survives a configuration change and process death, and how a UI request that reaches a stopped activity is fulfilled.

A declared activity opened, and the user rotated the screen. The activity is destroyed and rebuilt, and the fields of the new activity carry the values of the first setup. This lesson measures which value comes back after the rebuild. The criterion is again **fulfillment**, but here the request itself asks for a value back: the counter that was `5` before the rotation, is it requested from the platform as `5` afterwards, and does it arrive as `5`?

The measure of this lesson is separate from the lifecycle measures of the earlier courses. In the PHP course "deferred" was kept apart from "pending"; in the lifecycle lesson of the Swift Language course the question was when an expression is evaluated. The question here is a different one: what is measured is not when the request is evaluated but how it is fulfilled in the lifecycle state the activity is in at that moment.

```kotlin
// Fulfillment.kt — classifies how the platform fulfills a request
sealed interface Outcome {
    data class Given(val value: String) : Outcome
    data class Waiting(val on: String) : Outcome
    data class Refused(val reason: String) : Outcome
}

class Ask(val label: String, val wanted: String, val outcome: Outcome) {
    val fulfillment: String = when (outcome) {
        is Outcome.Waiting -> "pending"
        is Outcome.Refused -> "unmet"
        is Outcome.Given -> if (outcome.value == wanted) "full" else "substitute"
    }

    fun report() {
        val detail = when (outcome) {
            is Outcome.Given -> "given=${outcome.value}"
            is Outcome.Waiting -> "waiting-on=${outcome.on}"
            is Outcome.Refused -> "reason=${outcome.reason}"
        }
        println("$label: wanted=$wanted | $detail -> $fulfillment")
    }
}

fun tally(asks: List<Ask>) {
    for (name in listOf("full", "substitute", "pending", "unmet")) {
        println("$name: ${asks.count { it.fulfillment == name }}")
    }
}
```

#### Lifecycle Callbacks

An activity passes through certain states, and the platform calls a lifecycle callback at each transition. The order of setup is as follows.

- `onCreate`: the activity is set up and the UI is prepared. If a saved instance state exists, it arrives here.
- `onStart`: the activity becomes visible on the screen.
- `onResume`: the activity is in the foreground and interacts with the user.
- `onPause`: the activity leaves the foreground; another activity is coming to the front.
- `onStop`: the activity is no longer visible.
- `onDestroy`: the activity is destroyed.

On a configuration change such as rotating the screen, the platform first tears the activity down with `onPause`, `onStop`, and `onDestroy`, then rebuilds it with the new configuration through `onCreate`, `onStart`, and `onResume`. The fields disappear in between. `onSaveInstanceState`, which is called before the teardown, holds a small bundle; the rebuilt activity receives this bundle in `onCreate`.

```kotlin
// Platform code: only the counter is written to the saved instance state
import android.os.Bundle

override fun onSaveInstanceState(outState: Bundle) {
    super.onSaveInstanceState(outState)
    outState.putInt("counter", counter)
}
```

Here `counter` is saved; no other field such as `draft` is written. The system can also end an entire background process when memory runs low. This process death depends on the same bundle: a field that is not saved does not come back.

The model sets up this rule of the platform. The helper file carries a single rule: if the key is in the saved bundle, that value is given; if not, the field's default value is given. The input values are plain strings.

```kotlin
// Restore.kt — restores a field from the saved instance state
fun restoreField(key: String, saved: Map<String, String>, defaults: Map<String, String>): Outcome =
    Outcome.Given(saved[key] ?: defaults.getValue(key))
```

#### Three Fields on Rotation

Before the rotation three fields carry a value: `counter` is 5, `name` is Ada, `draft` is hello. `onSaveInstanceState` writes only the first two. The wanted value is always the one from before the rotation.

```kotlin
// Main.kt — how fields come back on a configuration change
fun main() {
    val before = mapOf("counter" to "5", "name" to "Ada", "draft" to "hello")
    val saved = mapOf("counter" to "5", "name" to "Ada")
    val defaults = mapOf("counter" to "0", "name" to "empty", "draft" to "empty")
    val asks = before.map { (key, value) -> Ask(key, value, restoreField(key, saved, defaults)) }
    for (ask in asks) {
        ask.report()
    }
    tally(asks)
}
```

```text
counter: wanted=5 | given=5 -> full
name: wanted=Ada | given=Ada -> full
draft: wanted=hello | given=empty -> substitute
full: 2
substitute: 1
pending: 0
unmet: 0
```

The two saved fields came back as wanted: full. The `draft` field, which was not saved, arrived with its default value: a value was given, but it is `empty`, not `hello`; substitute. The user sees that the text typed before the rotation is gone, and the code throws no error. For this reason substitute produces a quieter problem than unmet. An unmet request stops the code; a substitute flows on and carries the wrong value.

**AC1.** The model takes a single default value. The initial value of a real field depends on the setup in `onCreate`, and mechanisms such as a `ViewModel`, which carry state across a configuration change, were not measured in this lesson.

#### Three Events, Three Fulfillments

The same bundle is returned from by different events. Rotation and process death keep the bundle. If the user closes the activity themselves, for example with the back button, the platform discards the state: the saved bundle is also dropped. The model below takes the event as an input as well.

```kotlin
// Main.kt — keeping state by event
fun restoreAfter(event: String, key: String, saved: Map<String, String>, defaults: Map<String, String>): Outcome =
    if (event == "user-closed") Outcome.Refused("state-discarded") else restoreField(key, saved, defaults)

fun main() {
    val saved = mapOf("counter" to "5")
    val defaults = mapOf("counter" to "0", "draft" to "empty")
    val asks = listOf(
        Ask("rotation-counter", "5", restoreAfter("rotation", "counter", saved, defaults)),
        Ask("death-draft", "hello", restoreAfter("process-death", "draft", saved, defaults)),
        Ask("close-counter", "5", restoreAfter("user-closed", "counter", saved, defaults)),
    )
    for (ask in asks) {
        ask.report()
    }
    tally(asks)
}
```

```text
rotation-counter: wanted=5 | given=5 -> full
death-draft: wanted=hello | given=empty -> substitute
close-counter: wanted=5 | reason=state-discarded -> unmet
full: 1
substitute: 1
pending: 0
unmet: 1
```

On rotation the saved field came back fully. On process death the field that was not saved returned as a substitute. When the user closed the activity, the state did not come back at all: unmet. Closing is not an error, it is the user's wish; the platform does not carry the state left over from a closed activity into the next launch.

**AC2.** The model separates the three events only by these names. Under which condition the system ends an activity, and the size limit of the bundle, were not measured.

#### Callback Order

The order of the lifecycle callbacks changes with the event that reaches the activity. When the order departs from what is expected, the most common mistake is that the work meant for a callback never runs. This section measures four events in turn. The wanted value is the full chain described in the section on the order of setup; the given value is the callbacks the platform really calls for that event.

An app can choose to handle a configuration change itself. In that case the activity is not destroyed; only `onConfigurationChanged` is called. The choice is made in the manifest file with the `configChanges` attribute.

```xml
<activity android:name=".EditorActivity"
          android:configChanges="orientation|screenSize" />
```

The model sets up four events. `open` is the first launch. `rotate` is the default configuration change. `rotate-self-handled` is the same event with `configChanges` declared. `process-killed` is the system closing the background process; in this case a call to `onDestroy` is not guaranteed to arrive.

```kotlin
// Main.kt — callback chain by event
fun callbacksFor(event: String): Outcome = when (event) {
    "open" -> Outcome.Given("onCreate>onStart>onResume")
    "rotate" -> Outcome.Given("onPause>onStop>onDestroy>onCreate>onStart>onResume")
    "rotate-self-handled" -> Outcome.Given("onConfigurationChanged")
    else -> Outcome.Refused("onDestroy-not-guaranteed")
}

fun main() {
    val asks = listOf(
        Ask("open", "onCreate>onStart>onResume", callbacksFor("open")),
        Ask("rotate", "onPause>onStop>onDestroy>onCreate>onStart>onResume", callbacksFor("rotate")),
        Ask("rotate-self-handled", "onPause>onStop>onDestroy>onCreate>onStart>onResume", callbacksFor("rotate-self-handled")),
        Ask("process-killed", "onDestroy", callbacksFor("process-killed")),
    )
    for (ask in asks) {
        ask.report()
    }
    tally(asks)
}
```

```text
open: wanted=onCreate>onStart>onResume | given=onCreate>onStart>onResume -> full
rotate: wanted=onPause>onStop>onDestroy>onCreate>onStart>onResume | given=onPause>onStop>onDestroy>onCreate>onStart>onResume -> full
rotate-self-handled: wanted=onPause>onStop>onDestroy>onCreate>onStart>onResume | given=onConfigurationChanged -> substitute
process-killed: wanted=onDestroy | reason=onDestroy-not-guaranteed -> unmet
full: 2
substitute: 1
pending: 0
unmet: 1
```

The first two events gave the wanted chain exactly. The `rotate-self-handled` line came out substitute: because the app took over the configuration change, the activity was not destroyed, so the fields stayed in place. Here the substitute is harmless, even a wanted result; but a setup done in `onCreate` or a save done in `onSaveInstanceState` never runs on this path. Every activity that declares `configChanges` is obliged to reload the configuration-sensitive resources itself. The fourth line is unmet: code that relies on `onDestroy` for cleanup cannot do that cleanup when the process ends all at once. Data that has to last must be saved in `onStop` or earlier.

**AC3.** The model reduces the `configChanges` values to a single logical flag. The real attribute names each configuration type separately; a type that is not declared still destroys the activity. Which type leads to destruction under which condition was not measured.

#### A Translucent Activity on Top

The `onStop` callback means the activity is no longer visible at all. When a new activity is opened on top, this is not always true: if the new activity does not cover the screen entirely, for example if it is opened in the form of a dialog or with a translucent theme, the activity underneath stays visible. In that case the activity underneath receives only `onPause`; `onStop` is not called.

```kotlin
// Platform code: declaring an activity with a translucent theme
// <activity android:name=".ConfirmActivity"
//           android:theme="@style/ConfirmDialogTheme" />
```

The measurement below gives the callbacks that reach the activity underneath according to how the top activity covers it. The wanted value is `onPause>onStop`.

```kotlin
// Main.kt — top activity and the callbacks of the activity below
fun belowCallbacks(coverage: String): Outcome = when (coverage) {
    "full-screen" -> Outcome.Given("onPause>onStop")
    "dialog" -> Outcome.Given("onPause")
    "same-task-same-screen" -> Outcome.Waiting("top-activity-closing")
    else -> Outcome.Refused("unknown-coverage")
}

fun main() {
    val asks = listOf("full-screen", "dialog", "same-task-same-screen", "none").map {
        Ask("top-$it", "onPause>onStop", belowCallbacks(it))
    }
    for (ask in asks) {
        ask.report()
    }
    tally(asks)
}
```

```text
top-full-screen: wanted=onPause>onStop | given=onPause>onStop -> full
top-dialog: wanted=onPause>onStop | given=onPause -> substitute
top-same-task-same-screen: wanted=onPause>onStop | waiting-on=top-activity-closing -> pending
top-none: wanted=onPause>onStop | reason=unknown-coverage -> unmet
full: 1
substitute: 1
pending: 1
unmet: 1
```

When a full-screen activity opened, the activity underneath was stopped: full. When an activity in the form of a dialog opened, the activity underneath received only `onPause` and was not stopped: substitute. The activity underneath is still visible on the screen, and it does not release the resources that would be released in `onStop`. This difference explains why code that closes resources such as the camera or location updates in `onStop` sometimes fails to do so: while a dialog is open, `onStop` never arrives. The third line shows another use: a state that is awaited until the top activity closes appears as pending.

**AC4.** The model separates the way of covering with four names. In multi-window use and in picture-in-picture mode, the visibility of the activity underneath and which callbacks arrive were not measured.

#### The Bounding Measurement

The second effect of the lifecycle is the state in which a request is made. Code that wants to update the UI gets a different fulfillment depending on the state of the activity. An activity in the foreground applies the update at once. A stopped activity is not yet visible; the update waits until the activity is started again. An update sent to a destroyed activity goes nowhere.

```kotlin
// Main.kt — updating the UI by lifecycle state
fun updateUi(state: String): Outcome = when (state) {
    "resumed" -> Outcome.Given("updated")
    "stopped" -> Outcome.Waiting("started-state")
    else -> Outcome.Refused("no-activity")
}

fun main() {
    val asks = listOf(
        Ask("foreground", "updated", updateUi("resumed")),
        Ask("stopped", "updated", updateUi("stopped")),
        Ask("destroyed", "updated", updateUi("destroyed")),
    )
    for (ask in asks) {
        ask.report()
    }
    tally(asks)
    println(asks.filter { it.fulfillment == "pending" }.map { it.label })
}
```

```text
foreground: wanted=updated | given=updated -> full
stopped: wanted=updated | waiting-on=started-state -> pending
destroyed: wanted=updated | reason=no-activity -> unmet
full: 1
substitute: 0
pending: 1
unmet: 1
[stopped]
```

The same update request got three different fulfillments in three states. The pending line matters: `stopped` was neither refused nor deferred; it waits on a specific condition, `started-state`, and will be applied when the condition arrives. This is the activity counterpart of the distinction in the PHP course: pending is a request whose condition has a name.

**AC5.** The model separates three states; `started` and `paused` did not get a line of their own. What exactly the visibility is in these two states changes with the platform version and with multi-window use.

#### Summary

- This lesson's criterion: **fulfillment**. A request is fulfilled according to the lifecycle state of the activity. This lesson's fulfillment: **substitute**; a field that was not saved comes back with its default value without an error.
- On a configuration change the saved fields came back full, and the field that was not saved came back as a substitute with its default value: 2 full and 1 substitute.
- When the user closes the activity, the state is discarded; the same request stays unmet.
- A UI request that reaches a stopped activity stays pending and carries its condition by name: `started-state`.
- A substitute is silent, an unmet request is loud: the code goes on with the wrong value without raising an error.

#### Next Step

The state of an activity is kept with `onSaveInstanceState`; the parts of a screen carry lifecycles of their own. The next lesson, Fragments, measures how fragments inside an activity are added and how a fragment transaction is fulfilled while the host activity is stopped.

### Fragments

Source: https://academia.sh/en/courses/android-platform-and-tools/fragments

Measures where a fragment sits inside an activity, how FragmentManager transactions work, and how a fragment transaction is fulfilled while the host activity is stopped or has already saved its state.

The bundle an activity saves is small, and if the whole screen is loaded into a single activity, every change affects the entire screen. A fragment splits that load. A fragment is a reusable UI section that lives inside an activity and has its own UI and its own lifecycle. A list screen and a detail screen can be two fragments; on a phone they are shown one after the other in the same activity, on a large screen side by side.

The life of a fragment depends on its host activity. The question of this lesson is therefore related to the previous one: when a fragment transaction is requested, how does the platform **fulfill** that request according to the state of the host?

```kotlin
// Fulfillment.kt — classifies how the platform fulfills a request
sealed interface Outcome {
    data class Given(val value: String) : Outcome
    data class Waiting(val on: String) : Outcome
    data class Refused(val reason: String) : Outcome
}

class Ask(val label: String, val wanted: String, val outcome: Outcome) {
    val fulfillment: String = when (outcome) {
        is Outcome.Waiting -> "pending"
        is Outcome.Refused -> "unmet"
        is Outcome.Given -> if (outcome.value == wanted) "full" else "substitute"
    }

    fun report() {
        val detail = when (outcome) {
            is Outcome.Given -> "given=${outcome.value}"
            is Outcome.Waiting -> "waiting-on=${outcome.on}"
            is Outcome.Refused -> "reason=${outcome.reason}"
        }
        println("$label: wanted=$wanted | $detail -> $fulfillment")
    }
}

fun tally(asks: List<Ask>) {
    for (name in listOf("full", "substitute", "pending", "unmet")) {
        println("$name: ${asks.count { it.fulfillment == name }}")
    }
}
```

#### FragmentManager Transactions

Fragments are managed with `FragmentManager`. A transaction starts with `beginTransaction`, consists of one or several steps, and ends with `commit`. The most common steps are `add` and `replace`: the first adds a fragment to a container, the second swaps the fragment in the container for another. If the transaction is added to the back stack with `addToBackStack`, the back button undoes it.

```kotlin
// Platform code: adds a fragment to the container and records it on the back stack
supportFragmentManager.beginTransaction()
    .replace(R.id.container, DetailFragment())
    .addToBackStack(null)
    .commit()
```

The model sets up this rule of the platform. `commit` does not apply the transaction at once; it looks at the state of the host. If the host is started, the transaction is applied. If the host is stopped, the platform queues the transaction and holds it until the host is started again. The model builds this order from two inputs: the name of the transaction and the state of the host.

```kotlin
// Main.kt — fragment transaction by host state
fun commit(op: String, hostState: String): Outcome =
    if (hostState == "stopped") Outcome.Waiting("host-started") else Outcome.Given(op)

fun main() {
    val add = "add:ListFragment"
    val replace = "replace:DetailFragment"
    val asks = listOf(
        Ask("add-foreground", add, commit(add, "resumed")),
        Ask("replace-foreground", replace, commit(replace, "resumed")),
        Ask("add-stopped", add, commit(add, "stopped")),
        Ask("replace-stopped", replace, commit(replace, "stopped")),
    )
    for (ask in asks) {
        ask.report()
    }
    tally(asks)
}
```

```text
add-foreground: wanted=add:ListFragment | given=add:ListFragment -> full
replace-foreground: wanted=replace:DetailFragment | given=replace:DetailFragment -> full
add-stopped: wanted=add:ListFragment | waiting-on=host-started -> pending
replace-stopped: wanted=replace:DetailFragment | waiting-on=host-started -> pending
full: 2
substitute: 0
pending: 2
unmet: 0
```

In the foreground both transactions were applied as wanted: full. While the host was stopped, both waited, and the condition both waited on is the same: `host-started`. Waiting is not refusing; the transaction is not lost, and it is applied when the host is started again. The fulfillment of this lesson is therefore **pending**: the typical result of a fragment transaction is a wait that carries its condition by name.

**AC1.** The model reduces the host state to two values. The real platform manages the transaction queue separately among `created`, `started`, and `resumed`; this distinction was not measured.

#### The Back Stack and the Back Button

`replace` discards the old fragment in the container. In a `replace` transaction that was not added to the back stack, the back button does not return to the old fragment: it leaves the activity itself. What the user expects is the previous screen.

```kotlin
// Main.kt — back stack and back button
fun goBack(previous: String, addedToBackStack: Boolean): Outcome =
    if (addedToBackStack) Outcome.Given(previous) else Outcome.Given("activity-exit")

fun main() {
    val asks = listOf(
        Ask("with-stack-back", "ListFragment", goBack("ListFragment", addedToBackStack = true)),
        Ask("without-stack-back", "ListFragment", goBack("ListFragment", addedToBackStack = false)),
    )
    for (ask in asks) {
        ask.report()
    }
    tally(asks)
}
```

```text
with-stack-back: wanted=ListFragment | given=ListFragment -> full
without-stack-back: wanted=ListFragment | given=activity-exit -> substitute
full: 1
substitute: 1
pending: 0
unmet: 0
```

The transaction added to the back stack brought `ListFragment` back: full. A value also came for the transaction that was not added, but not the wanted one: `activity-exit`. The back button did something, but not what the user wanted; this is a substitute. The detail of the back stack, together with the back stack across activities, is taken up again in the last lesson of this course.

**AC2.** The model keeps a single previous fragment. The real back stack carries a list, and how many transactions are undone at once depends on the `popBackStack` call.

#### Separate Lifetimes of a Fragment and Its View

A fragment has two lifetimes, and they do not end at the same moment. The fragment itself, that is the object, keeps living while it waits on the back stack. The view of the fragment, however, is destroyed with `onDestroyView` the moment the fragment goes onto the back stack, and is rebuilt with `onCreateView` when the user returns. For this reason every reference you hold to the view goes stale while the fragment is on the back stack: the object exists, but the view it points to no longer does.

This difference decides between which callbacks the code in a fragment that reaches the UI is safe. Access to the view lies between `onCreateView` and `onDestroyView`. An observer attached to the fragment should be bound to the lifetime of the view.

```kotlin
// Platform code: binds the observer to the view lifetime, not the fragment
import androidx.lifecycle.Observer

override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    viewModel.items.observe(viewLifecycleOwner, Observer { items ->
        showItems(items)
    })
}
```

In the measurement the wanted value is always "update the UI". If the fragment has not yet built a view, the request waits for `onCreateView`. If the view is built, the request is fulfilled in full. While the fragment waits on the back stack there is no view; the request stays unmet and the reason is `no-view`.

```kotlin
// Main.kt — lifetime of the fragment view
fun updateView(stage: String): Outcome = when (stage) {
    "added" -> Outcome.Waiting("onCreateView")
    "view-created" -> Outcome.Given("updated")
    "on-back-stack" -> Outcome.Refused("no-view")
    else -> Outcome.Refused("no-fragment")
}

fun main() {
    val asks = listOf("added", "view-created", "on-back-stack", "removed").map {
        Ask("stage-$it", "updated", updateView(it))
    }
    for (ask in asks) {
        ask.report()
    }
    tally(asks)
}
```

```text
stage-added: wanted=updated | waiting-on=onCreateView -> pending
stage-view-created: wanted=updated | given=updated -> full
stage-on-back-stack: wanted=updated | reason=no-view -> unmet
stage-removed: wanted=updated | reason=no-fragment -> unmet
full: 1
substitute: 0
pending: 1
unmet: 2
```

The two unmet lines reach the same result by different paths. In `no-fragment` the object does not exist either; the code is not even expected to hold a reference. In `no-view` the object lives and the reference looks valid, but the view it points to is gone. The second is more dangerous, because the error appears only at run time. The fix is to reset the view reference in `onDestroyView` and to bind observers to the lifecycle owner of the view.

**AC3.** The model separates the four stages by name. The order of transitions between the stages, intermediate callbacks such as `onViewStateRestored`, and how the view reference is reset were not measured.

#### Arguments and Recreation

The platform can recreate a fragment: on a configuration change and after process death. When it recreates the fragment, it passes no parameter to the constructor. The default factory calls the parameterless constructor and then attaches to the fragment the `arguments` bundle that was given when it was created. A value given as a constructor parameter, if it is not in this bundle, is lost on recreation.

```kotlin
// Platform code: carries the value in the arguments bundle, not the constructor
class ListFragment : Fragment() {
    companion object {
        fun newInstance(category: String) = ListFragment().apply {
            arguments = bundleOf("category" to category)
        }
    }
}
```

In the measurement the wanted category is `book`. The value was given to the fragment by three different ways: with the bundle, with a constructor parameter, and through a class that has no parameterless constructor.

```kotlin
// Main.kt — path of the value when a fragment is recreated
fun recreate(way: String): Outcome = when (way) {
    "arguments" -> Outcome.Given("book")
    "constructor-parameter" -> Outcome.Given("all")
    else -> Outcome.Refused("no-empty-constructor")
}

fun main() {
    val asks = listOf("arguments", "constructor-parameter", "constructor-parameterized-only").map {
        Ask("way-$it", "book", recreate(it))
    }
    for (ask in asks) {
        ask.report()
    }
    tally(asks)
}
```

```text
way-arguments: wanted=book | given=book -> full
way-constructor-parameter: wanted=book | given=all -> substitute
way-constructor-parameterized-only: wanted=book | reason=no-empty-constructor -> unmet
full: 1
substitute: 1
pending: 0
unmet: 1
```

The value given with the bundle survived the recreation: full. The value given with a constructor parameter works at first creation but falls back to the default on recreation; `all` is a value, not the wanted one: substitute. In the third way the recreation never starts, because the platform cannot find a parameterless constructor. The substitute is the most insidious result here: the app does not crash, it only shows the wrong category.

**AC4.** The model fixes the loss of the value as a fall back to the default. The real behavior depends on what the constructor does with the value, and when a custom fragment factory is used a parameterized constructor can work too; this was not measured.

#### The Bounding Measurement

While the host was stopped, the transaction waited. But what if the host has already saved its state? After `onSaveInstanceState` is called, the platform counts the state as packed; adding a new transaction on top of the packed state means the transaction is lost when the process ends and the activity is rebuilt. For this reason the platform refuses `commit` at this stage. `commitAllowingStateLoss`, on the other hand, accepts the loss and applies the transaction, but the state of the transaction is not packed.

The same transaction can therefore receive three different fulfillments.

```kotlin
// Main.kt — the same transaction, three fulfillments after the state is saved
fun commitAfterSave(op: String, stateSaved: Boolean, allowLoss: Boolean): Outcome = when {
    !stateSaved -> Outcome.Given(op)
    allowLoss -> Outcome.Given("$op-stateless")
    else -> Outcome.Refused("state-saved")
}

fun main() {
    val op = "add:ListFragment"
    val asks = listOf(
        Ask("unsaved", op, commitAfterSave(op, stateSaved = false, allowLoss = false)),
        Ask("saved-commit", op, commitAfterSave(op, stateSaved = true, allowLoss = false)),
        Ask("saved-lossy", op, commitAfterSave(op, stateSaved = true, allowLoss = true)),
    )
    for (ask in asks) {
        ask.report()
    }
    tally(asks)
}
```

```text
unsaved: wanted=add:ListFragment | given=add:ListFragment -> full
saved-commit: wanted=add:ListFragment | reason=state-saved -> unmet
saved-lossy: wanted=add:ListFragment | given=add:ListFragment-stateless -> substitute
full: 1
substitute: 1
pending: 0
unmet: 1
```

While the state was not saved, the transaction was applied in full. After it was saved, `commit` stayed unmet; the reason is `state-saved`. `commitAllowingStateLoss` gave a value, but not the transaction itself, only its stateless form: substitute. Three lines are three different fulfillments, and the transaction is the same.

These lines also draw the boundary between pending and unmet. While the host is stopped, the platform knows the transaction can be applied later and holds it. After the state is saved, there is no condition for the transaction to wait on: the condition has passed, and the transaction can no longer be applied safely.

**AC5.** The model does not throw the exception of the real platform; it returns the refusal as `Refused`. In the platform, `commit` ends with an exception in this case.

#### Summary

- This lesson's criterion: **fulfillment**. A fragment transaction is fulfilled according to the state of the host activity. This lesson's fulfillment: **pending**.
- With the host in the foreground the transactions were fulfilled in full, and while it was stopped they were pending: 2 full and 2 pending.
- For a transaction not added to the back stack, the back button leaves the activity; another value comes in place of the wanted fragment: substitute.
- After the state is saved, `commit` stays unmet and `commitAllowingStateLoss` stays substitute; the same transaction receives three fulfillments.
- Pending is a request whose condition can be met in the future; unmet is a request whose condition has passed.

#### Next Step

Fragments are UI sections; the work that runs in the background has no UI. The next lesson, Services, measures the app component without a UI and the rule the platform sets for a service started from the background.

### Services

Source: https://academia.sh/en/courses/android-platform-and-tools/services

Measures the difference between started, bound, and foreground services, that a service is not a thread, and how the platform fulfills a request to start a service from the background.

A fragment had a UI; it waited on its transaction while the host was stopped. A service has no UI. A service is the app component that carries out work in the background while the user looks at something else on the screen: playing music, uploading a file, syncing data. Because the platform runs this work where the user cannot see it, the service is also the app component with the strictest rules about which service may run and when.

The question of the lesson is the same: when a service is requested, how does the platform **fulfill** the request? In this lesson substitute, pending, and unmet all appear inside the same topic.

```kotlin
// Fulfillment.kt — classifies how the platform fulfills a request
sealed interface Outcome {
    data class Given(val value: String) : Outcome
    data class Waiting(val on: String) : Outcome
    data class Refused(val reason: String) : Outcome
}

class Ask(val label: String, val wanted: String, val outcome: Outcome) {
    val fulfillment: String = when (outcome) {
        is Outcome.Waiting -> "pending"
        is Outcome.Refused -> "unmet"
        is Outcome.Given -> if (outcome.value == wanted) "full" else "substitute"
    }

    fun report() {
        val detail = when (outcome) {
            is Outcome.Given -> "given=${outcome.value}"
            is Outcome.Waiting -> "waiting-on=${outcome.on}"
            is Outcome.Refused -> "reason=${outcome.reason}"
        }
        println("$label: wanted=$wanted | $detail -> $fulfillment")
    }
}

fun tally(asks: List<Ask>) {
    for (name in listOf("full", "substitute", "pending", "unmet")) {
        println("$name: ${asks.count { it.fulfillment == name }}")
    }
}
```

#### Three Kinds of Service

Services fall into three kinds by the way they are used.

- **Started service**: an app component starts it with `startService`; the service runs until the work is done and stops itself.
- **Bound service**: a client binds to the service with `bindService` and talks to it through an interface. When no bound client is left, the service ends.
- **Foreground service**: it carries out work the user is aware of, for example music that is playing. A foreground service must show a user notification; the user notices it.

These are not alternatives to each other: a service can be both started and bound. But whichever kind is requested, the service is declared in the manifest file; a service that is not declared cannot be started, just like an activity that is not declared.

```xml
<application>
    <service android:name=".SyncService" android:exported="false" />
</application>
```

```kotlin
// Platform code: starts a service from the same app
startService(Intent(this, SyncService::class.java))
```

A service is **not** a separate thread. The callbacks of a service, methods such as `onStartCommand`, run on the main thread of the app. The service does not move long-running work to another thread by itself; whoever writes the work does that. This mix-up is a frequent source of bugs and it can be measured with the fulfillment rule.

The model sets up this rule of the platform. The measurement below asks two services for the thread they were wanted to use: one left at the default, the other having moved the work to its own thread.

```kotlin
// Main.kt — service and thread
fun threadFor(ownThread: Boolean): Outcome =
    Outcome.Given(if (ownThread) "background-thread" else "main-thread")

fun main() {
    val wanted = "background-thread"
    val asks = listOf(
        Ask("service-default", wanted, threadFor(ownThread = false)),
        Ask("service-own-thread", wanted, threadFor(ownThread = true)),
    )
    for (ask in asks) {
        ask.report()
    }
    tally(asks)
}
```

```text
service-default: wanted=background-thread | given=main-thread -> substitute
service-own-thread: wanted=background-thread | given=background-thread -> full
full: 1
substitute: 1
pending: 0
unmet: 0
```

The default service gave the main thread in place of the wanted background thread: substitute. This substitute is silent; the service runs, but the long work blocks the main thread. The service that moved the work to its own thread received the wanted value: full.

**AC1.** The model gives the thread name as a string. The real distinction is observed by the thread identity at run time, and it was not measured in this lesson.

#### Starting from the Background

If the user closed the app and moved on to something else, the app is in the background. From a certain API level on, the platform does not let an app in the background start services freely. Below the level there is no limit; above it, `startService` from the background is refused. The limit exists to stop invisible work that spends battery and memory.

The model builds the limit from two inputs: where the request comes from and the API level of the device. The level is a model input, not a claim about the real threshold of the platform; the limit was chosen as 26.

```kotlin
// Main.kt — starting a service from the background
fun startService(name: String, origin: String, level: Int): Outcome =
    if (origin == "background" && level >= 26) Outcome.Refused("background-limit") else Outcome.Given(name)

fun main() {
    val asks = listOf(
        Ask("foreground-start", "SyncService", startService("SyncService", "foreground", 31)),
        Ask("background-new", "SyncService", startService("SyncService", "background", 31)),
        Ask("background-old", "SyncService", startService("SyncService", "background", 24)),
    )
    for (ask in asks) {
        ask.report()
    }
    tally(asks)
}
```

```text
foreground-start: wanted=SyncService | given=SyncService -> full
background-new: wanted=SyncService | reason=background-limit -> unmet
background-old: wanted=SyncService | given=SyncService -> full
full: 2
substitute: 0
pending: 0
unmet: 1
```

Starting from the foreground was fulfilled in full. Starting from the background stayed unmet on the device that passed the limit level; on the device below the limit the same request was fulfilled in full. The same code, the same service, two devices, two fulfillments. The fulfillment of this lesson is therefore **unmet**: the negative answer the platform gives most often for a service is the one it gives to a request coming from the background.

**AC2.** 26 is an input chosen by the model. At which API level the platform applies the limit and which exemptions it recognizes were not measured.

#### A Service When the Process Ends

When memory runs low, the system can end the process that hosts a service. For a started service this means the service disappears while its work is half done. Whether the service restarts after it is ended, and with which intent it starts if it does, is decided by the value that the `onStartCommand` method returns.

- `START_NOT_STICKY`: if the service is ended it is not restarted. If there are pending start requests they arrive; otherwise the service does not come back by itself.
- `START_STICKY`: the service is restarted, but the last intent is not delivered; `onStartCommand` is called with an empty (`null`) intent.
- `START_REDELIVER_INTENT`: the service is restarted and the last delivered intent is delivered again until the work is completed with `stopSelf`.

```kotlin
// Platform code: delivers the last intent again on restart
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
    enqueue(intent)
    return START_REDELIVER_INTENT
}
```

What is wanted is for the work to continue from the point where it was cut off, with the same intent: `same-intent`. What is given varies with the return value.

```kotlin
// Main.kt — how the service returns after the process ends
fun afterKill(policy: String): Outcome = when (policy) {
    "START_REDELIVER_INTENT" -> Outcome.Given("same-intent")
    "START_STICKY" -> Outcome.Given("empty-intent")
    "START_NOT_STICKY" -> Outcome.Refused("does-not-restart")
    else -> Outcome.Waiting("system-decision")
}

fun main() {
    val policies = listOf("START_REDELIVER_INTENT", "START_STICKY", "START_NOT_STICKY", "unknown")
    val asks = policies.map { Ask(it, "same-intent", afterKill(it)) }
    for (ask in asks) {
        ask.report()
    }
    tally(asks)
}
```

```text
START_REDELIVER_INTENT: wanted=same-intent | given=same-intent -> full
START_STICKY: wanted=same-intent | given=empty-intent -> substitute
START_NOT_STICKY: wanted=same-intent | reason=does-not-restart -> unmet
unknown: wanted=same-intent | waiting-on=system-decision -> pending
full: 1
substitute: 1
pending: 1
unmet: 1
```

Four return values gave four fulfillments. `START_STICKY` restarts, but does not carry the intent; inside `onStartCommand` the `intent` can be null and the service has to handle that. Otherwise the service looks like it is running, but it runs without knowing what to do: the silent danger of the substitute is here. `START_NOT_STICKY` is for services whose work may be lost when they are ended; it does not restart and the caller does not learn this. The fourth line shows that the platform does not always make a promise here: the return value is a suggestion to the restart decision of the system, not a firm contract.

**AC3.** The model measures the three return values under the assumption that the ending has taken place. When the ending happens, how long the wait is before the service restarts, and the rules by which the system delays the restart were not measured.

#### The Lifetime of a Bound Service

The lifetime of a bound service depends on the number of bound clients. When the last client breaks its binding with `unbindService`, the platform destroys the service if it was used only as bound. If the same service was also started with `startService`, it keeps running even when the bindings are broken, and it stops only with `stopSelf` or `stopService`.

```kotlin
// Platform code: the client binds to the service and breaks the connection when done
bindService(Intent(this, PlayerService::class.java), connection, Context.BIND_AUTO_CREATE)
unbindService(connection)
```

In the measurement what is wanted is for the service to keep running after the last client has left (`running`). Four ways of use give four results.

```kotlin
// Main.kt — the service after the last client has left
fun afterLastUnbind(started: Boolean, stoppedSelf: Boolean, foreground: Boolean): Outcome = when {
    foreground -> Outcome.Waiting("user-notice-closing")
    started && !stoppedSelf -> Outcome.Given("running")
    started && stoppedSelf -> Outcome.Given("stopped")
    else -> Outcome.Given("destroyed")
}

fun main() {
    val asks = listOf(
        Ask("bound-only", "running", afterLastUnbind(started = false, stoppedSelf = false, foreground = false)),
        Ask("started-and-bound", "running", afterLastUnbind(started = true, stoppedSelf = false, foreground = false)),
        Ask("stopped-itself", "running", afterLastUnbind(started = true, stoppedSelf = true, foreground = false)),
        Ask("foreground", "running", afterLastUnbind(started = true, stoppedSelf = false, foreground = true)),
    )
    for (ask in asks) {
        ask.report()
    }
    tally(asks)
}
```

```text
bound-only: wanted=running | given=destroyed -> substitute
started-and-bound: wanted=running | given=running -> full
stopped-itself: wanted=running | given=stopped -> substitute
foreground: wanted=running | waiting-on=user-notice-closing -> pending
full: 1
substitute: 2
pending: 1
unmet: 0
```

The service used only as bound was destroyed together with the last client: substitute. The service that was started and bound kept running: full. The service that was started but had stopped itself ceased to run when the bindings broke: substitute. The foreground service came out pending on the fourth line: as long as the user notification stays visible the service runs, and it waits for the user to dismiss it or for the app to stop the service. Because two separate lifetime rules work together, to say when a service will stop one has to look both at how it was started and at how many clients it has.

**AC4.** The model evaluates the three flags in a fixed order of priority. In the real platform, the `BIND_AUTO_CREATE` flag, and how the `onUnbind` and `onRebind` callbacks work when the service is both bound and started, were not measured.

#### The Bounding Measurement

The limit has two exits. The first is the foreground service: a background service is requested from the background, and if the app promises to show a user notification, the platform starts it as a foreground service. What is wanted is a background service, what is given is a foreground service: substitute. The second is conditionally scheduled work: the work is written not as a service but as a conditional request given to the scheduler of the platform, and when the conditions are met the platform runs the work.

In the PHP course "deferred" was kept apart from "pending". Scheduled work is not deferred but pending, because the name of the condition it waits for is known: `network`, `power`. The model collects the missing conditions in sorted order and joins them with a comma.

```kotlin
// Main.kt — foreground service and conditionally scheduled job
fun startAs(wanted: String, fromBackground: Boolean, hasUserNotice: Boolean): Outcome = when {
    !fromBackground -> Outcome.Given(wanted)
    hasUserNotice -> Outcome.Given("foreground-service")
    else -> Outcome.Refused("background-limit")
}

fun scheduleJob(work: String, needs: Set<String>, met: Set<String>): Outcome {
    val missing = (needs - met).sorted()
    return if (missing.isEmpty()) Outcome.Given(work) else Outcome.Waiting(missing.joinToString(","))
}

fun main() {
    val needs = setOf("network", "power")
    val asks = listOf(
        Ask("foreground-granted", "background-service", startAs("background-service", true, true)),
        Ask("scheduled-power", "sync", scheduleJob("sync", needs, setOf("power"))),
        Ask("scheduled-none", "sync", scheduleJob("sync", needs, emptySet())),
        Ask("scheduled-ok", "sync", scheduleJob("sync", needs, setOf("network", "power"))),
    )
    for (ask in asks) {
        ask.report()
    }
    tally(asks)
    println(asks.filter { it.fulfillment == "pending" }.map { it.label })
}
```

```text
foreground-granted: wanted=background-service | given=foreground-service -> substitute
scheduled-power: wanted=sync | waiting-on=network -> pending
scheduled-none: wanted=sync | waiting-on=network,power -> pending
scheduled-ok: wanted=sync | given=sync -> full
full: 1
substitute: 1
pending: 2
unmet: 0
[scheduled-power, scheduled-none]
```

The first line is a substitute: a background service was requested, a foreground service came. The three scheduled job lines show the three faces of pending. When only power is met the job waits for `network`; when neither is met it waits for both conditions at once; when both are met the job is fulfilled in full. The pending lines carry the condition by name in the `waiting-on=` field, even though the rule does not see the reason. The last line shows that the rule only counts the category: the two pending requests are gathered in the same list and their missing conditions stay separate.

**AC5.** The model takes two conditions and a user-notice flag as input. The condition types of the real scheduler and the limits it places on job durations were not measured. The foreground service exit is not unconditional either: an app that targets API 31 and above cannot start a foreground service while it is in the background either, and can start it only in the exceptions the platform documents. The model does not build this limit; `startAs` counts the user-notice flag as sufficient.

#### Summary

- This lesson's criterion: **fulfillment**. A service is declared in the manifest file; it is used in three forms, as a started, bound, and foreground service. This lesson's fulfillment: **unmet**; a start request from the background is refused at the limit level.
- A service is not a thread: the default service gives the main thread in place of the wanted background thread; 1 full and 1 substitute.
- Starting a service from the background stays unmet on a device that passes the limit level: 2 full and 1 unmet.
- A foreground service is the substitute given to a request for a background service; the user notification is its price.
- A conditionally scheduled job stays pending and carries the missing condition by name: `network`, `power`, or `network,power`.

#### Next Step

A service starts with the app's own request; the system calls the app with its own events. The next lesson, Broadcast Receivers, measures what reaches a declared receiver when the system broadcasts an event, and what does not.

### Broadcast Receivers

Source: https://academia.sh/en/courses/android-platform-and-tools/broadcast-receivers

Measures how a broadcast receiver is registered in the manifest file and at run time, that an implicit system broadcast does not reach a declared receiver, and that the same receiver class gets different fulfillments by three paths.

A service runs by the app's own request. The system calls the app with its own events: the screen turned on, the device restarted, the charger was plugged in, the network changed. These events are sent to the system as a broadcast, and the app components that are interested receive it. The app component that receives a broadcast is a broadcast receiver (`BroadcastReceiver`). A broadcast receiver has no UI, and its only job is to do a short piece of work in the `onReceive` callback.

The question of the lesson: when a broadcast is requested, that is, when a receiver wants to receive a certain broadcast, how does the platform **fulfill** the request? The answer depends on the way the receiver was registered with the platform.

```kotlin
// Fulfillment.kt — classifies how the platform fulfills a request
sealed interface Outcome {
    data class Given(val value: String) : Outcome
    data class Waiting(val on: String) : Outcome
    data class Refused(val reason: String) : Outcome
}

class Ask(val label: String, val wanted: String, val outcome: Outcome) {
    val fulfillment: String = when (outcome) {
        is Outcome.Waiting -> "pending"
        is Outcome.Refused -> "unmet"
        is Outcome.Given -> if (outcome.value == wanted) "full" else "substitute"
    }

    fun report() {
        val detail = when (outcome) {
            is Outcome.Given -> "given=${outcome.value}"
            is Outcome.Waiting -> "waiting-on=${outcome.on}"
            is Outcome.Refused -> "reason=${outcome.reason}"
        }
        println("$label: wanted=$wanted | $detail -> $fulfillment")
    }
}

fun tally(asks: List<Ask>) {
    for (name in listOf("full", "substitute", "pending", "unmet")) {
        println("$name: ${asks.count { it.fulfillment == name }}")
    }
}
```

#### Two Ways to Register

A broadcast receiver is registered in two ways.

- **In the manifest file**: the receiver is declared with a `<receiver>` element, and an intent filter says which broadcast it wants. The platform can set up the receiver even when the app is not running.
- **At run time**: code registers the receiver with `registerReceiver` and removes it with `unregisterReceiver`. The receiver gets broadcasts only as long as it is registered.

```xml
<application>
    <receiver android:name=".ScreenReceiver" android:exported="false">
        <intent-filter>
            <action android:name="android.intent.action.SCREEN_ON" />
        </intent-filter>
    </receiver>
</application>
```

```kotlin
// Platform code: registering the same receiver at run time
import android.content.IntentFilter

val filter = IntentFilter("android.intent.action.SCREEN_ON")
registerReceiver(ScreenReceiver(), filter)
```

The two ways used to give the same result. In today's platform the difference comes from whether the broadcast is **implicit** or **explicit**. An implicit broadcast does not target a particular app; it is sent to everyone, and system events such as the screen turning on are like this. An explicit broadcast carries the package name of an app and goes only to that app. From a certain API level on, the platform has restricted receivers declared in the manifest file from receiving implicit system broadcasts; broadcasts on an exemption list are free of this restriction. Receivers registered at run time are not restricted.

The model sets up this rule in a single helper file. The inputs are the name of the broadcast, the way of registration, whether the broadcast is an implicit system broadcast, whether it was sent with a package name, and the exemption list. The list and the level are model inputs.

```kotlin
// Delivery.kt — sets up how a broadcast reaches the receiver class
fun deliver(
    action: String,
    way: String,
    systemImplicit: Boolean,
    explicitPackage: Boolean,
    exempt: Set<String>,
): Outcome = when {
    way == "runtime" -> Outcome.Given(action)
    explicitPackage -> Outcome.Given(action)
    systemImplicit && action !in exempt -> Outcome.Refused("implicit-broadcast-limit")
    else -> Outcome.Given(action)
}
```

#### A Broadcast That Reaches and One That Does Not Reach a Declared Receiver

Three receivers want three broadcasts. The screen turning on is an implicit system broadcast and is not on the exemption list. The device booting is also an implicit system broadcast, but it is on the exemption list. In the third, the screen broadcast is this time sent to a receiver registered at run time.

```kotlin
// Main.kt — implicit system broadcast and the way of registration
fun main() {
    val exempt = setOf("BOOT_COMPLETED")
    val asks = listOf(
        Ask("manifest-implicit", "SCREEN_ON", deliver("SCREEN_ON", "manifest", true, false, exempt)),
        Ask("manifest-exempt", "BOOT_COMPLETED", deliver("BOOT_COMPLETED", "manifest", true, false, exempt)),
        Ask("runtime-implicit", "SCREEN_ON", deliver("SCREEN_ON", "runtime", true, false, exempt)),
    )
    for (ask in asks) {
        ask.report()
    }
    tally(asks)
}
```

```text
manifest-implicit: wanted=SCREEN_ON | reason=implicit-broadcast-limit -> unmet
manifest-exempt: wanted=BOOT_COMPLETED | given=BOOT_COMPLETED -> full
runtime-implicit: wanted=SCREEN_ON | given=SCREEN_ON -> full
full: 2
substitute: 0
pending: 0
unmet: 1
```

The screen broadcast did not reach the receiver in the manifest file: unmet. By the same path, the device boot on the exemption list did reach: full. The screen broadcast reached the receiver registered at run time: full. The fulfillment of this lesson is **unmet**: declaring a receiver in the manifest file does not guarantee that an implicit system broadcast will reach it.

**AC1.** The exemption list and the API level at which the restriction starts are model inputs. Which broadcasts are on the list and how it changes between versions were not measured.

#### The Lifetime of a Run-Time Registration

A receiver that gets broadcasts through run-time registration gets them as long as the registration exists. If the activity that made the registration is destroyed and the receiver is not removed, there is a leak; if it is removed, the receiver is no longer a receiver. A registration in the manifest file, on the other hand, depends on the life of the app.

```kotlin
// Main.kt — lifetime of a run-time registration
fun deliverRegistered(action: String, way: String, active: Boolean): Outcome =
    if (way == "runtime" && !active) Outcome.Refused("not-registered") else Outcome.Given(action)

fun main() {
    val asks = listOf(
        Ask("runtime-registered", "SCREEN_ON", deliverRegistered("SCREEN_ON", "runtime", active = true)),
        Ask("runtime-unregistered", "SCREEN_ON", deliverRegistered("SCREEN_ON", "runtime", active = false)),
        Ask("manifest-always", "BOOT_COMPLETED", deliverRegistered("BOOT_COMPLETED", "manifest", active = false)),
    )
    for (ask in asks) {
        ask.report()
    }
    tally(asks)
}
```

```text
runtime-registered: wanted=SCREEN_ON | given=SCREEN_ON -> full
runtime-unregistered: wanted=SCREEN_ON | reason=not-registered -> unmet
manifest-always: wanted=BOOT_COMPLETED | given=BOOT_COMPLETED -> full
full: 2
substitute: 0
pending: 0
unmet: 1
```

The registered receiver received the broadcast: full. The receiver whose registration was removed did not: unmet. For a receiver in the manifest file the `active` value is meaningless; the registration is valid as long as the app exists. The difference between the two unmet lines again lies in the reason: in the first, `implicit-broadcast-limit` is a rule of the platform; in the second, `not-registered` is the work of the code itself.

**AC2.** The model takes the `active` value as an input. In which lifecycle callback the receiver should be removed was not measured in this lesson.

#### Ordered Broadcast and Aborting

Broadcasts are sent in two forms. In a normal broadcast, the platform delivers the broadcast to all receivers together; there is no order among the receivers and one does not affect another. In an ordered broadcast, the platform delivers the broadcast to the receivers one by one, in order of priority. Each receiver receives the broadcast and may add a result, or may abort the broadcast and stop the chain from reaching the ones that follow. The priority is given in the filter with the `android:priority` attribute, or on the `IntentFilter` in a run-time registration.

```kotlin
// Platform code: sending an ordered broadcast and stopping the chain
sendOrderedBroadcast(Intent("app.action.CHECK"), null)

// inside the receiver, to stop the chain
abortBroadcast()
```

In the measurement three receivers are lined up in order of priority. What is wanted is for every receiver to receive the broadcast. In an ordered broadcast, if the second receiver aborts, the third is never reached. In a normal broadcast the abort call has no effect.

```kotlin
// Main.kt — ordered broadcast and aborting
fun receive(index: Int, ordered: Boolean, abortAt: Int?): Outcome =
    if (ordered && abortAt != null && index > abortAt) Outcome.Refused("broadcast-aborted")
    else Outcome.Given("received")

fun main() {
    val asks = mutableListOf<Ask>()
    for (ordered in listOf(true, false)) {
        val kind = if (ordered) "ordered" else "normal"
        for (index in 0..2) {
            asks.add(Ask("$kind-receiver-${index + 1}", "received", receive(index, ordered, abortAt = 1)))
        }
    }
    for (ask in asks) {
        ask.report()
    }
    tally(asks)
}
```

```text
ordered-receiver-1: wanted=received | given=received -> full
ordered-receiver-2: wanted=received | given=received -> full
ordered-receiver-3: wanted=received | reason=broadcast-aborted -> unmet
normal-receiver-1: wanted=received | given=received -> full
normal-receiver-2: wanted=received | given=received -> full
normal-receiver-3: wanted=received | given=received -> full
full: 5
substitute: 0
pending: 0
unmet: 1
```

In the ordered broadcast the receiver that aborted received the broadcast: full; the receiver after it did not: unmet. In the normal broadcast the same abort changed nothing: all three receivers received it. The lesson to draw is not to assume that one receiver depends on another. In an ordered broadcast a receiver in the middle of the chain can stop the broadcast; this receiver may not be in your own app. Some system broadcasts cannot be aborted, and some broadcasts are sent only in the normal form.

**AC3.** The model takes the abort only as an index. Receivers adding results, the order of receivers with the same priority, and which system broadcasts are sent as ordered were not measured.

#### How Long the Receiver Can Run

As an app component, a broadcast receiver is very short-lived: it exists only while the `onReceive` method runs. When the method returns, the platform no longer counts the receiver as active and may end the process that hosts it. The method runs on the main thread, and if it takes long the platform raises a not-responding error. For this reason, doing long work inside `onReceive` is not solved only by opening a thread; the moment the receiver returns, the process loses priority.

```kotlin
// Platform code: scheduling the work before onReceive returns
override fun onReceive(context: Context, intent: Intent) {
    val pending = goAsync()
    thread {
        doWork(intent)
        pending.finish()
    }
}
```

The measurement takes the duration of the work and the manner in which it is done as inputs. The limit in the model is ten seconds; this is an input, not a fixed claim of the platform. What is wanted is `completed`. Short work finishes directly. If long work is done inside `onReceive`, the time is exceeded. If long work is handed off to scheduled work, the receiver returns quickly and the work is done later: a result that stands in for the wanted one, not the wanted one itself. Work that waits for the network waits until the network arrives.

```kotlin
// Main.kt — duration of work inside onReceive
fun runIn(onReceive: Boolean, seconds: Int, handOff: Boolean, needsNetwork: Boolean, limit: Int): Outcome = when {
    needsNetwork -> Outcome.Waiting("network")
    handOff -> Outcome.Given("job-scheduled")
    onReceive && seconds > limit -> Outcome.Refused("time-exceeded")
    else -> Outcome.Given("completed")
}

fun main() {
    val limit = 10
    val asks = listOf(
        Ask("short-work", "completed", runIn(true, 3, handOff = false, needsNetwork = false, limit = limit)),
        Ask("long-work", "completed", runIn(true, 15, handOff = false, needsNetwork = false, limit = limit)),
        Ask("long-work-handed-off", "completed", runIn(true, 15, handOff = true, needsNetwork = false, limit = limit)),
        Ask("network-work", "completed", runIn(true, 3, handOff = false, needsNetwork = true, limit = limit)),
    )
    for (ask in asks) {
        ask.report()
    }
    tally(asks)
}
```

```text
short-work: wanted=completed | given=completed -> full
long-work: wanted=completed | reason=time-exceeded -> unmet
long-work-handed-off: wanted=completed | given=job-scheduled -> substitute
network-work: wanted=completed | waiting-on=network -> pending
full: 1
substitute: 1
pending: 1
unmet: 1
```

Four jobs received four fulfillments. The short work finished under the limit: full. The long work stayed unmet when it was done inside the receiver. Handing the long work off to scheduled work gave a substitute: the work was not completed inside `onReceive`, but its completion was guaranteed. This is often the right solution. Work that waits for the network is not tried directly either; it stays pending and carries its condition. This distinction is the receiver counterpart of the distinction drawn earlier for scheduled jobs.

**AC4.** Ten seconds is an input chosen by the model. That the real duration changes with the type of broadcast and with whether the app is in the foreground, and the exact limit of the time obtained with `goAsync`, were not measured.

#### The Bounding Measurement

The same broadcast, the same receiver class: three paths. The first is an implicit broadcast to a receiver declared in the manifest file, and it stays unmet. The second is registering the same receiver class at run time, and it is fulfilled in full. The third is sending the broadcast explicitly, with the package name of the app: it reaches the receiver in the manifest file too.

```kotlin
// Main.kt — the same broadcast, the same receiver class, three paths
fun main() {
    val exempt = setOf("BOOT_COMPLETED")
    val asks = listOf(
        Ask("manifest-implicit", "SCREEN_ON", deliver("SCREEN_ON", "manifest", true, false, exempt)),
        Ask("runtime-implicit", "SCREEN_ON", deliver("SCREEN_ON", "runtime", true, false, exempt)),
        Ask("manifest-explicit", "SCREEN_ON", deliver("SCREEN_ON", "manifest", true, true, exempt)),
    )
    for (ask in asks) {
        ask.report()
    }
    tally(asks)
    println(asks.map { it.fulfillment }.distinct().sorted())
}
```

```text
manifest-implicit: wanted=SCREEN_ON | reason=implicit-broadcast-limit -> unmet
runtime-implicit: wanted=SCREEN_ON | given=SCREEN_ON -> full
manifest-explicit: wanted=SCREEN_ON | given=SCREEN_ON -> full
full: 2
substitute: 0
pending: 0
unmet: 1
[full, unmet]
```

Of the three paths, two are full and one is unmet. The last line again shows the view of the rule: the fulfillments of the three requests are only two different values, and which path corresponds to which cannot be read from the category; the path is in the `label` field. Whether the receiver runs depends on the path by which the broadcast came to it, not on the broadcast itself.

**AC5.** The model counts an explicit broadcast as any broadcast sent with a package name. In reality the path of an explicit broadcast also depends on the target component being declared and exported; this was not measured in this lesson.

#### Summary

- This lesson's criterion: **fulfillment**. A broadcast receiver is registered in the manifest file or at run time, and these two ways receive different fulfillments. This lesson's fulfillment: **unmet**; an implicit system broadcast does not reach a declared receiver.
- An implicit system broadcast does not reach a declared receiver: unmet. A broadcast on the exemption list and a run-time registration were fulfilled in full: 2 full and 1 unmet.
- When a run-time registration is removed, the receiver gets no broadcasts; the lifetime of the registration is the responsibility of the code.
- An explicit broadcast reaches the receiver in the manifest file too.
- The fulfillment category does not carry the path: two unmet results can come from different reasons.

#### Next Step

A broadcast receiver reports an event; it carries no data. To share the data itself with another app, the next lesson, Content Providers, measures how a content URI is mapped and how a query without permission is fulfilled.

### Content Providers

Source: https://academia.sh/en/courses/android-platform-and-tools/content-providers

How a content provider resolves a content URI with a URI matcher, how an unknown path, a missing row and a missing column are fulfilled, and why a query without permission stays unmet.

A broadcast receiver reports an event; it carries no data. When data has to be shared with another app, the platform offers another app component: the content provider (`ContentProvider`). Contacts, calendar entries and media files are shared this way; if you want to open your own app's data to another app, you follow the same path.

A content provider is not reached directly like an object. The caller gives a **content URI**, the platform finds from the authority part of the URI which provider is responsible, and it forwards the request to that provider. The tool in the caller's hand is the content resolver (`ContentResolver`): it routes queries, inserts and deletes to the provider. Inside the provider, in turn, there is a **URI matcher** (`UriMatcher`) that turns the URI into a code.

The question of the lesson: when data is requested with a content URI, how does the platform **fulfill** the request? Three different points give three different answers: the matching of the path, the existence of the row and the column, and the permission of the caller.

```kotlin
// Fulfillment.kt — classifies how the platform fulfills a request
sealed interface Outcome {
    data class Given(val value: String) : Outcome
    data class Waiting(val on: String) : Outcome
    data class Refused(val reason: String) : Outcome
}

class Ask(val label: String, val wanted: String, val outcome: Outcome) {
    val fulfillment: String = when (outcome) {
        is Outcome.Waiting -> "pending"
        is Outcome.Refused -> "unmet"
        is Outcome.Given -> if (outcome.value == wanted) "full" else "substitute"
    }

    fun report() {
        val detail = when (outcome) {
            is Outcome.Given -> "given=${outcome.value}"
            is Outcome.Waiting -> "waiting-on=${outcome.on}"
            is Outcome.Refused -> "reason=${outcome.reason}"
        }
        println("$label: wanted=$wanted | $detail -> $fulfillment")
    }
}

fun tally(asks: List<Ask>) {
    for (name in listOf("full", "substitute", "pending", "unmet")) {
        println("$name: ${asks.count { it.fulfillment == name }}")
    }
}
```

#### The Structure of a Content URI

A content URI is built from three parts: the `content://` scheme, the authority of the provider (`authority`) and the path. The number at the end of the path is most often the identifier of a row.

As an example, `content://com.example.library/books` points to all the books, and `content://com.example.library/books/7` points to the book whose identifier is 7.

The provider is declared in the manifest file with a `<provider>` element. The authority is given here; the `android:exported` and permission attributes are here too. Without this element in the manifest file the system cannot route the URI to any provider.

```xml
<application>
    <provider
        android:name=".BookProvider"
        android:authorities="com.example.library"
        android:exported="true"
        android:readPermission="com.example.library.READ_BOOKS" />
</application>
```

The code of the provider derives from the `ContentProvider` class and implements six methods: `onCreate`, `query`, `insert`, `update`, `delete` and `getType`. These methods, which the caller never calls directly, run through the content resolver.

```kotlin
// Platform code: a query reaching the provider
import android.content.ContentProvider
import android.net.Uri

class BookProvider : ContentProvider() {
    private val matcher = UriMatcher(UriMatcher.NO_MATCH).apply {
        addURI("com.example.library", "books", 1)
        addURI("com.example.library", "books/#", 2)
    }

    override fun query(uri: Uri, projection: Array<String>?, selection: String?,
                       args: Array<String>?, order: String?): Cursor? =
        when (matcher.match(uri)) {
            1 -> database.listBooks(projection)
            2 -> database.findBook(uri.lastPathSegment, projection)
            else -> throw IllegalArgumentException("Unknown URI: $uri")
        }
}
```

The matcher has two pattern words: `#` matches a number, and `*` matches any text. If there is no match, `match` returns `-1` (`NO_MATCH`) and the provider chooses to report this case with an exception. The person writing the provider makes this choice; the rule above is the common one, and the model of this lesson follows it.

#### The Matching of the Path

The model builds the URI matcher and the query in two helper files. The first compares the path with the patterns and returns the matching code, or `-1` if there is no match. The second turns the code into a result.

```kotlin
// Matcher.kt — compares the path with the patterns, returns the code
fun matchUri(path: String, patterns: Map<String, Int>): Int {
    val parts = path.split("/")
    for ((pattern, code) in patterns) {
        val want = pattern.split("/")
        if (want.size != parts.size) continue
        val fits = want.zip(parts).all { (w, p) ->
            when (w) {
                "#" -> p.isNotEmpty() && p.all { it.isDigit() }
                "*" -> p.isNotEmpty()
                else -> w == p
            }
        }
        if (fits) return code
    }
    return -1
}
```

```kotlin
// Provider.kt — turns the matching code into a result
val patterns = mapOf("books" to 1, "books/#" to 2)
val rows = setOf(7, 9)

fun queryBooks(path: String): Outcome = when (matchUri(path, patterns)) {
    1 -> Outcome.Given("book-list")
    2 -> {
        val id = path.substringAfter("/").toInt()
        if (id in rows) Outcome.Given("book-row") else Outcome.Given("empty-cursor")
    }
    else -> Outcome.Refused("no-match")
}
```

`rows` is an input of the model: it says which identifiers exist in the table. The first measurement makes four requests: the list, an existing row, a path that is not defined at all, and an identifier that carries text instead of a number.

```kotlin
// Main.kt — URI matching
fun main() {
    val asks = listOf(
        Ask("list", "book-list", queryBooks("books")),
        Ask("row", "book-row", queryBooks("books/7")),
        Ask("unknown", "book-list", queryBooks("authors")),
        Ask("text-id", "book-row", queryBooks("books/abc")),
    )
    for (ask in asks) {
        ask.report()
    }
    tally(asks)
}
```

```text
list: wanted=book-list | given=book-list -> full
row: wanted=book-row | given=book-row -> full
unknown: wanted=book-list | reason=no-match -> unmet
text-id: wanted=book-row | reason=no-match -> unmet
full: 2
substitute: 0
pending: 0
unmet: 2
```

The two requests that matched the two patterns were fulfilled in full. The two that did not match stayed unmet: the `authors` path is in no pattern, and `books/abc` does not fit the `#` pattern because the identifier is not a number. The second case is a subtle one: since the path starts with `books/`, the user expects a match, but the matcher compares the pattern part by part, not word by word, and `#` accepts only digits. The platform turns these requests away with an exception; in the model this is shown with `Refused`.

**AC1.** After a `books/#` match the model reads the identifier with `toInt`. The behavior with a number too long to fit was not measured. The real matcher does the same part-by-part comparison, but the order of precedence among patterns (exact text, then `#`, then `*`) was kept to two patterns in this lesson.

#### A Missing Row and an Empty Path

A path may fit the pattern but the content may turn out empty. The book with identifier 8 is not in the table, but the `books/8` path fits the `books/#` pattern. In this case the provider does not throw an exception: it returns a cursor with zero rows. The caller did not get the row it wanted, but it did not get an error either.

Three requests are made. One asks for the existing row. One asks for the row that does not exist. The third gives a path in the form `books/`, whose last part is empty.

```kotlin
// Main.kt — a missing row and an empty part
fun main() {
    val asks = listOf(
        Ask("existing", "book-row", queryBooks("books/7")),
        Ask("missing", "book-row", queryBooks("books/8")),
        Ask("empty-part", "book-row", queryBooks("books/")),
    )
    for (ask in asks) {
        ask.report()
    }
    tally(asks)
}
```

```text
existing: wanted=book-row | given=book-row -> full
missing: wanted=book-row | given=empty-cursor -> substitute
empty-part: wanted=book-row | reason=no-match -> unmet
full: 1
substitute: 1
pending: 0
unmet: 1
```

The existing row is full. The missing row became a **substitute**: the caller was expecting a book row, and the platform gave an empty cursor. Because the request did not turn into an error it is not unmet, but because what was wanted was not given it is not full either. The empty part, on the other hand, did not match: `#` does not count empty text as a number.

The practical counterpart of this distinction shows in the calling code. An empty cursor is a normal result and the code has to check for it. A matching failure is an exception, and if left unchecked it ends the app.

**AC2.** The model does not count an empty cursor as an error, because the provider itself throws no error. What happens if the caller does not check whether the cursor is empty was not measured.

#### A Missing Column

A query carries not only the row but also which columns are wanted. The list of columns is called `projection`. If the provider rests on a SQL table, a column name that does not exist brings the query down and the caller gets an exception. If the provider filters the columns itself, the rule may differ; this lesson builds the case that rests on a SQL table, which is the most common.

```kotlin
// Main.kt — the column list
val known = setOf("_id", "title", "author")

fun queryColumns(wanted: List<String>): Outcome {
    val missing = wanted.filter { it !in known }
    return if (missing.isEmpty()) {
        Outcome.Given(wanted.joinToString(","))
    } else {
        Outcome.Refused("column-missing")
    }
}

fun columnAsk(label: String, wanted: List<String>): Ask =
    Ask(label, wanted.joinToString(","), queryColumns(wanted))

fun main() {
    val asks = listOf(
        columnAsk("all-columns", listOf("title", "author")),
        columnAsk("missing-column", listOf("title", "isbn")),
        columnAsk("with-id", listOf("_id", "title", "author")),
    )
    for (ask in asks) {
        ask.report()
    }
    tally(asks)
}
```

```text
all-columns: wanted=title,author | given=title,author -> full
missing-column: wanted=title,isbn | reason=column-missing -> unmet
with-id: wanted=_id,title,author | given=_id,title,author -> full
full: 2
substitute: 0
pending: 0
unmet: 1
```

Two requests came with known columns and were fulfilled in full. The third asked for the `isbn` column; it is not in the table and the request stayed unmet. The platform does not find the row here and skip the missing column: if even one column is unknown, the whole query is rejected. A partial result is not a partial substitute; the rule at work is all or nothing.

**AC3.** The `known` set is an input of the model. How column names are validated during a query in reality, the special name of the identifier column (`_id`) and multi-table join queries were not measured.

#### The Bounding Measurement

The same content URI, the same query: only the state of the caller changes. If the provider is protected with `android:readPermission`, a caller without the permission cannot reach the query. The system checks the permission before it enters the provider; so the matching of the path or the existence of the row is no longer asked. Without permission, a correct path changes nothing.

There is one more path: the party that wrote the provider can give a temporary permission for a single URI (`grantUriPermission`). This permission covers only that URI, and does not pass to the other rows in the provider.

```kotlin
// Main.kt — the same query, three callers
fun access(path: String, caller: String, allowed: Set<String>): Outcome = when {
    caller == "permitted" -> queryBooks(path)
    path in allowed -> queryBooks(path)
    else -> Outcome.Refused("no-permission")
}

fun main() {
    val allowed = setOf("books/7")
    val asks = listOf(
        Ask("permitted", "book-row", access("books/7", "permitted", allowed)),
        Ask("uri-granted", "book-row", access("books/7", "other", allowed)),
        Ask("other-row", "book-row", access("books/9", "other", allowed)),
    )
    for (ask in asks) {
        ask.report()
    }
    tally(asks)
    println(asks.map { it.fulfillment }.distinct().sorted())
}
```

```text
permitted: wanted=book-row | given=book-row -> full
uri-granted: wanted=book-row | given=book-row -> full
other-row: wanted=book-row | reason=no-permission -> unmet
full: 2
substitute: 0
pending: 0
unmet: 1
[full, unmet]
```

The caller who holds the permission was fulfilled in full. The caller who has no permission but was given a temporary URI permission for `books/7` got the same row in the same way, in full. When the same caller asked for the `books/9` row it stayed unmet: the URI permission applies only to the URI it was given for. The last line shows that there are two different fulfillment values; it is not who the caller is but whether the URI is in the permission set that decides.

The fulfillment measured in this lesson is **full**: a request that matches, exists and is permitted gets the requested data from the content provider exactly. When fulfillment breaks, the place that breaks is always the same kind: the match, the row, the column or the permission. Which one broke is read from the result the caller received.

**AC4.** The model builds permission with two values: the existence of the provider's permission and the individual URI permissions. The write permission (`writePermission`), path-based permissions (`path-permission`) and loss of permission during an operation were not measured.

#### Summary

- This lesson's criterion: **fulfillment**. A content provider offers its data with a content URI; the content resolver is on the side of the client, and the URI matcher is on the side of the provider. This lesson's fulfillment: **full**; a matching, existing, permitted request receives the data as it is.
- A matching path was fulfilled in full and a non-matching path stayed unmet: 2 full and 2 unmet. An identifier that is not a number, such as `books/abc`, does not fit the `#` pattern.
- A missing row is not an error: an empty cursor returns as a **substitute**. A missing column leaves the whole query unmet.
- Permission is checked before entering the provider. A temporary URI permission covers only the URI it was given for.
- The rule gives the fulfillment, not the label: an empty cursor and a matching failure fall into different categories.

#### Next Step

You called a content provider with a URI. The general way of calling another app component, in turn, is the intent. The next lesson, Intents and Intent Filters, measures which app component an intent reaches, and the case where more than one app fulfills the same intent.

### Intents and Intent Filters

Source: https://academia.sh/en/courses/android-platform-and-tools/intents-and-intent-filters

The difference between an explicit and an implicit intent, how an intent filter matches on action, category and data, the pending fulfillment when more than one app fulfills the same intent, and the result that changes when the default app is removed.

You called a content provider with a URI. The general way of calling another app component is the intent. In the earlier lessons you started the activity, the service and the broadcast receiver in different ways: `startActivity`, `startService`, `sendBroadcast`. The shared input of the three is the `Intent` object. An intent is a message that says "do this", and it states its target in two forms.

- **Explicit intent**: gives the class or the package name of the target app component directly. The platform does no matching; it starts that app component.
- **Implicit intent**: does not give the target, it states the work to be done: an action, a category and a piece of data. The platform looks at the intent filters of the installed apps and finds the app component that can fulfill the work.

The question of the lesson: when an intent is sent, how does the platform **fulfill** the request? With an implicit intent this question does not end with a single answer: zero, one or more than one app may match. The lesson measures all three outcomes.

```kotlin
// Fulfillment.kt — classifies how the platform fulfills a request
sealed interface Outcome {
    data class Given(val value: String) : Outcome
    data class Waiting(val on: String) : Outcome
    data class Refused(val reason: String) : Outcome
}

class Ask(val label: String, val wanted: String, val outcome: Outcome) {
    val fulfillment: String = when (outcome) {
        is Outcome.Waiting -> "pending"
        is Outcome.Refused -> "unmet"
        is Outcome.Given -> if (outcome.value == wanted) "full" else "substitute"
    }

    fun report() {
        val detail = when (outcome) {
            is Outcome.Given -> "given=${outcome.value}"
            is Outcome.Waiting -> "waiting-on=${outcome.on}"
            is Outcome.Refused -> "reason=${outcome.reason}"
        }
        println("$label: wanted=$wanted | $detail -> $fulfillment")
    }
}

fun tally(asks: List<Ask>) {
    for (name in listOf("full", "substitute", "pending", "unmet")) {
        println("$name: ${asks.count { it.fulfillment == name }}")
    }
}
```

#### The Intent Filter

The `<intent-filter>` element in the manifest file says which intents an app component will receive. A filter carries three criteria.

- **Action** (`action`): the name of the work to be done. `VIEW` is for showing, `SEND` is for sending. The action of the intent must be one of the actions declared in the filter.
- **Category** (`category`): how the app component can be called. Every category the intent carries must be present in the filter.
- **Data** (`data`): the scheme and the type of the content to be processed. If the filter declares data, the data of the intent must agree with it; if the filter declares no data, the intent must carry no data either.

```xml
<activity android:name=".ImageViewerActivity" android:exported="true">
    <intent-filter>
        <action android:name="android.intent.action.VIEW" />
        <category android:name="android.intent.category.DEFAULT" />
        <data android:scheme="image" />
    </intent-filter>
</activity>
```

There is a subtle rule here. When an implicit intent is sent with `startActivity`, the platform adds the `DEFAULT` category to the intent by itself. For this reason the filter of an activity that is to be reached with `startActivity` has to contain the `DEFAULT` category. Even if the action and the data in the filter are right, without this category there is no match. The rule is often missed: the activity looks as if it works, but it does not reach an implicit intent.

```kotlin
// Platform code: sending an implicit and an explicit intent
import android.content.Intent
import android.net.Uri

val implicit = Intent(Intent.ACTION_VIEW, Uri.parse("geo:41.0,29.0"))
startActivity(implicit)

val explicit = Intent().setClassName("com.example.maps", "com.example.maps.MapActivity")
startActivity(explicit)
```

An implicit intent shows a special behavior not only when there is no matching app but also when more than one app matches: the platform shows the user a selection screen. If the user makes one app the default, later intents go to that app without asking. This lesson measures these two behaviors.

#### The Matching Model

The model builds the intent from an action, a data scheme and an optional default choice. The first helper file carries the matching rule and the result rule; the second carries the filters of the sample apps.

```kotlin
// Resolver.kt — builds matching with intent filters, and the result
class Filter(val app: String, val action: String, val categories: Set<String>, val scheme: String?)

fun matches(filter: Filter, action: String, scheme: String?): Boolean =
    filter.action == action && "DEFAULT" in filter.categories && filter.scheme == scheme

fun resolve(action: String, scheme: String?, filters: List<Filter>, chosen: String?): Outcome {
    val hits = filters.filter { matches(it, action, scheme) }.map { it.app }.sorted()
    return when {
        hits.isEmpty() -> Outcome.Refused("no-match")
        hits.size == 1 -> Outcome.Given(hits[0])
        chosen != null && chosen in hits -> Outcome.Given(chosen)
        else -> Outcome.Waiting("user-choice")
    }
}

fun resolveExplicit(app: String, installed: Set<String>): Outcome =
    if (app in installed) Outcome.Given(app) else Outcome.Refused("app-missing")
```

```kotlin
// Installed.kt — filters of the sample apps
val gallery = Filter("gallery", "VIEW", setOf("DEFAULT"), "image")
val editor = Filter("editor", "VIEW", setOf("DEFAULT"), "image")
val camera = Filter("camera", "VIEW", setOf("DEFAULT"), "image")
val maps = Filter("maps", "VIEW", setOf("DEFAULT"), "geo")
val browser = Filter("browser", "VIEW", setOf("DEFAULT", "BROWSABLE"), "https")
val browseOnly = Filter("browse-only", "VIEW", setOf("BROWSABLE"), "image")
```

`resolve` does not model the whole detail of the platform; it carries the three criteria (action, the `DEFAULT` category, scheme) and the result rule. The result rule is this: zero matches is unmet, a single match is that app, and with more than one match, if the default is chosen and is among the matches then that app, otherwise a pending fulfillment waiting for the user's choice.

**AC1.** The model builds the data criterion with the scheme only. The data type (`mimeType`), path patterns and the combination of more than one `<data>` element were not measured. Of the categories only `DEFAULT` is checked.

#### Explicit and Implicit Intents

The first measurement makes four requests. The explicit intent calls the installed app directly by name. The implicit intent looks for a match with a scheme and an action. The last two requests stay unmet: one explicitly asks for an app that is not installed, the other sends a scheme that is in no app's filter.

```kotlin
// Main.kt — explicit and implicit intents
fun main() {
    val installed = setOf("gallery", "maps", "browser")
    val filters = listOf(gallery, maps, browser)
    val asks = listOf(
        Ask("explicit", "maps", resolveExplicit("maps", installed)),
        Ask("implicit-geo", "maps", resolve("VIEW", "geo", filters, null)),
        Ask("implicit-ftp", "browser", resolve("VIEW", "ftp", filters, null)),
        Ask("explicit-missing", "music", resolveExplicit("music", installed)),
    )
    for (ask in asks) {
        ask.report()
    }
    tally(asks)
}
```

```text
explicit: wanted=maps | given=maps -> full
implicit-geo: wanted=maps | given=maps -> full
implicit-ftp: wanted=browser | reason=no-match -> unmet
explicit-missing: wanted=music | reason=app-missing -> unmet
full: 2
substitute: 0
pending: 0
unmet: 2
```

The explicit intent and the matching implicit intent were fulfilled in full: both reached the same app. The implicit intent stayed unmet because the `ftp` scheme is in no filter; so did the explicit intent that calls an app that is not installed by name. The difference between the two unmet results shows in the reason: `no-match` is the absence of the match the platform looked for, while `app-missing` is the absence of the target itself.

The practical result: the code that sends an implicit intent must take into account that there may be no match. In this case the platform throws `ActivityNotFoundException`. The sender has to check beforehand with `resolveActivity` or with a package visibility declaration.

**AC2.** The model has no package visibility. In reality an app has to declare a `<queries>` element in the manifest file to see whether other apps are installed; this was not measured.

#### A Missing Category in the Filter

A filter can carry the right action and the right scheme and still not reach the intent. A missing `DEFAULT` category is the most common reason for this. The `browse-only` app has declared the `VIEW` action and the `image` scheme, but its filter has only the `BROWSABLE` category.

```kotlin
// Main.kt — the default category
fun main() {
    val asks = listOf(
        Ask("category-present", "gallery", resolve("VIEW", "image", listOf(gallery), null)),
        Ask("category-missing", "browse-only", resolve("VIEW", "image", listOf(browseOnly), null)),
        Ask("action-missing", "gallery", resolve("SEND", "image", listOf(gallery), null)),
    )
    for (ask in asks) {
        ask.report()
    }
    tally(asks)
}
```

```text
category-present: wanted=gallery | given=gallery -> full
category-missing: wanted=browse-only | reason=no-match -> unmet
action-missing: wanted=gallery | reason=no-match -> unmet
full: 1
substitute: 0
pending: 0
unmet: 2
```

The filter that carries the `DEFAULT` category was fulfilled in full. The filter that does not carry the category stayed unmet although its action and scheme were right. The intent whose action did not hold stayed unmet too. The cause of both shows under the same label (`no-match`); to find the cause, the three criteria of the filter are looked at one by one.

**AC3.** The `BROWSABLE` category is required only for links that come from the browser, and it is not added by itself to an implicit intent that comes with `startActivity`. The model builds this distinction with a single rule, the `DEFAULT` check.

#### More Than One Match

If two apps can fulfill the same intent, the platform leaves the choice to the user. The selection screen has been shown but the user has not chosen yet: the request has been neither fulfilled nor refused. This is a **pending** fulfillment, and the condition it waits on is `user-choice`.

```kotlin
// Main.kt — a single match, many matches, a default
fun main() {
    val filters = listOf(gallery, editor, maps)
    val asks = listOf(
        Ask("single-match", "maps", resolve("VIEW", "geo", filters, null)),
        Ask("many-matches", "gallery", resolve("VIEW", "image", filters, null)),
        Ask("default-set", "gallery", resolve("VIEW", "image", filters, "gallery")),
    )
    for (ask in asks) {
        ask.report()
    }
    tally(asks)
}
```

```text
single-match: wanted=maps | given=maps -> full
many-matches: wanted=gallery | waiting-on=user-choice -> pending
default-set: wanted=gallery | given=gallery -> full
full: 2
substitute: 0
pending: 1
unmet: 0
```

The single match is full. Because two apps fit the `image` scheme, the second request stayed pending; the code on the caller's side cannot see the selection screen, it sees only that `startActivity` returned. In the third request the user has made `gallery` the default, and the platform started that app without asking for a choice: full.

Earlier lessons kept "deferred" and "pending" apart. The same distinction holds here: the selection screen is not a deferral but a fulfillment waiting on a condition with a known name. The condition is carried as `user-choice`, and when the condition is met the result becomes full or a substitute.

**AC4.** The model carries the default choice as a single name. In reality the default is recorded separately by intent type and by data scheme; this was not measured.

#### When the Default App Is Removed

The app chosen as the default can be removed. In this case the same intent now gives another result. The user has made `gallery` the default; later `gallery` is deleted. If `editor` remains, there is a single match and the intent goes to it: the user wanted the gallery, the editor was given. If two apps remain, the choice is asked again.

```kotlin
// Main.kt — when the default app is removed
fun main() {
    val asks = listOf(
        Ask("still-installed", "gallery", resolve("VIEW", "image", listOf(gallery, editor), "gallery")),
        Ask("one-left", "gallery", resolve("VIEW", "image", listOf(editor), "gallery")),
        Ask("two-left", "gallery", resolve("VIEW", "image", listOf(editor, camera), "gallery")),
    )
    for (ask in asks) {
        ask.report()
    }
    tally(asks)
}
```

```text
still-installed: wanted=gallery | given=gallery -> full
one-left: wanted=gallery | given=editor -> substitute
two-left: wanted=gallery | waiting-on=user-choice -> pending
full: 1
substitute: 1
pending: 1
unmet: 0
```

If the default stays, the result is full. When the default is gone and a single app remains, the request was fulfilled by a **substitute**: the intent did not fail, but it did not go to the app that was wanted. When two apps remain, the platform goes back to asking the user again: pending. The same intent, the same code, got three different fulfillments as the set of installed apps changed.

#### The Bounding Measurement

The same intent, the same filters: only the number of installed apps changes. Zero apps is unmet, one app is full, two apps is pending.

```kotlin
// Main.kt — the same intent, three sets of installed apps
fun main() {
    val asks = listOf(
        Ask("none-installed", "gallery", resolve("VIEW", "image", emptyList(), null)),
        Ask("one-installed", "gallery", resolve("VIEW", "image", listOf(gallery), null)),
        Ask("two-installed", "gallery", resolve("VIEW", "image", listOf(gallery, editor), null)),
    )
    for (ask in asks) {
        ask.report()
    }
    tally(asks)
    println(asks.map { it.fulfillment })
}
```

```text
none-installed: wanted=gallery | reason=no-match -> unmet
one-installed: wanted=gallery | given=gallery -> full
two-installed: wanted=gallery | waiting-on=user-choice -> pending
full: 1
substitute: 0
pending: 1
unmet: 1
[unmet, full, pending]
```

Three requests, three different fulfillments. The fulfillment of this lesson is **pending**, because the result of an implicit intent comes not from the intent but from the apps installed on the device. The sending code cannot know in advance which result will come; the only reliable way is to look with `resolveActivity` and to be ready for all three results.

**AC5.** The model takes the apps to be a set installed on the device. Multi-user profiles, the work profile and whether an app is enabled or disabled were not measured.

#### Summary

- This lesson's criterion: **fulfillment**. An explicit intent gives its target by name; an implicit intent looks for a match by action, category and data. This lesson's fulfillment: **pending**; the result of an implicit intent depends on the installed apps.
- `startActivity` adds the `DEFAULT` category to an implicit intent; if the filter lacks this category there is no match: unmet.
- If more than one app matches, the fulfillment stays pending and its condition is `user-choice`. If a default is chosen, full.
- When the default app is removed, a single remaining app gives a substitute and two remaining apps give pending.
- The same intent gets three different fulfillments by the set of installed apps: unmet, full, pending.

#### Next Step

When an activity is started with an intent, the platform places it in a task. The next lesson, Tasks and the Back Stack, measures whether the launch modes create a new instance or use the existing one, and how the back stack looks at every step.

### Tasks and the Back Stack

Source: https://academia.sh/en/courses/android-platform-and-tools/tasks-and-back-stack

The structure of the task and the back stack, whether the launch modes use a new instance or the existing one, how the same mode gets two different fulfillments depending on the state of the stack, and the edge case of the CLEAR_TOP flag.

When an activity is started with an intent, the platform places it somewhere. In the previous lesson you measured which app an intent reaches; this lesson looks at what happens after it reaches it: whether a new screen is opened, whether the existing screen comes to the front, and where the back button takes the user afterwards.

In the platform, activities are gathered in a **task**. A task is the group of activities the user moves through in order while doing a piece of work: while writing an email, the screen for choosing the sender, the screen for attachments and the writing screen are in the same task. The activities inside a task are ordered in a **back stack**. A newly opened activity is put on the top of the stack; the back button removes the activity on top and brings the one below to the front. When the activity at the very bottom of the stack is removed, the task ends.

The stack works like a **stack** data structure: last in, first out. In this lesson you will show the stack as a list, and its order will be printed. The left end is the activity at the bottom, the right end is the top.

The question of the lesson: when a request to open the same activity again arrives, how does the platform **fulfill** the request? The request is always "open a new instance of this activity". The platform fulfills it according to the launch mode, either with a new instance or by using the existing instance. The first is a full fulfillment, the second a substitute.

```kotlin
// Fulfillment.kt — classifies how the platform fulfills a request
sealed interface Outcome {
    data class Given(val value: String) : Outcome
    data class Waiting(val on: String) : Outcome
    data class Refused(val reason: String) : Outcome
}

class Ask(val label: String, val wanted: String, val outcome: Outcome) {
    val fulfillment: String = when (outcome) {
        is Outcome.Waiting -> "pending"
        is Outcome.Refused -> "unmet"
        is Outcome.Given -> if (outcome.value == wanted) "full" else "substitute"
    }

    fun report() {
        val detail = when (outcome) {
            is Outcome.Given -> "given=${outcome.value}"
            is Outcome.Waiting -> "waiting-on=${outcome.on}"
            is Outcome.Refused -> "reason=${outcome.reason}"
        }
        println("$label: wanted=$wanted | $detail -> $fulfillment")
    }
}

fun tally(asks: List<Ask>) {
    for (name in listOf("full", "substitute", "pending", "unmet")) {
        println("$name: ${asks.count { it.fulfillment == name }}")
    }
}
```

#### The Launch Mode

The launch mode (`launchMode`) decides whether a new instance is opened or the existing instance is used when an activity is asked to start. The mode is declared for the activity in the manifest file.

```xml
<activity android:name=".DetailActivity" android:launchMode="singleTop" />
```

There are four modes.

- **standard**: the default mode. Every request puts a new instance of the activity on the top of the task. The same activity can be in the stack more than once.
- **singleTop**: if the target activity is already on the top of the stack, no new instance is opened; the new intent is delivered to the existing instance with the `onNewIntent` callback. If it is not on top, a new instance is opened as in standard.
- **singleTask**: if the target activity exists somewhere in the task, no new instance is opened. The existing instance is brought to the front and all the activities above it are cleared from the stack. If it does not exist, a new instance is opened.
- **singleInstance**: like singleTask, but the activity runs alone in its own task and holds no other activity in that task. This lesson does not measure this mode.

The mode is set not only in the manifest file but also with intent flags. The `FLAG_ACTIVITY_CLEAR_TOP` flag clears the activities above the target if the target is in the stack. `FLAG_ACTIVITY_NEW_TASK` puts the activity in a new task.

```kotlin
// Platform code: starting with a flag
import android.content.Intent

val intent = Intent(this, DetailActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
startActivity(intent)
```

#### The Stack Model

The model keeps the stack as a list. The helper file builds the launch mode in a single function: the stack, the target and the mode are given; the new stack and what happened are returned. What happened is one of two values: `new-instance` or `existing-instance`. What is wanted is always `new-instance`, because the calling code wants to open the activity.

```kotlin
// Tasks.kt — builds the launch modes and consecutive requests
class Launch(val stack: List<String>, val how: String)

fun launchActivity(stack: List<String>, target: String, mode: String): Launch {
    val at = stack.lastIndexOf(target)
    return when (mode) {
        "standard" -> Launch(stack + target, "new-instance")
        "singleTop" ->
            if (stack.lastOrNull() == target) Launch(stack, "existing-instance")
            else Launch(stack + target, "new-instance")
        "singleTask" ->
            if (at >= 0) Launch(stack.take(at + 1), "existing-instance")
            else Launch(stack + target, "new-instance")
        "standard-clearTop" ->
            if (at >= 0) Launch(stack.take(at) + target, "new-instance")
            else Launch(stack + target, "new-instance")
        "singleTop-clearTop" ->
            if (at >= 0) Launch(stack.take(at + 1), "existing-instance")
            else Launch(stack + target, "new-instance")
        else -> Launch(stack, "undefined-mode")
    }
}

fun runLaunches(start: List<String>, targets: List<String>, mode: String): Pair<List<Ask>, List<String>> {
    var stack = start
    val asks = mutableListOf<Ask>()
    for ((index, target) in targets.withIndex()) {
        val launch = launchActivity(stack, target, mode)
        asks.add(Ask("${index + 1}-$mode-$target", "new-instance", Outcome.Given(launch.how)))
        stack = launch.stack
    }
    return Pair(asks, stack)
}
```

`runLaunches` applies consecutive requests in order and collects each request as an `Ask`. The request label carries the step number, the mode and the target. The stack is updated after every step.

**AC1.** The model assumes a single stack per task and does not measure the `NEW_TASK` flag, the `taskAffinity` attribute or multi-task cases. The activities in the stack are kept only as names; their state, their lifecycle callbacks and their destruction are not modeled.

#### The standard Mode

In the `standard` mode every request opens a new instance. A is open; B, then A, then A again are started. It is valid for A to be in the stack three times.

```kotlin
// Main.kt — standard mode
fun main() {
    val (asks, stack) = runLaunches(listOf("A"), listOf("B", "A", "A"), "standard")
    for (ask in asks) {
        ask.report()
    }
    tally(asks)
    println(stack.joinToString(" > "))
}
```

```text
1-standard-B: wanted=new-instance | given=new-instance -> full
2-standard-A: wanted=new-instance | given=new-instance -> full
3-standard-A: wanted=new-instance | given=new-instance -> full
full: 3
substitute: 0
pending: 0
unmet: 0
A > B > A > A
```

All three requests were fulfilled in full: each opened a new instance. The stack grew to four elements and A is in it three times. The user reaches the first A in this stack by pressing the back button three times, and each of the four elements is a separate instance. This is the cost the standard mode brings: code that opens the same screen again and again grows the stack silently.

#### The singleTop Mode

`singleTop` does not open a new instance if the target is already on top. A flow that links from a detail screen to itself, or tapping a user notification twice in a row, is an example of this situation. The stack starts as A, B; two requests arrive for B and two for C.

```kotlin
// Main.kt — singleTop mode
fun main() {
    val (asks, stack) = runLaunches(listOf("A", "B"), listOf("B", "C", "C"), "singleTop")
    for (ask in asks) {
        ask.report()
    }
    tally(asks)
    println(stack.joinToString(" > "))
}
```

```text
1-singleTop-B: wanted=new-instance | given=existing-instance -> substitute
2-singleTop-C: wanted=new-instance | given=new-instance -> full
3-singleTop-C: wanted=new-instance | given=existing-instance -> substitute
full: 1
substitute: 2
pending: 0
unmet: 0
A > B > C
```

B was already on top; the first request used the existing instance and the fulfillment was a substitute. C was not on top, a new instance was opened and it was fulfilled in full. In the third request C is now on top, so this time a substitute. The stack stayed A, B, C; the two substituted requests did not grow the stack. The calling side receives its new intent in the `onNewIntent` callback and has to update its screen. Code that assumes a new instance was opened works wrongly here.

**AC2.** The model does not measure whether `onNewIntent` is called; it measures only what happens to the stack. How the intent is delivered to the existing instance and whether the screen is updated were not measured.

#### The singleTask Mode

`singleTask` brings the target to the front if it is anywhere in the stack and clears what is above it. If a request for B arrives in the stack A, B, C, C is removed from the stack; B becomes the top. Then D is opened, then B is requested again: D is cleared too.

```kotlin
// Main.kt — singleTask mode
fun main() {
    val (asks, stack) = runLaunches(listOf("A", "B", "C"), listOf("B", "D", "B"), "singleTask")
    for (ask in asks) {
        ask.report()
    }
    tally(asks)
    println(stack.joinToString(" > "))
}
```

```text
1-singleTask-B: wanted=new-instance | given=existing-instance -> substitute
2-singleTask-D: wanted=new-instance | given=new-instance -> full
3-singleTask-B: wanted=new-instance | given=existing-instance -> substitute
full: 1
substitute: 2
pending: 0
unmet: 0
A > B
```

The first and the third requests used the existing B and were substitutes; the second was fulfilled in full with a new instance because D was not in the stack. The stack ends as A, B. The requests added C and D to the stack and then removed them: `singleTask` does not stop at not opening a new instance, it also discards the screens the user went through before returning to B. This is designed for screens that must stand at the root of the task, such as a home screen or an entry point.

#### The Edge Case of the CLEAR_TOP Flag

The `FLAG_ACTIVITY_CLEAR_TOP` flag moves the stack-clearing behavior of singleTask to a flag, but what happens to the existing instance depends on the mode. If the target is in the `standard` mode, the platform destroys the existing instance as well and creates a new one. If the target is in the `singleTop` mode, or if `FLAG_ACTIVITY_SINGLE_TOP` is given together with the flag, the existing instance is kept and `onNewIntent` is called.

Two requests send the same target to the same stack: B in the stack A, B, C.

```kotlin
// Main.kt — CLEAR_TOP and the mode
fun main() {
    val standardClear = runLaunches(listOf("A", "B", "C"), listOf("B"), "standard-clearTop")
    val singleTopClear = runLaunches(listOf("A", "B", "C"), listOf("B"), "singleTop-clearTop")
    val asks = standardClear.first + singleTopClear.first
    for (ask in asks) {
        ask.report()
    }
    tally(asks)
    println(standardClear.second.joinToString(" > "))
    println(singleTopClear.second.joinToString(" > "))
}
```

```text
1-standard-clearTop-B: wanted=new-instance | given=new-instance -> full
1-singleTop-clearTop-B: wanted=new-instance | given=existing-instance -> substitute
full: 1
substitute: 1
pending: 0
unmet: 0
A > B
A > B
```

The two stacks are the same: A, B. The fulfillments differ: with standard a new instance was opened and it is full; with singleTop the existing instance was kept and it is a substitute. The stack as seen from outside is the same in both cases; the difference lies in the identity of the instance and in its state. In the standard mode, the unsaved state inside the screen is lost because the instance is renewed.

The reason for this subtle difference is that what is measured is not the stack but the instance. The fulfillment category looks not at "how does the stack look" but at "how was the request fulfilled", and the two turn out independent of each other.

#### The Bounding Measurement

The same mode, the same target; only the state of the stack changes. In the `singleTop` mode B is the target. In one case B is on top, in the other it is left under C. For comparison with `singleTask`, a third case is added: the mode is singleTask, and B is again under C.

```kotlin
// Main.kt — the same mode, two stack states
fun main() {
    val cases = listOf(
        Triple("singleTop-on-top", listOf("A", "B"), "singleTop"),
        Triple("singleTop-not-on-top", listOf("A", "B", "C"), "singleTop"),
        Triple("singleTask-not-on-top", listOf("A", "B", "C"), "singleTask"),
    )
    val asks = mutableListOf<Ask>()
    for ((label, stack, mode) in cases) {
        val launch = launchActivity(stack, "B", mode)
        asks.add(Ask(label, "new-instance", Outcome.Given(launch.how)))
        println("$label stack: ${launch.stack.joinToString(" > ")}")
    }
    for (ask in asks) {
        ask.report()
    }
    tally(asks)
    println(asks.map { it.fulfillment }.distinct().sorted())
}
```

```text
singleTop-on-top stack: A > B
singleTop-not-on-top stack: A > B > C > B
singleTask-not-on-top stack: A > B
singleTop-on-top: wanted=new-instance | given=existing-instance -> substitute
singleTop-not-on-top: wanted=new-instance | given=new-instance -> full
singleTask-not-on-top: wanted=new-instance | given=existing-instance -> substitute
full: 1
substitute: 2
pending: 0
unmet: 0
[full, substitute]
```

The `singleTop` mode got two different fulfillments on two stacks. If B is on top, a substitute: the stack did not change. If B is not on top, full: the stack became A, B, C, B and B is in the stack with two instances. `singleTask`, on the same stack, brought B to the front and discarded C: a substitute.

What this measurement says is this: the mode alone does not determine the fulfillment. The fulfillment comes out of a rule that works over the mode and the current state of the stack together. An app that runs the same code at two different moments sees two different behaviors depending on where the user came from. The fulfillment of this lesson is **substitute**: a request to open the same activity is most often fulfilled by the platform with the existing instance.

**AC3.** The model measures the cases in which the target instance is in the stack only once. Which instance the `singleTop` and `CLEAR_TOP` rules choose when the target has two instances in the stack, and transitions between tasks (for example `singleInstance`), were not measured.

#### Summary

- This lesson's criterion: **fulfillment**. Activities are gathered in a task, and the task in a back stack. The launch mode decides whether a request to open the same activity is fulfilled with a new instance or with the existing instance. This lesson's fulfillment: **substitute**; a request to open the same activity is most often carried out with the existing instance.
- `standard` opens a new instance on every request: 3 full. `singleTop` uses the existing instance if the target is on top: substitute.
- `singleTask` brings the target to the front if it is in the stack and clears what is above it: substitute; if it is not in the stack, a new instance, full.
- With `CLEAR_TOP`, the existing instance is renewed in the `standard` mode (full) and kept in the `singleTop` mode (substitute). The stack as seen from outside is the same.
- The same mode gets two fulfillments depending on the state of the stack: `singleTop` is a substitute on top and full when not on top.

#### Next Step

You measured which task and which place in the order a screen takes. That screen cannot be started at all unless it has been introduced to the platform beforehand as an app component. The place of this introduction is the manifest file; the next lesson, The Manifest File, measures what is written there and how files that come from more than one source are merged.

## Project Structure and Tools

The manifest file that declares components and permissions, qualified resources and configuration changes, build scripts, dependencies and build types, product flavors and build variants, the development environment with the emulator, device and profiling tools, and install-time and runtime permissions.

### The Manifest File

Source: https://academia.sh/en/courses/android-platform-and-tools/manifest-file

The manifest file's role in introducing the app to the platform, and a model of merging that asks, for each element, whether what was wanted is preserved.

The previous lesson, Tasks and the Back Stack, measured which task and which position a screen lands in. That screen cannot be started at all unless it has first been introduced to the platform as an app component. The place for that introduction is the manifest file. This lesson measures what is written in the manifest file and how manifest files coming from several sources merge into a single file.

The measurement axis of the course is the same as in the opening lesson: how does the platform fulfill this request. There are four fulfillments: full, substitute, pending and unmet. The rule, not a label, gives the fulfillment: pending and unmet come directly from the outcome, and a given value that equals the wanted one is full, while one that differs is substitute. The file below carries that rule and is repeated verbatim in every lesson. The binding count of 3/2/2/1 from the opening lesson is not changed here; it is only referred to.

```kotlin
// Fulfillment.kt — classifies how the platform fulfills a request
sealed interface Outcome {
    data class Given(val value: String) : Outcome
    data class Waiting(val on: String) : Outcome
    data class Refused(val reason: String) : Outcome
}

class Ask(val label: String, val wanted: String, val outcome: Outcome) {
    val fulfillment: String = when (outcome) {
        is Outcome.Waiting -> "pending"
        is Outcome.Refused -> "unmet"
        is Outcome.Given -> if (outcome.value == wanted) "full" else "substitute"
    }

    fun report() {
        val detail = when (outcome) {
            is Outcome.Given -> "given=${outcome.value}"
            is Outcome.Waiting -> "waiting-on=${outcome.on}"
            is Outcome.Refused -> "reason=${outcome.reason}"
        }
        println("$label: wanted=$wanted | $detail -> $fulfillment")
    }
}

fun tally(asks: List<Ask>) {
    for (name in listOf("full", "substitute", "pending", "unmet")) {
        println("$name: ${asks.count { it.fulfillment == name }}")
    }
}
```

#### The Role of the Manifest File

The manifest file is the app's written declaration to the platform. Before running the app, the platform looks at this file and learns four things. The first is the app identity: the package name that makes the app unique on the device. The second is the app components: screens, background jobs, broadcast receivers and content providers, and the platform does not recognize a component that is not declared here. The third is the permissions the app requests. The fourth is the hardware features the app needs; the camera, for example, is declared as required or optional with the `uses-feature` element. The fragment below is a small file carrying these four things.

```xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-feature android:name="android.hardware.camera" android:required="true" />
    <uses-permission android:name="android.permission.CAMERA" />

    <application android:label="Notes" android:allowBackup="true">
        <activity android:name=".NoteListActivity" android:exported="true" />
    </application>
</manifest>
```

This fragment is in the platform's format and is not run. The measurements that follow model the merging rule, not the file itself. The model builds this rule of the platform; it is not the platform's real merging tool.

#### The Model of Merging

An app does not arrive with a single manifest file. The app module has its file; each library that is added brings its own; there can also be files specific to a build variant. The build task merges them into one file. When there is no conflict, the elements come together as they are. When the same attribute of the same element has different values in two files, the merge cannot resolve it on its own: unless the author marks the element in the app's file with `tools:replace` and declares that it will overwrite, the merge stops with an error.

The model builds this as one request per element. The wanted value is the value the library wrote for the element. If the app has not touched that element, or has written the same value, the library's value is preserved. If the app has written a different value and marked the element with `tools:replace`, the given value is the app's. If there is no marker, the conflict stays and no value is given. `mergeManifest` applies this; only the app and the library are taken as layers.

```kotlin
// Merge.kt — a model of manifest merging
class Layer(val values: Map<String, String>, val replaced: Set<String> = emptySet())

fun mergeManifest(app: Layer, library: Layer): List<Ask> =
    library.values.toSortedMap().map { (key, wanted) ->
        val own = app.values[key]
        val outcome = when {
            own == null || own == wanted -> Outcome.Given(wanted)
            key in app.replaced -> Outcome.Given(own)
            else -> Outcome.Refused("unmarked-conflict")
        }
        Ask(key, wanted, outcome)
    }
```

The keys have the form `element.attribute` and are sorted before they are printed. In the first measurement the library brings two elements: a hardware feature that declares the camera required, and an app attribute that supports right-to-left writing. The app does not touch either one; it writes only its own label and its own screen.

```kotlin
// Main.kt — merging without conflicts
fun main() {
    val app = Layer(mapOf(
        "application.label" to "Notes",
        "activity.NoteListActivity" to "exported",
    ))
    val library = Layer(mapOf(
        "uses-feature.camera" to "required",
        "application.supportsRtl" to "true",
    ))
    val asks = mergeManifest(app, library)
    for (ask in asks) ask.report()
    tally(asks)
}
```

```text
application.supportsRtl: wanted=true | given=true -> full
uses-feature.camera: wanted=required | given=required -> full
full: 2
substitute: 0
pending: 0
unmet: 0
```

Both elements were preserved as wanted. The measurement gives not only the `full` count but a line for each element: that a value the library wanted was not silently lost can be seen in the line.

**PS1.** In the first two measurements the model merges only two layers, the app and a single library; three layers are measured separately in the next section. How the order of precedence works when there are several libraries or a build variant file is not in these measurements.

**PS2.** Each element is represented by a single key and a single value. In a real manifest file an element has several attributes; the model counts them as separate keys.

#### Overwriting and Conflict

The second measurement contains conflicts. The library brings three elements. For `application.label` the app has written the same value. For `application.allowBackup` the library wants `true`, the app has written `false`, and the app has marked the element with `tools:replace`. For `application.theme` the values are also different, but the app has not marked the element.

```kotlin
// Main.kt — overwriting and conflict
fun main() {
    val app = Layer(
        values = mapOf(
            "application.label" to "Notes",
            "application.allowBackup" to "false",
            "application.theme" to "Dark",
        ),
        replaced = setOf("application.allowBackup"),
    )
    val library = Layer(mapOf(
        "application.label" to "Notes",
        "application.allowBackup" to "true",
        "application.theme" to "Light",
    ))
    val asks = mergeManifest(app, library)
    for (ask in asks) ask.report()
    tally(asks)
}
```

```text
application.allowBackup: wanted=true | given=false -> substitute
application.label: wanted=Notes | given=Notes -> full
application.theme: wanted=Light | reason=unmarked-conflict -> unmet
full: 1
substitute: 1
pending: 0
unmet: 1
```

The three elements fell into three different fulfillments. `allowBackup` is substitute: a value was given, but not the one the library wanted. The rule sees this from the difference between `true` and `false`; it does not know that the author did it on purpose. `theme` is unmet: no value was given, because an unmarked conflict stops the merge. What the rule sees is only the result and the equality; whether `tools:replace` is there affects the fulfillment not directly but through the given value.

M30/K02 asked how two definitions are resolved when they meet, and its permission lessons measured the meeting of the wanted and given sets. That is not the measure in this lesson: what is measured is not the meeting of two sets but, for each element taken as a separate request, whether what was wanted is preserved. The same two files can have the same set of keys and still have some elements full and some substitute.

#### Merge Precedence

The manifest file is not a single file. At build time the app's main file, the files of the build variant and the file of each library are merged. The order of precedence rises from the library toward the variant: the library has the lowest precedence, the app's main file stands in the middle, and the build variant's files have the highest precedence. A higher-precedence layer can leave the value written by a lower-precedence layer as it is, repeat it with the same value, or override it by marking it with a different value. The fragment below shows two markers: `tools:replace` overrides an attribute value, and `tools:node="remove"` removes the element from the merge.

```xml
<application
    android:label="@string/app_name"
    tools:replace="android:label">
    <activity android:name=".LegacyScreen" tools:node="remove" />
</application>
```

The model builds this precedence as a chain of three layers. The wanted value is the one written by the lowest layer, the library. Each following layer preserves the value, repeats the same value, or writes a different one. A different value passes only if that layer has marked the element; an unmarked difference is a conflict and the chain stops there. For an element that does not exist in the library at all, there is no value to be wanted, so the model gives it as unmet.

```kotlin
// Chain.kt — a model of layered merging
fun mergeChain(key: String, layers: List<Layer>): Ask {
    val wanted = layers.first().values[key] ?: return Ask(key, "-", Outcome.Refused("not-in-library"))
    var current = wanted
    for (layer in layers.drop(1)) {
        val value = layer.values[key] ?: continue
        if (value == current) continue
        if (key in layer.replaced) current = value
        else return Ask(key, wanted, Outcome.Refused("unmarked-conflict"))
    }
    return Ask(key, wanted, Outcome.Given(current))
}
```

In the first measurement there are three layers, in the order library, main file, variant. The library writes three elements. The app's main file repeats one element with the same value and writes one element with an unmarked different value. The variant overrides one element by marking it.

```kotlin
// Main.kt — three layers
fun main() {
    val library = Layer(mapOf("label" to "library-name", "direction" to "right-to-left", "camera" to "required"))
    val variant = Layer(mapOf("label" to "variant-name"), setOf("label"))
    val app = Layer(mapOf("direction" to "right-to-left", "camera" to "optional"))
    val asks = listOf("label", "direction", "camera").map { mergeChain(it, listOf(library, app, variant)) }
    for (ask in asks) ask.report()
    tally(asks)
}
```

```text
label: wanted=library-name | given=variant-name -> substitute
direction: wanted=right-to-left | given=right-to-left -> full
camera: wanted=required | reason=unmarked-conflict -> unmet
full: 1
substitute: 1
pending: 0
unmet: 1
```

The `label` element, once overridden by the variant, is substitute because the given value differs from the wanted one. The `direction` element did not change at all and is full. The `camera` element met an unmarked difference: the app wrote the hardware the library called required as optional but did not mark the element, so no value was given.

**PS3.** The model takes the layer order as fixed: library, the app's main file, variant. In a real merge the order among several libraries and among several variant files is determined separately; the measurement does not cover it.

#### Resolving the Conflict

An unmarked conflict is resolved in two ways: either the lower-precedence layer gives up its value, or the higher-precedence layer marks the element. Since changing the library file is mostly not possible, the resolution is on the app side. The result changes with the position of the marker: if the marker stays in the overriding layer the value is overridden, and if another layer comes in between the marker takes effect only as far as that layer. The measurement below compares two separate cases. In the first, the app marks the camera element and overrides it. In the second, the marker was placed by the app's main file and the variant file later writes a different value.

```kotlin
// Main.kt — the position of the marker
fun main() {
    val library = Layer(mapOf("camera" to "required"))
    val marked = Layer(mapOf("camera" to "optional"), setOf("camera"))
    val appMarked = Layer(mapOf("camera" to "optional"), setOf("camera"))
    val variantOther = Layer(mapOf("camera" to "forbidden"))
    val asks = listOf(
        mergeChain("camera", listOf(library, marked)).let { Ask("app-marked", it.wanted, it.outcome) },
        mergeChain("camera", listOf(library, appMarked, variantOther)).let { Ask("main-file-marked", it.wanted, it.outcome) },
    )
    for (ask in asks) ask.report()
    tally(asks)
}
```

```text
app-marked: wanted=required | given=optional -> substitute
main-file-marked: wanted=required | reason=unmarked-conflict -> unmet
full: 0
substitute: 1
pending: 0
unmet: 1
```

The marker protects only the value of the layer that places it; a different value in the following layer asks for a marker again. The rule tells these apart: both requests look at the same element, but in the first the last link of the chain is marked and in the second it is not.

**PS4.** The model keeps the marker at the element level. In a real file the marker can also be given at the attribute level and can cover several attributes at once; the measurement does not separate this.

#### The Bounding Measurement

The model has one side it cannot measure: the value may not be written in the manifest file. A library or the app file leaves the value as a placeholder such as `${appLabel}`; the real value is supplied by the build variant's setting. Until the placeholder is resolved, the result of the merge is not known. `resolvePlaceholder` asks about the same element twice: first when the variant has not supplied the value, then when it has.

```kotlin
// Placeholder.kt — resolving a placeholder
fun resolvePlaceholder(label: String, wanted: String, token: String, provided: Map<String, String>): Ask {
    val value = provided[token]
    val outcome = if (value == null) Outcome.Waiting("build-variant") else Outcome.Given(value)
    return Ask(label, wanted, outcome)
}
```

```kotlin
// Main.kt — the same element, two moments
fun main() {
    val before = resolvePlaceholder("application.label", "Notes", "appLabel", emptyMap())
    val after = resolvePlaceholder("application.label", "Notes", "appLabel", mapOf("appLabel" to "Notes"))
    val asks = listOf(before, after)
    for (ask in asks) ask.report()
    tally(asks)
}
```

```text
application.label: wanted=Notes | waiting-on=build-variant -> pending
application.label: wanted=Notes | given=Notes -> full
full: 1
substitute: 0
pending: 1
unmet: 0
```

The same element was first pending, then full. The pending fulfillment is not a lasting result but a moment: what the rule calls pending turns into another result once the thing waited on arrives. That is why the two lines carry the same label and the tally sees them as two separate requests. The measurement also does not say whether the placeholder's value is wrong; it looks only at whether the given value equals the wanted one.

**PS5.** The model does not claim what the build actually produces when the placeholder is unresolved, that is, whether it gives an error or continues with an empty value. The pending fulfillment is only the model's classification.

#### Summary

- This lesson's criterion: **fulfillment**; in merging, for each element, whether what was wanted is preserved; a request per element, not a set. This lesson's fulfillment: **full**.
- The manifest file declares the app identity, the app components, the permissions and the hardware features to the platform.
- In merging, the library's value is preserved as full as long as the app does not touch it or writes the same value.
- An element overwritten with `tools:replace` is substitute: the given value differs from the wanted one. An unmarked conflict is unmet.
- A placeholder is pending until the build variant gives its value; once it has given it, the same element becomes full.

#### Next Step

The manifest file declares what the app is; the text, images and layouts shown on screens live in resources. The next lesson measures what the resource system gives when it selects a resource for the requested configuration and no exact match can be found.

### The Resource System

Source: https://academia.sh/en/courses/android-platform-and-tools/resource-system

A model of selecting a resource ID from qualified directories by configuration, covering exact match, falling back to the default, and qualifier precedence.

The previous lesson measured the merging of the manifest file, counting each element as a separate request. The manifest file declares what the app is; the text, images and layouts shown on screen live in resources. Resources are not embedded in code but kept in separate files, because the same resource must come from a different file depending on the device's language, screen size and day or night appearance. This lesson measures what the resource system gives when it selects a resource for the requested configuration.

The measurement axis of the course does not change: how does the platform fulfill this request. There are four fulfillments: full, substitute, pending and unmet. The rule, not a label, gives the fulfillment: a given value that equals the wanted one is full, and one that differs is substitute; pending and unmet come directly from the outcome. The file below carries that rule and is repeated verbatim in every lesson. The count of 3/2/2/1 from the opening lesson is not changed here.

```kotlin
// Fulfillment.kt — classifies how the platform fulfills a request
sealed interface Outcome {
    data class Given(val value: String) : Outcome
    data class Waiting(val on: String) : Outcome
    data class Refused(val reason: String) : Outcome
}

class Ask(val label: String, val wanted: String, val outcome: Outcome) {
    val fulfillment: String = when (outcome) {
        is Outcome.Waiting -> "pending"
        is Outcome.Refused -> "unmet"
        is Outcome.Given -> if (outcome.value == wanted) "full" else "substitute"
    }

    fun report() {
        val detail = when (outcome) {
            is Outcome.Given -> "given=${outcome.value}"
            is Outcome.Waiting -> "waiting-on=${outcome.on}"
            is Outcome.Refused -> "reason=${outcome.reason}"
        }
        println("$label: wanted=$wanted | $detail -> $fulfillment")
    }
}

fun tally(asks: List<Ask>) {
    for (name in listOf("full", "substitute", "pending", "unmet")) {
        println("$name: ${asks.count { it.fulfillment == name }}")
    }
}
```

#### Resource Types and the Resource ID

Resources live under the `res` directory, in subdirectories separated by type. Text is in XML files in the `values` directory; drawable images are in the `drawable` directory; screen layouts are in the `layout` directory. Every resource has a name, and the build task produces a resource ID from these names: code reaches them by writing `R.string.title` or `R.drawable.logo`. The code does not know which file it will be read from, only the ID; choosing the file is the system's job.

```xml
<resources>
    <string name="title">Notes</string>
    <string name="subtitle">Everything in one place</string>
</resources>
```

This fragment is the text file in the default `values` directory. For German, a separate file with the same names can be placed in a `values-de` directory, containing only the `title` ID. The `-de` part of the directory name is called a qualifier. The fragment below shows reading by ID from code.

```kotlin
// Platform code: reading text by ID
val heading = getString(R.string.title)
```

This is platform code and is not run. On this line the system selects the directory that matches the device's current configuration; the model builds this rule of the platform. Qualifiers name a configuration property such as language, region, night appearance and smallest screen width: `values-de`, `values-de-rAT`, `values-night`, `layout-sw600dp`.

#### The Selection Model

The model knows five things: the resource key, the wanted directory name, the device's configuration and the directories. The wanted directory is the one the configuration names exactly. A directory is a candidate if it contains the key and all of its qualifiers are in the configuration. Among the candidates, the one with the highest-precedence qualifier is selected; the order of precedence is language, region, screen width, night appearance. The default directory has no qualifier, so if it contains the key it is always a candidate. If there is no candidate, the resource cannot be found.

```kotlin
// Resources.kt — a model of resource selection
class ResourceDir(val name: String, val keys: Set<String>) {
    val tokens: List<String> = name.split("-").drop(1)
}

fun qualifierWeight(token: String): Int = when {
    token == "night" -> 1
    token.startsWith("sw") -> 2
    token.length == 3 && token.startsWith("r") -> 4
    else -> 8
}

fun resolveResource(key: String, wanted: String, config: Set<String>, dirs: List<ResourceDir>): Ask {
    val candidates = dirs.filter { key in it.keys && it.tokens.all { token -> token in config } }
    val best = candidates.maxByOrNull { dir -> dir.tokens.sumOf { qualifierWeight(it) } }
    val outcome = if (best == null) Outcome.Refused("no-default") else Outcome.Given(best.name)
    return Ask(key, wanted, outcome)
}
```

The weights build the order of precedence: language weighs eight, region four, width two and night one. The weight of a higher qualifier is greater than the sum of those below it; so language comes first no matter how many width and night qualifiers there are. The first measurement asks for three resources. The text key is in the German directory and the configuration is German. The image is in the night directory and the configuration includes night. The subtitle text, however, is found only in the default directory; the German directory does not contain it.

```kotlin
// Main.kt — exact match and falling back to the default
fun main() {
    val dirs = listOf(
        ResourceDir("values", setOf("string/title", "string/subtitle")),
        ResourceDir("values-de", setOf("string/title")),
        ResourceDir("drawable", setOf("drawable/logo")),
        ResourceDir("drawable-night", setOf("drawable/logo")),
    )
    val config = setOf("de", "night")
    val asks = listOf(
        resolveResource("string/title", "values-de", config, dirs),
        resolveResource("drawable/logo", "drawable-night", config, dirs),
        resolveResource("string/subtitle", "values-de", config, dirs),
    )
    for (ask in asks) ask.report()
    tally(asks)
}
```

```text
string/title: wanted=values-de | given=values-de -> full
drawable/logo: wanted=drawable-night | given=drawable-night -> full
string/subtitle: wanted=values-de | given=values -> substitute
full: 2
substitute: 1
pending: 0
unmet: 0
```

The first two IDs came from the exactly matching directory. For the subtitle the wanted directory was German but it was not there; the system gave the text in the default directory. That is a substitute, and the app did not crash: the default directory is a safety net.

**PS1.** The model knows four kinds of qualifier and expects at most one of each kind in a directory. The real set of qualifiers is much wider, and each kind has its own matching rule.

**PS2.** The model counts the presence of a qualifier in the configuration as sufficient. For numeric qualifiers such as screen width, the real system compares against a threshold, not equality; in this measurement `sw600dp` is treated only as a name.

#### Configuration Change

When the device is rotated or night appearance is turned on, the configuration changes and the activity is recreated; resources are selected again for the new configuration. The measurement below asks about the same key in two configurations. The directories are `values`, `values-de` and `values-night`. In the German configuration `values-de` is selected. When night is added, both `values-de` and `values-night` become candidates; the wanted one, however, is `values-de-night`, which names the combination of the two. The third line asks, in a French configuration, for a key found only in the German directory.

```kotlin
// Main.kt — selecting again when the configuration changes
fun main() {
    val dirs = listOf(
        ResourceDir("values", setOf("string/title")),
        ResourceDir("values-de", setOf("string/title", "string/promo")),
        ResourceDir("values-night", setOf("string/title")),
    )
    val asks = listOf(
        resolveResource("string/title", "values-de", setOf("de"), dirs),
        resolveResource("string/title", "values-de-night", setOf("de", "night"), dirs),
        resolveResource("string/promo", "values-fr", setOf("fr"), dirs),
    )
    for (ask in asks) ask.report()
    tally(asks)
}
```

```text
string/title: wanted=values-de | given=values-de -> full
string/title: wanted=values-de-night | given=values-de -> substitute
string/promo: wanted=values-fr | reason=no-default -> unmet
full: 1
substitute: 1
pending: 0
unmet: 1
```

The same ID went from full to substitute when the configuration changed: the combined directory that the second configuration named did not exist. The third ID is unmet, because `string/promo` was only in the German directory and was not in the default directory; no candidate remained in the French configuration. What exactly the real system does in this case is not claimed in this lesson; the model only classifies that a counterpart was not given.

The configuration is an input to the measurement, but it is not the input that decides the result. The same configuration gives full for one ID and substitute for another. What decides is whether the given directory equals the wanted one.

#### Type Directories and Screen Width

Resource directories are named by type. `values` carries simple values such as text, color and dimension, `drawable` carries images, and `layout` carries screen layouts. The directory of each type can take its own qualifiers. The screen-width qualifier is written like `sw600dp` and applies on devices whose smallest side is not less than that value. The fragment below shows a separate layout directory for wide screens.

```xml
<!-- layout-sw600dp/main.xml -->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="horizontal"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />
```

The same name can also exist in the `layout` directory; on a wide screen the one in `layout-sw600dp` is selected, and on a narrow screen the one in the default directory. The wanted one is again the directory that the configuration names exactly. In the measurement the configuration is wide like a tablet, and German. It asks for three types: a layout, a text and an image. The layout is only in the wide-screen directory and in the default. The text is only in the German directory and in the default. The image is only in the default directory.

```kotlin
// Main.kt — resource types
fun main() {
    val dirs = listOf(
        ResourceDir("layout", setOf("layout/main")),
        ResourceDir("layout-sw600dp", setOf("layout/main")),
        ResourceDir("values", setOf("string/title")),
        ResourceDir("values-de", setOf("string/title")),
        ResourceDir("drawable", setOf("drawable/logo")),
    )
    val config = setOf("de", "sw600dp")
    val asks = listOf(
        resolveResource("layout/main", "layout-sw600dp", config, dirs),
        resolveResource("string/title", "values-de", config, dirs),
        resolveResource("drawable/logo", "drawable-sw600dp", config, dirs),
    )
    for (ask in asks) ask.report()
    tally(asks)
}
```

```text
layout/main: wanted=layout-sw600dp | given=layout-sw600dp -> full
string/title: wanted=values-de | given=values-de -> full
drawable/logo: wanted=drawable-sw600dp | given=drawable -> substitute
full: 2
substitute: 1
pending: 0
unmet: 0
```

The first two requests were fulfilled from the directory the configuration named. The third asked for an image specific to wide screens, but only the default image existed; the default was given, and it was a substitute.

**PS3.** The model recognizes only the `de`, `rAT`, `night` and `sw` qualifiers. In the real system there are other qualifiers such as density, orientation and platform version; the measurement does not cover them.

#### The Steps of Elimination

Selection is done in two steps. First, the directories that conflict with the configuration are eliminated: a directory with a qualifier that is not in the configuration cannot be a candidate. Then, among the remaining candidates, the best one is selected by the qualifier order of precedence. In the first step `values-night` is eliminated in a daytime configuration, while `values-de` stays in a German configuration. If the candidate list is empty, the resource cannot be found. If it is not in the default directory either, that means the resource is not defined anywhere. In the measurement the configuration is daytime and German. There is a night directory and a German directory; in addition, a key that is in no directory is asked for.

```kotlin
// Main.kt — an eliminated directory and a missing key
fun main() {
    val dirs = listOf(
        ResourceDir("values", setOf("string/title")),
        ResourceDir("values-night", setOf("string/title")),
        ResourceDir("values-de", setOf("string/title")),
    )
    val config = setOf("de")
    val asks = listOf(
        resolveResource("string/title", "values-night", config, dirs),
        resolveResource("string/title", "values-de", config, dirs),
        resolveResource("string/missing", "values", config, dirs),
    )
    for (ask in asks) ask.report()
    tally(asks)
}
```

```text
string/title: wanted=values-night | given=values-de -> substitute
string/title: wanted=values-de | given=values-de -> full
string/missing: wanted=values | reason=no-default -> unmet
full: 1
substitute: 1
pending: 0
unmet: 1
```

Although the night directory was wanted, it was eliminated in the daytime configuration and the German directory was given: substitute. The key that is in no directory stayed unmet; the reason is named `no-default`, because the model could not find it in the default directory either.

**PS4.** The model eliminates by checking whether the qualifier is in the configuration. The real system proceeds by eliminating qualifiers one by one in order of precedence; in most cases the two give the same result, but the measurement does not compare them.

#### The Bounding Measurement

The model matches qualifiers one by one; it does not know that one qualifier may overlap with a close neighbor. A separate directory such as `values-de-rAT` can be written for every region where German is spoken. If there is no region directory and only `values-de` exists, an Austrian-region configuration reads from the German directory: the model classifies this as a substitute. The second and third lines test the collision of two qualifiers. The directories are `values-de` and `values-night`, and the configuration contains both. It is asked first with both present, then after the German directory is removed.

```kotlin
// Main.kt — region and precedence
fun main() {
    val region = listOf(
        ResourceDir("values", setOf("string/title")),
        ResourceDir("values-de", setOf("string/title")),
    )
    val both = listOf(
        ResourceDir("values-de", setOf("string/title")),
        ResourceDir("values-night", setOf("string/title")),
        ResourceDir("values", setOf("string/title")),
    )
    val nightOnly = both.filter { it.name != "values-de" }
    val config = setOf("de", "rAT", "night")
    val asks = listOf(
        resolveResource("string/title", "values-de-rAT", config, region),
        resolveResource("string/title", "values-de-night", config, both),
        resolveResource("string/title", "values-de-night", config, nightOnly),
    )
    for (ask in asks) ask.report()
    tally(asks)
}
```

```text
string/title: wanted=values-de-rAT | given=values-de -> substitute
string/title: wanted=values-de-night | given=values-de -> substitute
string/title: wanted=values-de-night | given=values-night -> substitute
full: 0
substitute: 3
pending: 0
unmet: 0
```

All three lines are substitute, and the rule does not tell them apart: in all three the given differs from the wanted. What separates them is the given column. In the second line, with two candidates, language won. In the third line, since the language directory was gone, the night directory was selected. The model builds the order of precedence; the rule sees only the result, not why that result was selected. The tally counts three substitutes and does not show that precedence worked.

**PS5.** The model builds precedence as language, region, width, night; these four qualifiers are in this order in the platform's list as well. The platform's full precedence list is longer, and it does not use a sum of weights but eliminates qualifiers in order. The measurement shows only that the order works for these four qualifiers.

#### Summary

- This lesson's criterion: **fulfillment**; whether the given directory equals the wanted directory, not the configuration itself. This lesson's fulfillment: **substitute**.
- Resources live under `res` in directories by type; code reaches them by resource ID, and the system selects the file.
- Qualifiers are configuration properties at the end of the directory name: language, region, night appearance, screen width.
- If the directory the configuration names exists, the resource is full; a resource coming from the default directory is substitute.
- A resource that is not in the default directory either is unmet.
- When two qualifiers collide, the order of precedence decides the result; the rule does not see this reason, only the equality of the given and the wanted.

#### Next Step

Resources and the manifest file must be built to come together into a runnable app. The next lesson covers the build system and measures which version is given when dependency versions collide.

### The Build System

Source: https://academia.sh/en/courses/android-platform-and-tools/build-system

A model of how the build system reads dependencies and the version catalog, how the highest version is selected for a transitive dependency, and how a strict version constraint behaves.

The previous lesson measured how resources are selected according to the configuration. The manifest, the resources and the code are not an app on their own; the build system is what brings them together and turns them into an installable package. This lesson introduces the main parts of the build system and measures what happens when the dependency version one module wants collides with the requests of other modules.

The measurement axis of the course does not change: how does the platform fulfill this request. There are four fulfillments: full, substitute, pending and unmet. The rule, not a label, gives the fulfillment: a given value that equals the wanted one is full, and one that differs is substitute; pending and unmet come directly from the outcome. The file below carries that rule and is repeated verbatim in every lesson. The count of 3/2/2/1 from the opening lesson is not changed here.

```kotlin
// Fulfillment.kt — classifies how the platform fulfills a request
sealed interface Outcome {
    data class Given(val value: String) : Outcome
    data class Waiting(val on: String) : Outcome
    data class Refused(val reason: String) : Outcome
}

class Ask(val label: String, val wanted: String, val outcome: Outcome) {
    val fulfillment: String = when (outcome) {
        is Outcome.Waiting -> "pending"
        is Outcome.Refused -> "unmet"
        is Outcome.Given -> if (outcome.value == wanted) "full" else "substitute"
    }

    fun report() {
        val detail = when (outcome) {
            is Outcome.Given -> "given=${outcome.value}"
            is Outcome.Waiting -> "waiting-on=${outcome.on}"
            is Outcome.Refused -> "reason=${outcome.reason}"
        }
        println("$label: wanted=$wanted | $detail -> $fulfillment")
    }
}

fun tally(asks: List<Ask>) {
    for (name in listOf("full", "substitute", "pending", "unmet")) {
        println("$name: ${asks.count { it.fulfillment == name }}")
    }
}
```

#### The Parts of the Build System

A project is set up with three separate written documents. The project settings script gives the name of the project and says which modules it consists of. Each module has its own module build script: it defines the module's compile API level, minimum and target API levels, build types and dependencies. The version catalog collects the names and versions of the dependencies in one place. The two fragments below show the project settings script and a module build script; both are scripts that the platform tools read, and they are not run.

```kotlin
// project settings script
rootProject.name = "Notes"
include(":app", ":data")
```

```kotlin
// module build script
android {
    namespace = "com.example.notes"
    compileSdk = 34
    defaultConfig {
        minSdk = 24
        targetSdk = 34
    }
    buildTypes {
        release { isMinifyEnabled = true }
    }
}

dependencies {
    implementation(project(":data"))
    implementation(libs.notesCore)
}
```

The script knows two build types: `debug`, used during development, and `release`, prepared for distribution. Each type carries its own settings; here code shrinking is turned on for `release`. A dependency declaration can be written in two forms: another module with `project(":data")`, or a library from the version catalog with `libs.notesCore`. The version catalog is a separate file.

```toml
[versions]
notesCore = "1.4"

[libraries]
notesCore = { module = "com.example:notes-core", version.ref = "notesCore" }
```

The build system runs each step as a build task: it processes the resources, compiles the code, packages. The tasks depend on each other, and the system derives the order from the dependencies. What this lesson measures is not the order of the build tasks but the resolution of dependency versions.

#### Version Selection in a Transitive Dependency

A module asks for a library directly; that library depends on other libraries. These are called transitive dependencies. The same library can be requested along the way several times, with different versions. The build system selects a single version, and the model builds this selection as the highest version. Each request is a separate question: the wanted value is the version the module wrote, and the given value is the version the resolution selected.

```kotlin
// Resolver.kt — a model of version resolution
class Request(val from: String, val library: String, val version: String, val strict: Boolean = false)

fun compareVersions(a: String, b: String): Int {
    val left = a.split(".").map { it.toInt() }
    val right = b.split(".").map { it.toInt() }
    for (i in 0 until maxOf(left.size, right.size)) {
        val diff = left.getOrElse(i) { 0 } - right.getOrElse(i) { 0 }
        if (diff != 0) return diff
    }
    return 0
}

fun signOf(n: Int): Int = if (n < 0) -1 else if (n > 0) 1 else 0

fun resolveDependency(requests: List<Request>): List<Ask> {
    val strictVersions = requests.filter { it.strict }.map { it.version }.toSet()
    val highest = requests.map { it.version }.reduce { best, next ->
        if (compareVersions(next, best) > 0) next else best
    }
    return requests.map { request ->
        val outcome = when {
            strictVersions.size > 1 -> Outcome.Refused("strict-version-mismatch")
            strictVersions.size == 1 -> Outcome.Given(strictVersions.single())
            else -> Outcome.Given(highest)
        }
        Ask("${request.from}>${request.library}", request.version, outcome)
    }
}
```

In the first measurement there are two libraries. The app module asks for the `notes-core` library with version 1.2, and the data module asks for it with version 1.4. Only the app module asks for the `net-client` library.

```kotlin
// Main.kt — the highest version is selected
fun main() {
    val core = resolveDependency(listOf(
        Request("app", "notes-core", "1.2"),
        Request("data", "notes-core", "1.4"),
    ))
    val net = resolveDependency(listOf(Request("app", "net-client", "3.0")))
    val asks = core + net
    for (ask in asks) ask.report()
    tally(asks)
}
```

```text
app>notes-core: wanted=1.2 | given=1.4 -> substitute
data>notes-core: wanted=1.4 | given=1.4 -> full
app>net-client: wanted=3.0 | given=3.0 -> full
full: 2
substitute: 1
pending: 0
unmet: 0
```

The data module got the version it wanted. The app module asked for 1.2 and got 1.4: a higher version is a substitute, because the given value differs from the wanted one. The rule does not say whether this version is better or worse; it says only that it is not equal. For a library with a single request there is nothing to resolve, and the result is full.

**PS1.** The model builds only the rule of selecting the highest version. The real resolver also reads other inputs, such as range versions, version weights and exclusions.

**PS2.** Version strings consist only of numbers separated by dots. Pre-release suffixes and versions that contain letters are outside the model.

#### The Version Catalog

The purpose of the version catalog is to prevent the same library from being written separately in each module. The modules do not write the version themselves; they use the name in the catalog, and the version comes from one place. The measurement below asks this in two cases. With the catalog, all three modules ask for the same version. Without the catalog, each module has written a version by hand and the three versions have drifted apart.

```kotlin
// Main.kt — with and without the catalog
fun main() {
    val withCatalog = resolveDependency(listOf(
        Request("app", "ui-kit", "2.0"),
        Request("data", "ui-kit", "2.0"),
        Request("sync", "ui-kit", "2.0"),
    ))
    val withoutCatalog = resolveDependency(listOf(
        Request("app", "ui-kit", "1.8"),
        Request("data", "ui-kit", "1.9"),
        Request("sync", "ui-kit", "2.0"),
    ))
    val asks = withCatalog + withoutCatalog
    for (ask in asks) ask.report()
    tally(asks)
}
```

```text
app>ui-kit: wanted=2.0 | given=2.0 -> full
data>ui-kit: wanted=2.0 | given=2.0 -> full
sync>ui-kit: wanted=2.0 | given=2.0 -> full
app>ui-kit: wanted=1.8 | given=2.0 -> substitute
data>ui-kit: wanted=1.9 | given=2.0 -> substitute
sync>ui-kit: wanted=2.0 | given=2.0 -> full
full: 4
substitute: 2
pending: 0
unmet: 0
```

With the catalog, all three requests came out full. Without the catalog, the module that asks for the highest version is full and the others are substitute. The result of reading the version from the catalog is that it removes the chance of drift; the measure still looks only at the equality of the given and the wanted.

#### Project and Module Separation

The project settings script and the module build script work at two different levels. At the project level, where dependencies are downloaded from and which modules are part of the project are determined. At the module level, what that module depends on and how it is built are determined. The fragment below shows a dependency repository declaration at the project level.

```kotlin
// project settings script
dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
    }
}
```

Because the repository declaration is at the project level, every module resolves dependencies from the same places. A module's script can also define its own build types, `debug` and `release`. A build type carries settings such as code shrinking, debuggability and signing. When there is no flavor dimension, the build variant carries the same name as the build type; the next lesson measures that flavors enter this product.

#### Dependency Configurations

A dependency is declared together with the classpath it will appear on. The first word of the declaration is the configuration. `implementation` adds the dependency to the module's compilation and run but does not show it to other modules that use the module. `api` also puts the same dependency on the compile classpath of the modules that use it. `compileOnly` enters only the compilation and does not enter the running app. `runtimeOnly` enters only the run. `testImplementation` enters only the test compilation. The model binds to each configuration a set that says which of four classpaths it reaches: compile, runtime, consumer and test.

```kotlin
// Config.kt — a model of dependency configurations
val reach: Map<String, Set<String>> = mapOf(
    "implementation" to setOf("compile", "runtime"),
    "api" to setOf("compile", "runtime", "consumer"),
    "compileOnly" to setOf("compile"),
    "runtimeOnly" to setOf("runtime"),
    "testImplementation" to setOf("test"),
)

fun resolveConfiguration(configuration: String, classpath: String): Ask {
    val paths = reach[configuration]
    val outcome = when {
        paths == null -> Outcome.Refused("no-configuration")
        classpath in paths -> Outcome.Given(classpath)
        else -> Outcome.Refused("not-on-classpath")
    }
    return Ask(configuration, classpath, outcome)
}
```

In the first measurement, a library belonging to the same module is wanted to be present at run time. Three configurations are asked.

```kotlin
// Main.kt — the runtime classpath
fun main() {
    val asks = listOf("implementation", "runtimeOnly", "compileOnly").map { resolveConfiguration(it, "runtime") }
    for (ask in asks) ask.report()
    tally(asks)
}
```

```text
implementation: wanted=runtime | given=runtime -> full
runtimeOnly: wanted=runtime | given=runtime -> full
compileOnly: wanted=runtime | reason=not-on-classpath -> unmet
full: 2
substitute: 0
pending: 0
unmet: 1
```

If an interface library is needed at compile time but another package supplies it at run time, `compileOnly` is the right choice. A wrong choice leads to the class not being found at run time. The model shows this as unmet. The second measurement asks the edge cases: appearing on the consumer module's compile classpath, appearing on the test classpath, and a configuration that was never defined.

```kotlin
// Main.kt — consumer, test and unknown
fun main() {
    val asks = listOf(
        resolveConfiguration("api", "consumer"),
        resolveConfiguration("implementation", "consumer"),
        resolveConfiguration("testImplementation", "compile"),
        resolveConfiguration("debugImplementation", "runtime"),
    )
    for (ask in asks) ask.report()
    tally(asks)
}
```

```text
api: wanted=consumer | given=consumer -> full
implementation: wanted=consumer | reason=not-on-classpath -> unmet
testImplementation: wanted=compile | reason=not-on-classpath -> unmet
debugImplementation: wanted=runtime | reason=no-configuration -> unmet
full: 1
substitute: 0
pending: 0
unmet: 3
```

`api` and `implementation` compile the same library in the same module, but their visibility to consumers is separated. This distinction matters in the long run: unnecessary use of `api` can cause a change in one module to force every module that uses it to be recompiled. The model does not measure this cost; it measures only access.

**PS3.** The model reads the configuration name from a small fixed table. In a real build system, configurations multiply with the build type (for example, ones specific to only one build type), and a module can define its own configuration.

#### The Bounding Measurement

The model is limited in two places. The first is the ordering of versions. Version `1.10` is higher than version `1.9`, because a version is compared by its numeric parts. If the same two strings are ordered alphabetically, the result reverses. `compareVersions` applies numeric ordering, and the string `compareTo` applies alphabetical ordering; the first two lines print the sign of each. The second is the strict version constraint (`strictly`): if two modules wrote different strict versions for the same library, the resolution cannot select a version.

```kotlin
// Main.kt — ordering and the strict constraint
fun main() {
    println("numeric: ${signOf(compareVersions("1.10", "1.9"))}")
    println("string: ${signOf("1.10".compareTo("1.9"))}")
    val ordering = resolveDependency(listOf(
        Request("app", "log-tools", "1.9"),
        Request("data", "log-tools", "1.10"),
    ))
    val strict = resolveDependency(listOf(
        Request("app", "crypto-lib", "2.0", strict = true),
        Request("sync", "crypto-lib", "2.1", strict = true),
    ))
    val asks = ordering + strict
    for (ask in asks) ask.report()
    tally(asks)
}
```

```text
numeric: 1
string: -1
app>log-tools: wanted=1.9 | given=1.10 -> substitute
data>log-tools: wanted=1.10 | given=1.10 -> full
app>crypto-lib: wanted=2.0 | reason=strict-version-mismatch -> unmet
sync>crypto-lib: wanted=2.1 | reason=strict-version-mismatch -> unmet
full: 1
substitute: 1
pending: 0
unmet: 2
```

In numeric ordering the sign is positive: `1.10` is higher. In string ordering the sign is negative: the opposite comes out for the same two strings. That is why the `app>log-tools` request became substitute and `data>log-tools` stayed full. The model builds numeric ordering; the measurement shows only that the model applies this ordering. With the strict constraint, because the two strict versions differ, the resolution gave no version and both requests came out unmet. The rule does not see the reason for the mismatch; the `strict-version-mismatch` string that the model carries gives the reason.

**PS4.** The model counts the strict constraint as unmet only when the two strict versions differ from each other. When there is a single strict version, it gives that version; details such as whether a strict version can drop another request are not measured.

#### Summary

- This lesson's criterion: **fulfillment**; whether the version the resolution gave equals the version the module wanted. This lesson's fulfillment: **substitute**.
- The project settings script lists the modules, the module build script defines a module's settings and dependencies, and the version catalog collects names and versions in one place.
- `debug` and `release` are build types; each type has its own settings.
- In a transitive dependency the model selects the highest version: the one that asks for the lower version is fulfilled by substitute, and the one that asks for the higher version in full.
- The version catalog prevents drift, because it gives the same version to all modules.
- Version ordering is numeric; a strict version constraint mismatch is unmet.

#### Next Step

The build system can produce several apps from the same source: free and paid versions, or packages for different markets. The next lesson measures product flavors and the build variants that arise from their product with build types.

### Product Flavors

Source: https://academia.sh/en/courses/android-platform-and-tools/product-flavors

A model of the build variants that arise from the product of product flavors and build types, the variant filter, and source set precedence.

The previous lesson measured how the build system resolves dependency versions. The build system does not have to produce a single app from the same source. Free and paid editions, packages for different markets, or builds that connect to different servers come out of the same code base. Product flavors provide this. This lesson measures which build variants are produced and what the platform gives when a variant is requested.

The measurement axis of the course does not change: how does the platform fulfill this request. The file below carries the rule that gives the fulfillment and is repeated verbatim in every lesson.

```kotlin
// Fulfillment.kt — classifies how the platform fulfills a request
sealed interface Outcome {
    data class Given(val value: String) : Outcome
    data class Waiting(val on: String) : Outcome
    data class Refused(val reason: String) : Outcome
}

class Ask(val label: String, val wanted: String, val outcome: Outcome) {
    val fulfillment: String = when (outcome) {
        is Outcome.Waiting -> "pending"
        is Outcome.Refused -> "unmet"
        is Outcome.Given -> if (outcome.value == wanted) "full" else "substitute"
    }

    fun report() {
        val detail = when (outcome) {
            is Outcome.Given -> "given=${outcome.value}"
            is Outcome.Waiting -> "waiting-on=${outcome.on}"
            is Outcome.Refused -> "reason=${outcome.reason}"
        }
        println("$label: wanted=$wanted | $detail -> $fulfillment")
    }
}

fun tally(asks: List<Ask>) {
    for (name in listOf("full", "substitute", "pending", "unmet")) {
        println("$name: ${asks.count { it.fulfillment == name }}")
    }
}
```

#### Flavors and Variants

A product flavor is a named version of an app derived from the same code base. Flavors are bound to a flavor dimension: the dimension says along which axis the flavors differ. The `free` and `paid` flavors sit in a pricing dimension. The build types `debug` and `release` are another axis. The build system takes the product of these axes; each combination is called a build variant. Two flavors and two build types give four variants: `freeDebug`, `freeRelease`, `paidDebug`, `paidRelease`. The fragment below shows this definition and a filter that removes one variant from production.

```kotlin
// module build script
android {
    flavorDimensions += "tier"
    productFlavors {
        create("free") { dimension = "tier" }
        create("paid") { dimension = "tier" }
    }
}

androidComponents {
    beforeVariants { variant ->
        if (variant.name == "paidDebug") variant.enable = false
    }
}
```

The script is one that the platform tools read and is not run. The filter removes the named variant from production; that variant can be neither built nor selected. The model builds this rule of the platform. In this fragment the filter leaves out the `paidDebug` variant.

#### The Variant Set

The model builds the variant name from the flavor name and the build type name joined: the flavor as it is, the build type with a capitalized first letter. When there are several dimensions, the flavors are joined in the order of the dimensions. If the requested variant is in the set and has not been excluded by the filter, the given one is the same as the wanted one. If it is excluded by the filter or is not in the set, no variant is given.

```kotlin
// Variants.kt — a model of the variant set
fun variantNames(dimensions: List<List<String>>, buildTypes: List<String>): List<String> {
    var combos = listOf("")
    for (dimension in dimensions) {
        combos = combos.flatMap { prefix ->
            dimension.map { flavor ->
                if (prefix.isEmpty()) flavor else prefix + flavor.replaceFirstChar { it.uppercase() }
            }
        }
    }
    return combos.flatMap { combo -> buildTypes.map { type -> combo + type.replaceFirstChar { it.uppercase() } } }
}

fun resolveVariant(label: String, wanted: String, variants: List<String>, filtered: Set<String>): Ask {
    val outcome = when {
        wanted in filtered -> Outcome.Refused("excluded-by-filter")
        wanted in variants -> Outcome.Given(wanted)
        else -> Outcome.Refused("no-variant")
    }
    return Ask(label, wanted, outcome)
}
```

The first measurement sets up two flavors in one dimension and two build types, and `paidDebug` is excluded by the filter. Four requests are asked: a variant for development, a variant for distribution, the variant excluded by the filter, and a build type that was never defined.

```kotlin
// Main.kt — the variant set and the filter
fun main() {
    val variants = variantNames(listOf(listOf("free", "paid")), listOf("debug", "release"))
    println(variants.joinToString())
    val filtered = setOf("paidDebug")
    val asks = listOf(
        resolveVariant("run", "freeDebug", variants, filtered),
        resolveVariant("publish", "paidRelease", variants, filtered),
        resolveVariant("filtered", "paidDebug", variants, filtered),
        resolveVariant("undefined", "freeStaging", variants, filtered),
    )
    for (ask in asks) ask.report()
    tally(asks)
}
```

```text
freeDebug, freeRelease, paidDebug, paidRelease
run: wanted=freeDebug | given=freeDebug -> full
publish: wanted=paidRelease | given=paidRelease -> full
filtered: wanted=paidDebug | reason=excluded-by-filter -> unmet
undefined: wanted=freeStaging | reason=no-variant -> unmet
full: 2
substitute: 0
pending: 0
unmet: 2
```

A variant name written exactly as it is in the set is given; no other variant is given in place of a name that is not there. That is why no substitute comes out in the variant measurement: a variant either exists or it does not. `paidDebug` stays in the `variants` list but the filter excludes it; the rule does not separate the reasons of the two unmet results, and the reason is told by the string the model carries.

**PS1.** The model joins the variant name from the flavor and the build type. The naming and the dimension precedence of the real build system have other details; the measurement does not cover them.

**PS2.** The filter looks only at the name of the variant. A real filter can read other properties of the variant as well.

#### Multiple Dimensions

The second measurement has two dimensions: the pricing dimension (`free`, `paid`) and the market dimension (`global`, `local`). The product with the build types gives eight variants. The flavors are joined in dimension order: pricing first, market second. Three requests are asked: a name that follows the order, a name with the order reversed, and a name excluded by the filter.

```kotlin
// Main.kt — two dimensions
fun main() {
    val variants = variantNames(
        listOf(listOf("free", "paid"), listOf("global", "local")),
        listOf("debug", "release"),
    )
    println(variants.size)
    val filtered = setOf("paidLocalDebug")
    val asks = listOf(
        resolveVariant("ordered", "freeLocalDebug", variants, filtered),
        resolveVariant("reversed", "localFreeDebug", variants, filtered),
        resolveVariant("filtered", "paidLocalDebug", variants, filtered),
    )
    for (ask in asks) ask.report()
    tally(asks)
}
```

```text
8
ordered: wanted=freeLocalDebug | given=freeLocalDebug -> full
reversed: wanted=localFreeDebug | reason=no-variant -> unmet
filtered: wanted=paidLocalDebug | reason=excluded-by-filter -> unmet
full: 1
substitute: 0
pending: 0
unmet: 2
```

When the same two flavors were written in reverse order, the variant was not found: the name is a result of dimension order. The size of the set is the product of the flavor counts and the number of build types: 2 × 2 × 2.

#### Dimension Order and Product

The order of flavor dimensions matters in two places. The first is the variant name: the name is built in dimension order, as the measurement above showed. The second is conflict: if flavors in two dimensions define the same setting, which one takes precedence comes from the dimension order. This lesson does not measure the second rule; it measures only the first. The script fragment below declares the two dimensions in order.

```kotlin
// module build script
android {
    flavorDimensions += listOf("tier", "market")
    productFlavors {
        create("free") { dimension = "tier" }
        create("paid") { dimension = "tier" }
        create("global") { dimension = "market" }
        create("local") { dimension = "market" }
    }
}
```

The number of variants is the product of the flavor counts in the dimensions and the number of build types. Adding a third dimension multiplies the number: three dimensions with two flavors each and two build types give sixteen variants. Every variant is built, tested and signed separately; that is why the product calculation is done at the design stage. The filter is the way to remove unnecessary combinations at the start. Details such as whether binding a flavor to a dimension is mandatory in the platform's flavor definition are outside this model.

**PS4.** The model takes the dimension order from the order of the flavor list; it does not read the dimension declaration in the build settings.

#### Source Set Precedence Order

When a variant is built, the source sets that enter it are lined up in an order. The model builds the order from lowest precedence to highest: first the main set, then the flavors' sets, then the build type's set, and last the set of the compound variant. Among flavors, the flavor of the dimension declared first has higher precedence, so it enters the list in reverse order. If a file is in more than one set, the last set wins. `layersFor` builds the order, and `resolveLayered` searches for the file from the end toward the start.

```kotlin
// Layers.kt — a model of the source set order
fun layersFor(flavors: List<String>, buildType: String): List<String> {
    val variant = flavors.joinToString("") { it.replaceFirstChar { c -> c.uppercase() } }
        .replaceFirstChar { it.lowercase() } + buildType.replaceFirstChar { it.uppercase() }
    return listOf("main") + flavors.reversed() + listOf(buildType, variant)
}

fun resolveLayered(path: String, wanted: String, order: List<String>, sets: Map<String, Set<String>>): Ask {
    val owner = order.reversed().firstOrNull { path in (sets[it] ?: emptySet()) }
    val outcome = if (owner == null) Outcome.Refused("no-file") else Outcome.Given(owner)
    return Ask(path, wanted, outcome)
}
```

The first measurement has the `free` flavor and the `release` build type. The order is built from five sets and three files are asked: one only in the main set, one in both the main set and the flavor set, and one in both the main set and the compound variant set.

```kotlin
// Main.kt — the source set order
fun main() {
    val order = layersFor(listOf("free"), "release")
    println(order.joinToString())
    val sets = mapOf(
        "main" to setOf("res/logo.xml", "res/title.xml", "res/banner.xml"),
        "free" to setOf("res/title.xml"),
        "freeRelease" to setOf("res/banner.xml"),
    )
    val asks = listOf(
        resolveLayered("res/logo.xml", "main", order, sets),
        resolveLayered("res/title.xml", "main", order, sets),
        resolveLayered("res/banner.xml", "main", order, sets),
    )
    for (ask in asks) ask.report()
    tally(asks)
}
```

```text
main, free, release, freeRelease
res/logo.xml: wanted=main | given=main -> full
res/title.xml: wanted=main | given=free -> substitute
res/banner.xml: wanted=main | given=freeRelease -> substitute
full: 1
substitute: 2
pending: 0
unmet: 0
```

Whichever set the file is found in, the result is only the name of the highest-precedence set that carries the file. That is why a file requested from the main set counts as a substitute when another set gives it. The second measurement is an edge case: a file found in no set, and a file found in the sets of two flavors.

```kotlin
// Main.kt — edge cases
fun main() {
    val order = layersFor(listOf("free", "local"), "debug")
    println(order.joinToString())
    val sets = mapOf(
        "free" to setOf("res/promo.xml"),
        "local" to setOf("res/promo.xml"),
    )
    val asks = listOf(
        resolveLayered("res/promo.xml", "local", order, sets),
        resolveLayered("res/missing.xml", "main", order, sets),
    )
    for (ask in asks) ask.report()
    tally(asks)
}
```

```text
main, local, free, debug, freeLocalDebug
res/promo.xml: wanted=local | given=free -> substitute
res/missing.xml: wanted=main | reason=no-file -> unmet
full: 0
substitute: 1
pending: 0
unmet: 1
```

When the same file was in the sets of two flavors, the model chose the flavor of the dimension declared first. The file requested from the `local` set was given from the `free` set: substitute. The platform also looks at the declaration order of the dimensions in this case and gives the win to the flavor of the dimension declared first; a resource file found in the sets of two flavors is not counted as a conflict for this reason.

**PS5.** The model builds the platform's four-step order: variant, build type, flavors, main set. The detailed rules of merging resources were not measured; the measurement shows only that the model applies this order.

#### Source Sets

Every flavor has a source set (source set) that carries its own source code and resources. Directories are built from flavor and variant names:

- `src/main` enters every variant.
- `src/free` and `src/paid` enter the respective flavors.
- `src/paidRelease` enters only that variant.

The source sets of the same variant are merged in the build, and if a file is in more than one set, the flavor set takes precedence over the main set. For resource files this rule works this way. For source code classes, if the same class is in two sets the result is a conflict and the build stops. The model builds these two distinctions in a single function.

#### The Bounding Measurement

`resolveSourceFile` compares a file path against the main set and the flavor set. A file found only in the flavor set comes from the flavor, and one found only in the main set comes from the main set. For a resource file found in both, the flavor wins; a `.kt` class found in both is a conflict.

```kotlin
// Sources.kt — a model of source set precedence
class SourceSet(val name: String, val files: Set<String>)

fun resolveSourceFile(path: String, wanted: String, main: SourceSet, flavor: SourceSet): Ask {
    val inMain = path in main.files
    val inFlavor = path in flavor.files
    val outcome = when {
        inMain && inFlavor && path.endsWith(".kt") -> Outcome.Refused("class-conflict")
        inFlavor -> Outcome.Given(flavor.name)
        inMain -> Outcome.Given(main.name)
        else -> Outcome.Refused("no-file")
    }
    return Ask(path, wanted, outcome)
}
```

There are four files in the measurement. An ads class only in the flavor set, a shared image only in the main set, a text resource in both sets, and a billing class in both sets.

```kotlin
// Main.kt — source set precedence
fun main() {
    val main = SourceSet("main", setOf("Billing.kt", "res/logo.xml", "res/strings.xml"))
    val free = SourceSet("free", setOf("Ads.kt", "Billing.kt", "res/strings.xml"))
    val asks = listOf(
        resolveSourceFile("Ads.kt", "free", main, free),
        resolveSourceFile("res/logo.xml", "main", main, free),
        resolveSourceFile("res/strings.xml", "main", main, free),
        resolveSourceFile("Billing.kt", "main", main, free),
    )
    for (ask in asks) ask.report()
    tally(asks)
}
```

```text
Ads.kt: wanted=free | given=free -> full
res/logo.xml: wanted=main | given=main -> full
res/strings.xml: wanted=main | given=free -> substitute
Billing.kt: wanted=main | reason=class-conflict -> unmet
full: 2
substitute: 1
pending: 0
unmet: 1
```

The text resource was requested from the main set and given from the flavor set: substitute. For the same pair, `Billing.kt` was left as a conflict with nothing given. The rule separates the two results only by the given value; the rule does not know that the substitute is a deliberate override in a real build and the conflict is an error.

**PS3.** The model compares only one main set with a single flavor set. The relative precedence of the variant and build type sets was not measured.

#### Summary

- This lesson's criterion: **fulfillment**; whether the requested variant or file is given as it is, not whether the input changes. This lesson's fulfillment: **substitute**.
- A product flavor is a named version derived from the same code base; a flavor dimension is the axis along which flavors differ.
- A build variant is the product of flavors and build types; the name is built in dimension order.
- A variant in the set is given as full; a variant that is not in the set or is excluded by the filter is unmet.
- A flavor source set takes precedence over the main set; for a resource file this is a substitute, and for the same source code class it is a conflict.

#### Next Step

Once the build variants are ready, the app has to be run and tried. The next lesson covers the development environment, the emulator, the device connection tool and the debugging tools.

### The Development Environment

Source: https://academia.sh/en/courses/android-platform-and-tools/development-environment

A model of the request to install an app on a device, measured against the API level, the debugging authorization and the processor architecture.

The previous lesson measured which build variants come out of the same source. When a variant is built, a place is needed to run it. The development environment is the set of tools in which source code is written, built, installed on a device and monitored. This lesson measures what a device gives to an install request.

The measurement axis of the course does not change: how does the platform fulfill this request. There are four fulfillments: full, substitute, pending and unmet. The rule, not a label, gives the fulfillment: a given value that equals the wanted one is full, and one that differs is substitute; pending and unmet come directly from the outcome. The file below carries that rule and is repeated verbatim in every lesson. The count of 3/2/2/1 from the opening lesson is not changed here.

```kotlin
// Fulfillment.kt — classifies how the platform fulfills a request
sealed interface Outcome {
    data class Given(val value: String) : Outcome
    data class Waiting(val on: String) : Outcome
    data class Refused(val reason: String) : Outcome
}

class Ask(val label: String, val wanted: String, val outcome: Outcome) {
    val fulfillment: String = when (outcome) {
        is Outcome.Waiting -> "pending"
        is Outcome.Refused -> "unmet"
        is Outcome.Given -> if (outcome.value == wanted) "full" else "substitute"
    }

    fun report() {
        val detail = when (outcome) {
            is Outcome.Given -> "given=${outcome.value}"
            is Outcome.Waiting -> "waiting-on=${outcome.on}"
            is Outcome.Refused -> "reason=${outcome.reason}"
        }
        println("$label: wanted=$wanted | $detail -> $fulfillment")
    }
}

fun tally(asks: List<Ask>) {
    for (name in listOf("full", "substitute", "pending", "unmet")) {
        println("$name: ${asks.count { it.fulfillment == name }}")
    }
}
```

#### Devices and Tools

An app is run in two places. An emulator is a virtual device that imitates a device on the computer. A physical device is real hardware and is connected to the computer. Both offer the same interface to the tools. The tools do four jobs:

- The device connection tool finds the device, installs the app and builds a bridge between the device and the computer.
- The log tool shows the log lines the device writes.
- The debugger stops the running app and inspects its variables.
- The profiler measures processor, memory and network use over time.

The fragment below shows the build setting that declares the minimum API level of the app. It is a script that the platform tools read and is not run.

```kotlin
// module build script
android {
    defaultConfig {
        minSdk = 26
    }
}
```

This setting limits which devices the app can be installed on. If the device's API level is below this value, the install is not performed. The model builds this rule of the platform.

#### The Install Request

The model describes a device by its name, its API level, its processor architecture and whether the debugging authorization has been approved. Three cases are separated in the install request:

- If the device's API level is below the minimum API level, the install is not given: unmet, reason `api-level`.
- If the API level is sufficient but the device has not yet approved the debugging authorization, the install waits: pending, waiting on `user-approval`.
- If both conditions are met, the wanted app is installed as it is: full.

```kotlin
// Install.kt — a model of the install request
class Device(val name: String, val apiLevel: Int, val abi: String, val debugApproved: Boolean)

fun resolveInstall(wanted: String, minApi: Int, device: Device): Ask {
    val outcome = when {
        device.apiLevel < minApi -> Outcome.Refused("api-level")
        !device.debugApproved -> Outcome.Waiting("user-approval")
        else -> Outcome.Given(wanted)
    }
    return Ask(device.name, wanted, outcome)
}
```

In the measurement the minimum API level is 26. There are four devices: a current emulator, a physical device that has given approval, a physical device that has not yet passed the approval screen, and an old device.

```kotlin
// Main.kt — the install request
fun main() {
    val devices = listOf(
        Device("emulator", 34, "arm64", true),
        Device("phone-approved", 30, "arm64", true),
        Device("phone-unapproved", 30, "arm64", false),
        Device("old-phone", 23, "arm64", true),
    )
    val asks = devices.map { resolveInstall("app", 26, it) }
    for (ask in asks) ask.report()
    tally(asks)
}
```

```text
emulator: wanted=app | given=app -> full
phone-approved: wanted=app | given=app -> full
phone-unapproved: wanted=app | waiting-on=user-approval -> pending
old-phone: wanted=app | reason=api-level -> unmet
full: 2
substitute: 0
pending: 1
unmet: 1
```

Two devices got full, one got pending and one got unmet. The unapproved phone was not refused; the phone whose API level was not enough was refused.

**PS1.** The model builds the debugging authorization as a single logical value. On a real device this approval is given through user interaction and is kept separately for each device; the measurement does not imitate this.

**PS2.** The install model makes no difference between an emulator and a physical device; the difference is only in the sensor measurement. In reality there are also differences in performance and network.

#### The Arrival of Approval

A pending request becomes full when approval arrives. To measure this, the same device is asked twice: first unapproved, then approved.

```kotlin
// Main.kt — approval before and after
fun main() {
    val before = Device("phone", 30, "arm64", false)
    val after = Device("phone", 30, "arm64", true)
    val asks = listOf(
        resolveInstall("app", 26, before),
        resolveInstall("app", 26, after),
    )
    for (ask in asks) ask.report()
    tally(asks)
}
```

```text
phone: wanted=app | waiting-on=user-approval -> pending
phone: wanted=app | given=app -> full
full: 1
substitute: 0
pending: 1
unmet: 0
```

The same request was fulfilled differently while pending and after approval. A pending result names a user action; when the action arrives, the same request becomes full.

#### Debugging and Profiling Tools

Three tools attach to the same running app but do different jobs. The log tool streams the lines the app and the system write; it does not stop the app. The debugger stops the app at a point set in the source code, reads the variables and steps forward one step at a time. The profiler records processor, memory and network use as a timeline without stopping the app. Because the debugger interferes with how the app runs, the build type must allow it. The script fragment below declares that a distribution build is closed to debugging.

```kotlin
// module build script
android {
    buildTypes {
        getByName("release") { isDebuggable = false }
        getByName("debug") { isDebuggable = true }
    }
}
```

The model tests the three tools according to this setting. The log tool is given full in every build. The debugger is given full only in a build that is open to debugging; if it is closed, it is unmet, reason `not-debuggable`. The profiler gives a restricted measurement in a closed build; the model counts this as substitute.

```kotlin
// Tools.kt — a model of attaching tools
fun attachTool(tool: String, debuggable: Boolean): Ask {
    val outcome = when (tool) {
        "log-tool" -> Outcome.Given(tool)
        "debugger" ->
            if (debuggable) Outcome.Given(tool) else Outcome.Refused("not-debuggable")
        "profiler" ->
            if (debuggable) Outcome.Given(tool) else Outcome.Given("restricted-profiler")
        else -> Outcome.Refused("no-tool")
    }
    return Ask("$tool/${if (debuggable) "debug" else "release"}", tool, outcome)
}
```

In the measurement each of the three tools is asked in two builds.

```kotlin
// Main.kt — attaching tools
fun main() {
    val asks = listOf(
        attachTool("log-tool", true),
        attachTool("debugger", true),
        attachTool("profiler", true),
        attachTool("log-tool", false),
        attachTool("debugger", false),
        attachTool("profiler", false),
    )
    for (ask in asks) ask.report()
    tally(asks)
}
```

```text
log-tool/debug: wanted=log-tool | given=log-tool -> full
debugger/debug: wanted=debugger | given=debugger -> full
profiler/debug: wanted=profiler | given=profiler -> full
log-tool/release: wanted=log-tool | given=log-tool -> full
debugger/release: wanted=debugger | reason=not-debuggable -> unmet
profiler/release: wanted=profiler | given=restricted-profiler -> substitute
full: 4
substitute: 1
pending: 0
unmet: 1
```

The same three tools were fulfilled differently in the two builds: the difference came from the build type, not from the tool. The debugger was not given at all in the distribution build, while the profiler was given as something else.

**PS3.** The model builds the behavior of the profiler in a closed build as a restricted measurement. Which measurements remain in the real platform depends on the tool and the version; the measurement does not verify this.

#### Emulator and Physical Device

An emulator imitates sensor data: it gives virtual values for location, acceleration and the camera. On a physical device these values come from real hardware. If the device is not connected, nothing can be given, but once the connection is made the request can be asked again. The model separates three cases: a device that is not connected is pending, a connected emulator is substitute, and a connected physical device is full.

```kotlin
// Sensor.kt — a model of the sensor request
class Target(val name: String, val connected: Boolean, val emulated: Boolean)

fun resolveSensor(wanted: String, target: Target): Ask {
    val outcome = when {
        !target.connected -> Outcome.Waiting("device-connection")
        target.emulated -> Outcome.Given("virtual-$wanted")
        else -> Outcome.Given(wanted)
    }
    return Ask(target.name, wanted, outcome)
}
```

In the measurement there are three targets: a phone with its cable plugged in, a running emulator, and a phone whose cable has been unplugged.

```kotlin
// Main.kt — the sensor request
fun main() {
    val targets = listOf(
        Target("phone", true, false),
        Target("emulator", true, true),
        Target("phone-unplugged", false, false),
    )
    val asks = targets.map { resolveSensor("location", it) }
    for (ask in asks) ask.report()
    tally(asks)
}
```

```text
phone: wanted=location | given=location -> full
emulator: wanted=location | given=virtual-location -> substitute
phone-unplugged: wanted=location | waiting-on=device-connection -> pending
full: 1
substitute: 1
pending: 1
unmet: 0
```

The value the emulator gives is a virtual version of the wanted value; that is why it is a substitute. The phone with a broken connection was not refused; it is waiting.

**PS4.** The model separates the virtual value by its name. How the virtual location is given in a real emulator and which of the sensor types are imitated were not measured.

#### The Bounding Measurement

Native code is built for a specific processor architecture. A device whose API level is sufficient still cannot run the native part of the app if the architecture does not match. The model says by name which field it compares: first the API level, then the processor architecture.

```kotlin
// Abi.kt — the architecture comparison
fun resolveNative(wanted: String, minApi: Int, builtFor: Set<String>, device: Device): Ask {
    val outcome = when {
        device.apiLevel < minApi -> Outcome.Refused("api-level")
        device.abi !in builtFor -> Outcome.Refused("processor-architecture")
        else -> Outcome.Given(wanted)
    }
    return Ask(device.name, wanted, outcome)
}
```

The native code is built only for `arm64`. Although its API level is sufficient, the emulator is on the `x86` architecture; a second emulator is on the `arm64` architecture.

```kotlin
// Main.kt — architecture mismatch
fun main() {
    val builtFor = setOf("arm64")
    val devices = listOf(
        Device("emulator-x86", 34, "x86", true),
        Device("emulator-arm", 34, "arm64", true),
        Device("old-emulator", 23, "x86", true),
    )
    val asks = devices.map { resolveNative("native-code", 26, builtFor, it) }
    for (ask in asks) ask.report()
    tally(asks)
}
```

```text
emulator-x86: wanted=native-code | reason=processor-architecture -> unmet
emulator-arm: wanted=native-code | given=native-code -> full
old-emulator: wanted=native-code | reason=api-level -> unmet
full: 1
substitute: 0
pending: 0
unmet: 2
```

The first emulator differs from the second, which has the same API level, only in architecture, and the result changes. On the third device neither condition is met; the model makes the comparison in order and gives the name of the first field that does not match, which is why the reason is `api-level`.

**PS5.** The model accepts only an exact match. On a real platform one architecture can run another through a translation layer; this was not measured.

#### Summary

- This lesson's criterion: **fulfillment**; whether what is installed is the wanted one, not where the device runs. This lesson's fulfillment: **pending**.
- The development environment is the whole of the tools for writing, building, installing and monitoring; an emulator and a physical device use the same install interface.
- If the device's API level is below the minimum API level, the install is unmet.
- If the debugging authorization has not been approved, the install is pending; when approval arrives, it becomes full.
- If the processor architecture of native code does not match the device's, the result is unmet even if the API level is sufficient.

#### Next Step

Once an app is installed on a device, it has to ask the user for access to sensitive data and hardware. The next lesson covers the permission model.

### The Permission Model

Source: https://academia.sh/en/courses/android-platform-and-tools/permission-model

A fulfillment model of install-time and runtime permissions, normal and dangerous permissions, precise and approximate location, denial and the do-not-ask-again state, with the closing table of the course.

The previous lesson measured the installation of the app on a device. Installation is done once, and the app stays on the device. Afterward the app asks for the user's camera, location or contacts. A permission is the app's right to access protected data or hardware. This lesson measures what the platform gives to a permission request, and closes the course.

The measurement axis of the course does not change: how does the platform fulfill this request. There are four fulfillments: full, substitute, pending and unmet. The rule, not a label, gives the fulfillment: a given value that equals the wanted one is full, and one that differs is substitute; pending and unmet come directly from the outcome. The file below carries that rule and is repeated verbatim in every lesson.

```kotlin
// Fulfillment.kt — classifies how the platform fulfills a request
sealed interface Outcome {
    data class Given(val value: String) : Outcome
    data class Waiting(val on: String) : Outcome
    data class Refused(val reason: String) : Outcome
}

class Ask(val label: String, val wanted: String, val outcome: Outcome) {
    val fulfillment: String = when (outcome) {
        is Outcome.Waiting -> "pending"
        is Outcome.Refused -> "unmet"
        is Outcome.Given -> if (outcome.value == wanted) "full" else "substitute"
    }

    fun report() {
        val detail = when (outcome) {
            is Outcome.Given -> "given=${outcome.value}"
            is Outcome.Waiting -> "waiting-on=${outcome.on}"
            is Outcome.Refused -> "reason=${outcome.reason}"
        }
        println("$label: wanted=$wanted | $detail -> $fulfillment")
    }
}

fun tally(asks: List<Ask>) {
    for (name in listOf("full", "substitute", "pending", "unmet")) {
        println("$name: ${asks.count { it.fulfillment == name }}")
    }
}
```

The permission lessons in M30/K02 measured the meeting of the requested permission set with the given set. Here each permission is a separate request, and a permission the user has not yet answered is a category in its own right: pending. In M12/K03 the decision belonged to the user, but the category did not name the owner of the decision; the fulfillment stayed the same and only the `reason=` field could name the owner. It is the same here.

#### Install-Time Permissions and Runtime Permissions

Permissions are given at two moments. An install-time permission is given while the app is being installed, without asking the user. A runtime permission is requested while the app is running, by showing the user a dialog. The permission type determines which goes through which path:

- A normal permission touches the user's privacy little, for example network access. Declaring it in the manifest is enough, and it is given at install.
- A dangerous permission touches the user's data or hardware, for example the camera, audio recording and location. It must be declared in the manifest, but it is not given unless the user approves it at run time.

The declaration in the manifest looks like this. It is a file that the platform tools read and is not run.

```xml
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
```

The model describes a permission by its name, whether it is dangerous, and whether it is declared in the manifest. The user's reply is a separate input: `null` if there is no reply, `grant` for approval, `deny` for denial. Any other text shows that the user gave something different from what was requested.

```kotlin
// Permission.kt — a model of the permission request
class Perm(val name: String, val dangerous: Boolean, val declared: Boolean)

fun resolvePermission(label: String, perm: Perm, reply: String?): Ask {
    val outcome = when {
        !perm.declared -> Outcome.Refused("not-declared")
        !perm.dangerous -> Outcome.Given(perm.name)
        reply == null -> Outcome.Waiting("user-reply")
        reply == "deny" -> Outcome.Refused("user-denied")
        reply == "grant" -> Outcome.Given(perm.name)
        else -> Outcome.Given(reply)
    }
    return Ask(label, perm.name, outcome)
}
```

There are three permissions in the measurement. One normal permission and two dangerous permissions are asked, and the user has not yet answered any of them.

```kotlin
// Main.kt — normal and dangerous permissions
fun main() {
    val asks = listOf(
        resolvePermission("network", Perm("internet", false, true), null),
        resolvePermission("camera", Perm("camera", true, true), null),
        resolvePermission("microphone", Perm("audio-recording", true, true), null),
    )
    for (ask in asks) ask.report()
    tally(asks)
}
```

```text
network: wanted=internet | given=internet -> full
camera: wanted=camera | waiting-on=user-reply -> pending
microphone: wanted=audio-recording | waiting-on=user-reply -> pending
full: 1
substitute: 0
pending: 2
unmet: 0
```

The normal permission became full without waiting for any reply. The two dangerous permissions were not refused; they are waiting, and the name of what they wait on is `user-reply`. This lesson's fulfillment is pending.

**PS6.** The model builds the reply as a single piece of text. On the real platform the reply is the result of a dialog and differs according to the option with which the permission was given; the measurement does not imitate the dialog.

**PS7.** The model takes the separation of normal and dangerous permissions as one logical value per permission. On the real platform the version determines the protection level of a permission; which permission is at which level was not measured.

#### The Permission Request Flow

A runtime permission is requested in code with a contract. `ActivityResultContracts.RequestPermission` takes the name of the permission and reports through a callback whether the user approved. The fragment below shows the shape of the flow. It uses a platform API, so it is not measured and its output is not claimed.

```kotlin
val launcher = registerForActivityResult(
    ActivityResultContracts.RequestPermission()
) { granted ->
    if (granted) openCamera() else showFallback()
}

launcher.launch(Manifest.permission.CAMERA)
```

The flow has two return paths, and the model separates both. At the moment the dialog opens, the app is waiting for a reply: pending. When the user replies, the same request becomes either full or unmet. To measure this, the same permission is asked in three user states.

```kotlin
// Main.kt — before the reply, approval and denial
fun main() {
    val camera = Perm("camera", true, true)
    val asks = listOf(
        resolvePermission("no-reply", camera, null),
        resolvePermission("approval", camera, "grant"),
        resolvePermission("denial", camera, "deny"),
    )
    for (ask in asks) ask.report()
    tally(asks)
}
```

```text
no-reply: wanted=camera | waiting-on=user-reply -> pending
approval: wanted=camera | given=camera -> full
denial: wanted=camera | reason=user-denied -> unmet
full: 1
substitute: 0
pending: 1
unmet: 1
```

The same request received three fulfillments, and the difference did not come from the permission but from the user's reply. The fulfillment category does not say the owner; only `reason=user-denied` names the user's decision.

#### Precise and Approximate Location

The location permission is requested at two levels of detail. Precise location gives the place where the device is in detail. Approximate location gives only the surroundings. The app can request both together, and if the user picks the approximate one, the app has to work without precise location. The model counts this as substitute: the wanted one is precise location, the given one is approximate location.

```kotlin
// Main.kt — precise location was requested, what the user gave
fun main() {
    val location = Perm("precise-location", true, true)
    val asks = listOf(
        resolvePermission("precise-grant", location, "grant"),
        resolvePermission("approximate-grant", location, "approximate-location"),
        resolvePermission("no-reply", location, null),
        resolvePermission("denial", location, "deny"),
    )
    for (ask in asks) ask.report()
    tally(asks)
}
```

```text
precise-grant: wanted=precise-location | given=precise-location -> full
approximate-grant: wanted=precise-location | given=approximate-location -> substitute
no-reply: wanted=precise-location | waiting-on=user-reply -> pending
denial: wanted=precise-location | reason=user-denied -> unmet
full: 1
substitute: 1
pending: 1
unmet: 1
```

All four cases fell into a separate category. When the user gave approximate location, the app was not refused; it is working with less than it asked for.

**PS8.** The model names approximate location as the substitute of precise location. On the real platform the accuracy of approximate location varies by device and setting; the measurement does not give this accuracy as a number.

#### Denial, Do Not Ask Again and Rationale

When the user denies a request, the app may not be able to show the dialog for the same permission again right away. The platform separates two cases. After the first denial, the system suggests showing a rationale to explain why the permission is needed: the value of `shouldShowRequestPermissionRationale` returns `true`. A rationale is the app's own screen; it tells the user what the permission is for. When the user denies the request with "do not ask again", the system never shows the dialog and the request comes back directly denied.

A rationale does not change the fulfillment. What changes is the probability of the reply the user will give. What changes the fulfillment is whether the user can be reached. The model builds this with a single input: whether the dialog is blocked.

```kotlin
// Denial.kt — a model of the request after denial
fun askAfterDenial(label: String, perm: Perm, blocked: Boolean, reply: String?): Ask {
    val outcome = when {
        blocked -> Outcome.Refused("do-not-ask-again")
        reply == null -> Outcome.Waiting("user-reply")
        reply == "deny" -> Outcome.Refused("user-denied")
        else -> Outcome.Given(perm.name)
    }
    return Ask(label, perm.name, outcome)
}
```

In the measurement the user has denied the camera once. In the first two rows the dialog is open, and the request is asked before and after the rationale. In the last two rows the user has said "do not ask again": one is asked with no reply, one with approval.

```kotlin
// Main.kt — rationale and do not ask again
fun main() {
    val camera = Perm("camera", true, true)
    val asks = listOf(
        askAfterDenial("before-rationale", camera, false, null),
        askAfterDenial("after-rationale", camera, false, "grant"),
        askAfterDenial("do-not-ask-again", camera, true, null),
        askAfterDenial("do-not-ask-again-grant", camera, true, "grant"),
    )
    for (ask in asks) ask.report()
    tally(asks)
}
```

```text
before-rationale: wanted=camera | waiting-on=user-reply -> pending
after-rationale: wanted=camera | given=camera -> full
do-not-ask-again: wanted=camera | reason=do-not-ask-again -> unmet
do-not-ask-again-grant: wanted=camera | reason=do-not-ask-again -> unmet
full: 1
substitute: 0
pending: 1
unmet: 2
```

In the blocked dialog even an approval reply did not change the result, because the reply never reached the user. The way out of this state is not inside the app: the user turns on the permission itself from the system settings.

**PS9.** The model takes the "do not ask again" state as a single logical value. On the real platform, how many repetitions of which denial create this state varies by version; the measurement does not determine this.

#### The Bounding Measurement

The fulfillment rule sees the result and the equality, not the reason. In permissions this boundary has one face: a permission that was not declared cannot be given by any user reply. The model makes the comparison in order. First, is the permission in the manifest; then, is it dangerous; then, what did the user say. The first field that does not match determines the result and gives its name.

In the measurement the same camera permission is asked without being written in the declaration and with the user having approved. For comparison, a normal permission is also asked without being declared, and its declared form is added.

```kotlin
// Main.kt — an undeclared permission
fun main() {
    val asks = listOf(
        resolvePermission("undeclared-camera", Perm("camera", true, false), "grant"),
        resolvePermission("undeclared-network", Perm("internet", false, false), null),
        resolvePermission("declared-camera", Perm("camera", true, true), "grant"),
    )
    for (ask in asks) ask.report()
    tally(asks)
}
```

```text
undeclared-camera: wanted=camera | reason=not-declared -> unmet
undeclared-network: wanted=internet | reason=not-declared -> unmet
declared-camera: wanted=camera | given=camera -> full
full: 1
substitute: 0
pending: 0
unmet: 2
```

The user's approval did not rescue the undeclared camera permission. The normal permission was not given either without being declared: an install-time permission also starts from the manifest.

#### The Closing Table

The course asked the same question across fifteen lessons. The table below gives together, for each lesson, the request it measured, that lesson's fulfillment, and what the lesson's bounding measurement showed. The fulfillment cell is taken from the "This lesson's fulfillment" item in each lesson's Summary. The selection rule is single: a lesson's fulfillment is the fulfillment of the focus request in the "Measured request" column, not the majority of the measurements in the lesson.

| Lesson | Measured request | Fulfillment | Bounding measurement |
|---|---|---|---|
| Android Architecture Layers | declared activity, normal permission | full | the same full comes from two separate reasons, the rule does not see the reason; compatibility fallback substitute |
| Activities | undeclared or non-exported activity | unmet | `android:exported`: the same activity is full or unmet depending on the caller |
| The Activity Lifecycle | state on a configuration change | substitute | a UI update to a stopped activity waits, and to a destroyed one is unmet |
| Fragments | fragment transaction while the host is stopped | pending | `commit` after the state is saved is unmet, `commitAllowingStateLoss` is substitute |
| Services | starting a service from the background | unmet | a foreground service is substitute, a conditionally scheduled job is pending |
| Broadcast Receivers | implicit system broadcast | unmet | the same receiver class gives two results on three paths |
| Content Providers | row query from a provider | full | a query without permission is unmet, a URI permission covers only a single row |
| Intents and Intent Filters | implicit intent | pending | number of installed apps: zero is unmet, one is full, two is pending |
| Tasks and the Back Stack | new instance request | substitute | the same mode gives two results depending on the state of the stack |
| The Manifest File | merged manifest element | full | the result is not known until the placeholder is resolved: pending |
| The Resource System | resource selection with qualifiers | substitute | the language directory when the region directory is absent; qualifier conflict |
| The Build System | transitive dependency version | substitute | version `1.10` is higher than version `1.9`; strict version conflict |
| Product Flavors | file requested from a source set | substitute | a `.kt` class found in the same two sets is a conflict, unmet |
| The Development Environment | installing the app on a device | pending | a processor architecture mismatch is unmet even when the API level is sufficient |
| The Permission Model | dangerous runtime permission | pending | an undeclared permission is not given by any reply |

The distribution: full 3, substitute 5, pending 4, unmet 3; total 15. The opening lesson gave the core distribution of 3/2/2/1. The two distributions are not the same thing. The core was a single measurement of eight requests; the table counts the own fulfillment of each of the fifteen lessons. No forecast bridge is built between them: one is the output of one model, and the other is a selected row from each of fifteen separate models.

#### Summary

- This lesson's criterion: **fulfillment**; whether the permission waits for a reply from the user, not who decides. This lesson's fulfillment: **pending**.
- An install-time permission is given at install without asking the user; a runtime permission is given while running, by asking the user.
- A normal permission becomes full once declared; a dangerous permission is pending until the user replies, and a denial is unmet.
- If precise location is requested and approximate location is given, the result is substitute.
- A rationale does not change the fulfillment; in the "do not ask again" state the system does not reach the user and the request comes back unmet.
- A permission not declared in the manifest is not given even if the user approves.

#### Next Step

The course closes here. The next course covers building the interface in a declarative form.

