Lesson 05 / 15
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.
Published Updated
In this lesson
- Tells a started service, a bound service, and a foreground service apart
- Measures that a service does not bring its own thread
- Shows that a request to start a service from the background stays unmet depending on the API level
- Verifies that a conditionally scheduled job stays pending and carries its condition by name
Contents
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.
// 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
bindServiceand 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.
<application> <service android:name=".SyncService" android:exported="false" /> </application>
// 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.
// 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)
}
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.
// 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)
}
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;onStartCommandis 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 withstopSelf.
// 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.
// 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)
}
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.
// 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.
// 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)
}
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.
// 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 })
}
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, ornetwork,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.
To keep your progress and take notes, Log in
My notes
Log in to take notes.