Lesson 02 / 15
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.
Published Updated
In this lesson
- Defines an activity as the app component that interacts with the user, usually as a single screen
- Measures that a request for an activity not declared in the manifest file stays unmet
- Shows in three cases that the launcher filter decides which activity opens
- Verifies that an activity closed to other apps gets two different fulfillments depending on the calling app
Contents
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.
// 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.
<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.
// 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.
// 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)
}
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.
// 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)
}
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.
// 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.
// 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)
}
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.
// 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)
}
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.
// 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 })
}
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 onProfileActivity. - 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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.