APIs & Devices NotesRohit’s interview study guide
Chapter 09

Integrating with Android and the Platform

The Android side of a device's web UI and APIs: where the server process lives and how it stays alive, the Android 11→12→13 behaviour changes that hit such apps, Binder/AIDL and permissions for talking to platform services, contracts between the SDK, platform and web teams, and debugging one request from the browser down to a system service.

5 topics
Parts marked Advanced are extra depth. Skip them on a first read or a quick revision.

#Web UIs and APIs on an Android-based device

Added

On a laptop, "the web server" is a program you start. On an Android-based device it's a process the platform has to agree to keep alive. Android was designed for phones, where any app may be killed at any moment to save memory or battery, and release after release tightens what apps may do in the background (the next topic lists the Android 11 to 13 steps). A device whose admin UI and REST API must answer at 3 a.m., with nobody touching the screen, is fighting that design unless the server runs in one of the few places Android treats as "must stay up".

There are three realistic homes for the server, and choosing between them is a platform decision, not a web one:

  • A regular app with a foreground service. An APK like any other, with a Service that calls startForeground() and shows an ongoing notification. Android keeps foreground services running, but the app lives under every app rule: background-start limits, runtime permissions, the Android 13 Task Manager that lets a user stop it, and every behaviour change tied to its targetSdkVersion.
  • A privileged system app. The same kind of APK, but built into the system image (a priv-app directory) and usually signed with the platform's key. It can hold privileged and signature permissions that regular apps can't, can be marked android:persistent="true" (the <application> reference says persistence "is intended only for certain system applications"), and can call system APIs. It's still an Android app, so most framework rules still apply to it.
  • A native daemon started by init. A Linux executable described in an .rc file on the system image. init starts it at boot and restarts it when it exits (the AOSP init README: non-oneshot services are restarted, with a default restart_period of 5 s). It can be given exactly the Linux capabilities it needs, for example to listen on port 443. It isn't an Android app at all: no Service, no targetSdkVersion, and no Java framework APIs without extra plumbing.

A mental model: think of the device as a hotel. A regular app is a guest: comfortable, but subject to house rules, and the front desk (the user, or the system under memory pressure) can ask it to leave. A privileged system app is staff: it has keys to rooms guests can't enter, but it still follows the staff handbook (most framework rules). A native daemon is the plumbing: always on, nobody evicts it, but changing it means renovating the building (a new system image). Where your web server lives decides which rules you're fighting and who has to sign off on a change.

Regular app + foreground servicePrivileged system appNative daemon (init)
Who starts itthe app (activity, boot broadcast)the app, or the system if persistentinit, from an .rc file
Kept alive byforeground-service statuspersistent and/or foreground serviceinit restarts it on exit
Updated byinstalling a new APKa system image update (or an APK update over the preinstalled copy)a system image update
Extra powersruntime/normal permissions onlyprivileged and signature permissions, system APIsLinux capabilities, SELinux domain set by the platform
Android behaviour changesall of them, by targetSdkVersionmost of them; some rules exempt system appsnone (it's not an app), but SELinux and init rules
Port 80/443not by default (see below)not by defaultyes, if given the capability
Android-based device Admin's browser TLS proxy :443 Web API app :8080 Settings service app system_server HALs, hardware HTTPS loopback Binder (AIDL) Binder optional native daemon, started by init foreground service, restarted on boot and on update signature permission; restarted by ActivityManager platform team's code Each box is its own process and uid: every arrow inside the device is a place a request can fail.
One admin request through the device. The proxy is one way to serve port 443; without it, the app's port is used directly.

The public Poly documentation I could find describes the device's web interface from the administrator's side, but doesn't say how the server is implemented on PolyOS, so treat any assumption about Poly's internals as a question to ask, not a fact. What you can say in an interview is the general shape above, and the trade-offs that make a team pick one.

Here's the "regular or system app" version: a foreground service that runs a small HTTP server and answers from a platform service. It's an excerpt of a real Kotlin file that was built with the Android Gradle Plugin 9.3.0 (compileSdk 36, minSdk 30, targetSdk 33), passed Android lint, and was run on an Android 16 emulator (API 36, an AOSP image without Google services). Every "on the emulator" observation in this chapter means that setup: Android 16 (API 36) with apps targeting API 33. None of it was observed on an Android 13 device or image, so where a rule is tied to the OS version rather than the target, Android 13 may behave differently. The request handling is shown further down.

ApiServerService.kt (excerpt)
class ApiServerService : Service() {

    @Volatile private var server: ServerSocket? = null
    private val workers = Executors.newFixedThreadPool(4)
    private lateinit var settings: SettingsClient

    override fun onCreate() {
        super.onCreate()
        settings = SettingsClient(this).also { it.bind() }
    }

    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        // Must happen soon after startForegroundService() (about the ANR interval) or the app crashes.
        startForeground(NOTIFICATION_ID, Notifications.build(this, "Device API on port $PORT"))
        if (server == null) startServer()
        return START_STICKY // if the process is killed, recreate the service when memory allows
    }

    private fun startServer() {
        val socket = ServerSocket(PORT) // all interfaces: the admin UI is reached over the LAN
        server = socket
        Log.i(TAG, "listening on port $PORT")
        thread(name = "api-accept") {
            while (!socket.isClosed) {
                val client = try { socket.accept() } catch (e: SocketException) { break }
                workers.execute { handle(client) }
            }
        }
    }

    override fun onDestroy() {
        server?.close()
        server = null
        workers.shutdownNow()
        settings.unbind()
        super.onDestroy()
    }

    override fun onBind(intent: Intent?): IBinder? = null

    companion object {
        const val PORT = 8080
        private const val NOTIFICATION_ID = 1
        private const val TAG = "ApiServer"

        /** Starts the server, reporting Android 12's background-start restriction instead of crashing. */
        fun start(context: Context): Boolean {
            return try {
                context.startForegroundService(Intent(context, ApiServerService::class.java))
                true
            } catch (e: IllegalStateException) {
                // ForegroundServiceStartNotAllowedException (API 31+) is an IllegalStateException.
                val blocked = Build.VERSION.SDK_INT >= 31 && e is ForegroundServiceStartNotAllowedException
                Log.w(TAG, "start refused (background start blocked: $blocked): ${e.message}")
                false
            }
        }
    }
}
What’s happening
  1. Something calls ApiServerService.start(context): the launcher activity when it opens, or a receiver after a reboot or an update. That calls startForegroundService(), a promise to Android that the service will go foreground. The Context reference says the service gets "an amount of time comparable to the ANR interval" to keep it, or the system crashes the process.
  2. Android creates the service: onCreate() binds to the platform's settings service through SettingsClient (the Binder side, in Talking to platform services). Binding is asynchronous, so the server may briefly run before the settings service is connected; requests in that window get 503.
  3. onStartCommand() keeps the promise: startForeground() attaches an ongoing notification and moves the process into the foreground-service priority class, so it's among the last processes the low-memory killer picks (the Service docs say it isn't a candidate for killing when memory is low, though the system can still stop it under extreme pressure). Then it opens a ServerSocket on port 8080 and returns START_STICKY, which asks Android to recreate the service if its process is killed.
  4. The accept loop runs on its own thread (api-accept): each connection goes to a pool of four worker threads, so one slow client can't block the others and the main thread never touches the network. On the emulator, logcat showed ApiServer: listening on port 8080 about 6 ms after the activity started the service.
  5. onDestroy() closes the socket, which makes the blocked accept() throw SocketException, which ends the loop. It also unbinds from the settings service; a service that forgets this leaks the binding and keeps the other process alive.
  6. start() catches the exception Android 12 introduced. On Android 12 and later, an app that targets API 31+ and calls startForegroundService() from the background gets ForegroundServiceStartNotAllowedException (a subclass of IllegalStateException, which is why the catch compiles with minSdk 30). Without the catch, a boot-time or broadcast-time start would crash the app instead of logging.
AdvancedThe manifest that goes with it

The service is only half the contract; the manifest says what Android will allow. This is the built file, trimmed to the parts this chapter discusses:

AndroidManifest.xml (excerpt)
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
    <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
    <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
    <uses-permission android:name="com.example.settings.permission.ACCESS_DEVICE_SETTINGS" />

    <!-- Android 11+: without this, the settings app is invisible to us and bindService fails. -->
    <queries>
        <package android:name="com.example.settings" />
    </queries>

    <application android:label="@string/app_name" android:allowBackup="false">

        <!-- The embedded HTTP server. Not exported: only this app starts it. -->
        <service android:name=".ApiServerService" android:exported="false" />

        <!-- Restarts the server after a reboot or an update of this app. Has an intent filter,
             so Android 12+ requires an explicit android:exported. -->
        <receiver android:name=".BootReceiver" android:exported="false">
            <intent-filter>
                <action android:name="android.intent.action.BOOT_COMPLETED" />
                <action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
            </intent-filter>
        </receiver>
    </application>
</manifest>
What’s happening
  1. INTERNET is described in the permission reference as "Allows applications to open network sockets"; that includes the listening socket. FOREGROUND_SERVICE is what startForeground() requires for apps targeting API 28 or later (the Service reference says so).
  2. POST_NOTIFICATIONS is Android 13's runtime permission. A foreground service needs a notification, but as the next topic shows, the service still runs if the user says no; the notification just doesn't appear in the drawer.
  3. The <queries> element and the custom permission belong to the Binder side: they let this app see and use the platform's settings service.
  4. The service is exported="false": no other app can start or stop the server. The receiver is also exported="false", and on the emulator it still received BOOT_COMPLETED from the system after a reboot and MY_PACKAGE_REPLACED after adb install -r.
  5. Because the receiver has an <intent-filter>, a targetSdk 33 build must say android:exported explicitly. Removing it made the build fail in the manifest merger with "Apps targeting Android 12 and higher are required to specify an explicit value for android:exported when the corresponding component has an intent filter defined."
BootReceiver.kt
class BootReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context, intent: Intent) {
        if (intent.action != Intent.ACTION_BOOT_COMPLETED &&
            intent.action != Intent.ACTION_MY_PACKAGE_REPLACED
        ) return
        // Both are listed exemptions from the Android 12 ban on background foreground-service starts.
        Log.i("ApiServer", "BootReceiver: ${intent.action}")
        ApiServerService.start(context)
    }
}
What’s happening
  1. After a reboot, Android sends BOOT_COMPLETED to apps that hold RECEIVE_BOOT_COMPLETED and aren't in the stopped state (never launched, or force-stopped). The emulator log showed BootReceiver: android.intent.action.BOOT_COMPLETED, then listening on port 8080 15 ms later.
  2. The action check matters: an exported receiver could be sent anything by any app, and even a non-exported one should only act on what it expects.
  3. MY_PACKAGE_REPLACED is the one people forget. Installing a new version of the app kills its process, and the emulator showed that the foreground service did not come back on its own after adb install -r, despite START_STICKY: requests failed until the app was opened again. With this action added, the next update logged BootReceiver: android.intent.action.MY_PACKAGE_REPLACED and the server was listening 8 ms later.
  4. Both broadcasts are on Android's list of situations where an app may start a foreground service from the background, so start() succeeds here. The system log said so in as many words: Background started FGS: Allowed [... code:PACKAGE_REPLACED ...].
AdvancedRequest handling, and the bug a test caught

The rest of the service is a deliberately small HTTP/1.1 handler. It's here to show what the bridge does, not as a recommendation: a real product uses a maintained server library or a native server, with TLS.

ApiServerService.kt (request handling)
private fun handle(client: Socket) {
    client.use { conn ->
        conn.soTimeout = 5_000
        val input = conn.getInputStream().bufferedReader()
        val parts = (input.readLine() ?: return).split(" ")
        var contentLength = 0
        while (true) {
            val header = input.readLine() ?: return
            if (header.isEmpty()) break
            if (header.startsWith("content-length:", ignoreCase = true)) {
                contentLength = header.substringAfter(':').trim().toIntOrNull() ?: 0
            }
        }
        // read() may return part of the body: loop until all of it has arrived.
        val buffer = CharArray(contentLength.coerceIn(0, 1024))
        var filled = 0
        while (filled < buffer.size) {
            val n = input.read(buffer, filled, buffer.size - filled)
            if (n < 0) break
            filled += n
        }
        val body = buffer.concatToString(0, filled)
        val (status, json) = route(parts.getOrElse(0) { "" }, parts.getOrElse(1) { "" }, body)
        val bytes = json.toString().toByteArray()
        conn.getOutputStream().apply {
            write(("HTTP/1.1 $status\r\nContent-Type: application/json\r\n" +
                "Content-Length: ${bytes.size}\r\nConnection: close\r\n\r\n").toByteArray())
            write(bytes)
            flush()
        }
    }
}

private fun route(method: String, path: String, body: String): Pair<String, JSONObject> {
    if (method == "GET" && path == "/api/v1/status") {
        return "200 OK" to JSONObject().put("state", "ready").put("settingsConnected", settings.service != null)
    }
    val key = path.removePrefix("/api/v1/settings/")
    if (key == path || key.isEmpty()) return "404 Not Found" to problem("Not Found")
    val service = settings.service ?: return "503 Service Unavailable" to problem("Settings service not connected")
    return try {
        when (method) {
            "GET" -> service.getSetting(key)
                ?.let { "200 OK" to JSONObject().put("key", key).put("value", it) }
                ?: ("404 Not Found" to problem("Unknown setting"))
            "PUT" -> if (service.setSetting(key, body.trim())) {
                "200 OK" to JSONObject().put("key", key).put("value", body.trim())
            } else {
                "422 Unprocessable Content" to problem("Rejected by the settings service")
            }
            else -> "405 Method Not Allowed" to problem("Method Not Allowed")
        }
    } catch (e: RemoteException) {
        "503 Service Unavailable" to problem("Settings service died")
    } catch (e: SecurityException) {
        "500 Internal Server Error" to problem("Not allowed to use the settings service")
    }
}
What’s happening
  1. handle() runs on a worker thread. client.use { } closes the socket however the block exits, including the early returns when a client disconnects mid-request. soTimeout = 5_000 makes a silent client fail after 5 s instead of holding a worker forever (four workers means four silent clients would otherwise stop the API).
  2. It reads the request line (PUT /api/v1/settings/display.brightness HTTP/1.1), then headers until the blank line, keeping only Content-Length. The body size is capped at 1,024 characters, because a device API must not let one request allocate unbounded memory.
  3. The loop around read() is the bug fix. The first version called input.read(buffer) once. On the emulator, the first PUT with body 55 came back 422 even though 55 is valid: one read() returned before the whole body had arrived, so the value was incomplete, failed validation, and was rejected. Run again, the same request succeeded. A stream read returns what has arrived so far, not what you asked for; the same lesson as framing on a TCP socket in Node.
  4. route() maps the HTTP request onto the platform service: GET /api/v1/status reports whether the Binder connection is up, GET and PUT on /api/v1/settings/{key} call getSetting and setSetting across processes. JSONObject (built into Android) builds the body, so a key with a quote in it can't break the JSON.
  5. Each platform failure becomes a different HTTP status: not connected yet or connection lost → 503, the call threw RemoteException (the other process died mid-call) → 503, SecurityException → 500, because a missing permission is a bug in the device's own software, not something the API caller did.

These are the real responses from the emulator, reached from the Mac with adb forward tcp:18609 tcp:8080 (the port on the Mac is arbitrary):

curl through adb forward (Android 16 emulator, app targeting API 33)
$ curl -s -i http://127.0.0.1:18609/api/v1/status
HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: 42
Connection: close

{"state":"ready","settingsConnected":true}

$ curl -s http://127.0.0.1:18609/api/v1/settings/display.brightness
{"key":"display.brightness","value":"70"}

$ curl -s -X PUT --data 65 http://127.0.0.1:18609/api/v1/settings/display.brightness
{"key":"display.brightness","value":"65"}

$ curl -s -i -X PUT --data 140 http://127.0.0.1:18609/api/v1/settings/display.brightness
HTTP/1.1 422 Unprocessable Content
Content-Type: application/json
Content-Length: 44
Connection: close

{"title":"Rejected by the settings service"}

$ curl -s -i http://127.0.0.1:18609/api/v1/settings/nope
HTTP/1.1 404 Not Found
...
{"title":"Unknown setting"}

After the fix, ten PUTs in a row (10, 20 … 90, 65) each returned the value sent, and the out-of-range 140 was rejected with 422. One more observation from the same run: after an emulator reboot, display.brightness read 70 again. The example's settings service keeps values in memory, so a reboot loses them; a real one persists them (a file, SharedPreferences, or the platform's settings provider) before it answers true.

AdvancedPorts below 1024

Admin UIs live on 80 and 443 on most devices, but an app can't simply listen there. In the Linux kernel, binding a port below net.ipv4.ip_unprivileged_port_start requires the CAP_NET_BIND_SERVICE capability, and apps don't have it. On the Android 16 (API 36) emulator image used here, that value was 1024 (also the kernel's default), and binding as the test app's own uid (via run-as) gave:

adb shell on the emulator
$ cat /proc/sys/net/ipv4/ip_unprivileged_port_start
1024
$ run-as com.example.webapi toybox nc -l -p 80
nc: bind: Permission denied
$ run-as com.example.webapi toybox nc -l -p 8081 &
$ netstat -ltn | grep 8081
tcp6       0      0 [::]:8081               [::]:*                  LISTEN

So the usual patterns are: the app listens on a high port and something with the privilege forwards 443 to it (a small native proxy started by init with capabilities NET_BIND_SERVICE, or a firewall redirect set up by the platform), or the whole web server is native. Both are platform-team work, which is why "which port does the admin UI use" is a cross-team question.

This is moving. heise online reported on 2 June 2026 ("Android opens a few well-known ports", Daniel AJ Sokolov) that the next Google Play system update would let apps use an allow-list of ports below 1024: nine TCP ports (20, 21, 22, 23 for Telnet, 80, 443, 445, 515 and 631) and UDP 319, 320 and 443. It requires Android 13 (API 33) or later and Linux kernel 5.15 or later, and is implemented with eBPF in the updatable Mainline module for data connections; the report cites the public Android issue tracker (issue 218578943). The report doesn't say how it interacts with ip_unprivileged_port_start, and this chapter didn't test it. A device platform that doesn't take Google Play system updates, or runs an older kernel, won't get that, so check with the platform team rather than assuming either way.

AdvancedMapping platform failures to HTTP answers

The Kotlin route() above handles "not connected", "died" and "not allowed". There's one more failure it can't see: a Binder call that never returns. The generated AIDL proxy calls mRemote.transact(code, data, reply, 0) with no timeout argument (you can read this in the generated Java), so a platform service stuck on a lock holds your worker thread until it answers. Here's the whole mapping as a Node model, with a timeout added at the HTTP layer:

http-bridge.mjs
// The on-device web API is a bridge: HTTP in, a platform-service call out.
// Every way the platform call can fail needs its own HTTP answer. Binder calls have no timeout
// of their own, so the bridge adds one.
import express from "express";
import request from "supertest";

class RemoteException extends Error {}
let neverSettles; // a call that hangs, like a platform service stuck on a lock
const platform = {
  connected: true,
  async getSetting(key) {
    if (key === "audio.volume") throw new RemoteException("DeadObjectException");
    if (key === "camera.ptz") return new Promise((resolve) => (neverSettles = resolve));
    if (key === "secure.pin") throw Object.assign(new Error("Caller lacks permission"), { name: "SecurityException" });
    return { "display.brightness": "70" }[key] ?? null;
  },
};

const withTimeout = (promise, ms) =>
  Promise.race([promise, new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), ms).unref())]);

const app = express();
app.get("/api/v1/settings/:key", async (req, res) => {
  if (!platform.connected) return res.status(503).set("Retry-After", "2").json({ title: "Settings service not connected" });
  try {
    const value = await withTimeout(platform.getSetting(req.params.key), 50);
    if (value === null) return res.status(404).json({ title: "Unknown setting" });
    res.json({ key: req.params.key, value });
  } catch (e) {
    if (e instanceof RemoteException) return res.status(503).set("Retry-After", "2").json({ title: "Settings service died" });
    if (e.message === "timeout") return res.status(504).json({ title: "Settings service did not answer" });
    res.status(500).json({ title: "Internal error" }); // SecurityException: our bug, not the caller's
  }
});

for (const key of ["display.brightness", "nope", "audio.volume", "camera.ptz", "secure.pin"]) {
  const r = await request(app).get(`/api/v1/settings/${key}`);
  console.log(`${key.padEnd(19)} ${r.status} ${JSON.stringify(r.body)}${r.headers["retry-after"] ? " Retry-After: 2" : ""}`);
}
platform.connected = false;
const r = await request(app).get("/api/v1/settings/display.brightness");
console.log(`(disconnected)      ${r.status} ${JSON.stringify(r.body)}`);
neverSettles?.(null);
Result
display.brightness  200 {"key":"display.brightness","value":"70"}
nope                404 {"title":"Unknown setting"}
audio.volume        503 {"title":"Settings service died"} Retry-After: 2
camera.ptz          504 {"title":"Settings service did not answer"}
secure.pin          500 {"title":"Internal error"}
(disconnected)      503 {"title":"Settings service not connected"}
What’s happening
  1. platform stands in for the Binder proxy: one key works, one is unknown, one throws like a DeadObjectException (the remote process died during the call), one never settles, and one throws like a SecurityException.
  2. Known and unknown keys are the easy cases: 200 with the value, 404 for a key the service doesn't have. The unknown key returns null rather than throwing, which is a contract choice the next topics come back to.
  3. A dead service is temporary: Android restarts it and the binding reconnects (you'll see it take 1 s, then 4 s, on the emulator later). So the answer is 503 with Retry-After, which tells a web UI or a partner script to try again rather than to give up.
  4. camera.ptz hangs, and withTimeout turns it into 504 after 50 ms (on a device you'd pick something like a few seconds). On Android, the timeout only frees the HTTP response: the worker thread is still blocked inside transact(). That's why the Kotlin server uses a bounded worker pool, and why a hung platform service should show up in monitoring, not just as slow requests.
  5. A SecurityException means the device's own software is misconfigured (wrong signature, missing permission), so it's a 500; the caller can't fix it by retrying. .unref() on the timer and settling neverSettles at the end let the script exit cleanly.
AdvancedGotcha: listening on every interface
Why did the server disappear after an update?

The web API app above is running. You install a new build with adb install -r (data kept). The app's service returns START_STICKY. Without the MY_PACKAGE_REPLACED action in the receiver, what happens to the API, and why?

Show answer

The API stays down until something starts the service again (on the emulator: until the app was opened).

What’s happening
  1. Installing an update stops the running app: its process is killed so the new code can load. That's normal Android behaviour for any app update, including updates delivered by a device's own update system.
  2. START_STICKY asks Android to recreate a service whose process was killed for memory. It isn't a promise to restart a service whose package was replaced; on the emulator, no restart happened.
  3. Nothing in the new version runs until an entry point fires: an activity, a broadcast, a bound service. MY_PACKAGE_REPLACED is that entry point, sent only to the app that was just updated.
  4. It's also one of the documented exemptions from Android 12's background-start rule, so the receiver may call startForegroundService(). With it, the emulator logged the restart within milliseconds of the install.

#Android 11 to 13: changes that matter

Added

Each Android release ships a list of behaviour changes: things that worked one way and now work another. For an app that serves a device's web UI and APIs, a few of them decide whether the server starts at boot, whether it can see the platform's services, and whether its notification shows. Migrating a device from one Android base to another means walking those lists and deciding, rule by rule, what your app does about each.

First, check which Android versions you're actually migrating between. The role description (and this site's outline) says PolyOS 4.x is on Android 11. HP's official release notes say otherwise for the video systems. Poly VideoOS Software Release Notes 5.X (HP Support, read 27 October 2026) say 5.0.0 (release date May 2026) "contains an Android 13 update for only Poly Studio G62, Poly Studio X32, Poly Studio X52, and Poly Studio X72 systems", and: "Your system updates from Android 10 to Android 13 and your Poly TC10 touch controller updates from Android 11 to Android 12." The same notes require Poly VideoOS 4.6.0 before 5.0.0 and make the upgrade one-way ("Once you update your system to Poly VideoOS 5.0.0, you cannot downgrade"); the product side is in Poly video devices and PolyOS in brief. The notes don't say which API level the device's own apps target, so ask which base and target each app is on: if the base is Android 10, the Android 11 changes below aren't background, they're part of the jump. This topic keeps the "11 to 13" heading but covers Android 10 → 11 as well. (The product side of the migration is in Migrating a legacy device app to a new OS version.)

The mental model is two dials. One dial is the OS version on the device; the other is the targetSdkVersion in your APK, which is your promise "I've been tested against the rules up to this level". Some changes apply to every app once the OS is new enough ("all apps" changes); most apply only once you also turn your target dial up ("apps targeting Android N" changes). That split is what makes a migration manageable: a platform team can upgrade the OS first while apps keep their old target, then each app team retargets when it's ready.

Here's that model with the changes from the developer.android.com behaviour-change pages for Android 11, 12 and 13 (read October 2026; the pages show "last updated 23 October 2026"), walked through a typical migration:

behaviour-dials.mjs
// Two dials decide whether an Android behaviour change hits your app:
// the OS the device runs (its API level) and the targetSdkVersion your APK declares.
// "all" changes apply to every app on that OS; "target" changes only once you target that level.
// Rows come from developer.android.com behaviour-change pages for Android 11, 12 and 13 (read October 2026).
// Android 10 (API 29) is the baseline: nothing in this list applies there.
const changes = [
  { api: 30, scope: "target", what: "package visibility filtered: declare <queries>" },
  { api: 30, scope: "target", what: "camera/mic in a foreground service need foregroundServiceType" },
  { api: 30, scope: "target", what: "scoped storage enforced" },
  { api: 30, scope: "target", what: "background location granted only in system settings" },
  { api: 30, scope: "all", what: "one-time permissions for location, mic, camera" },
  { api: 31, scope: "target", what: "android:exported required on components with intent filters" },
  { api: 31, scope: "target", what: "every PendingIntent declares FLAG_IMMUTABLE or FLAG_MUTABLE" },
  { api: 31, scope: "target", what: "no foreground-service starts from the background" },
  { api: 31, scope: "target", what: "WebView: cookies without SameSite treated as Lax" },
  { api: 31, scope: "target", what: "Bluetooth: BLUETOOTH_SCAN/CONNECT/ADVERTISE permissions" },
  { api: 31, scope: "all", what: "foreground-service notification may be delayed 10 s" },
  { api: 31, scope: "all", what: "restricted App Standby Bucket active" },
  { api: 31, scope: "all", what: "users can switch camera and mic off for all apps" },
  { api: 33, scope: "all", what: "POST_NOTIFICATIONS runtime permission exists" },
  { api: 33, scope: "target", what: "the app decides when the notification prompt appears" },
  { api: 33, scope: "target", what: "Wi-Fi APIs need NEARBY_WIFI_DEVICES" },
  { api: 33, scope: "target", what: "WebView setForceDark() is a no-op" },
  { api: 33, scope: "all", what: "Task Manager: users can stop apps running foreground services" },
  { api: 33, scope: "all", what: "foreground-service notifications are dismissible" },
];

const applies = (c, os, target) => os >= c.api && (c.scope === "all" || target >= c.api);

const scenarios = [
  { name: "A. targetSdk 29 app on Android 10 (API 29)", os: 29, target: 29 },
  { name: "B. same APK after the OS moves to Android 13", os: 33, target: 29 },
  { name: "C. app retargeted to 33 on Android 13", os: 33, target: 33 },
];

let previous = new Set();
for (const s of scenarios) {
  const now = new Set(changes.filter((c) => applies(c, s.os, s.target)));
  const added = [...now].filter((c) => !previous.has(c));
  console.log(`${s.name}: ${now.size} of ${changes.length} apply, ${added.length} new`);
  for (const c of added) console.log(`   + [${c.scope.padEnd(6)} API ${c.api}] ${c.what}`);
  previous = now;
}
Result
A. targetSdk 29 app on Android 10 (API 29): 0 of 19 apply, 0 new
B. same APK after the OS moves to Android 13: 7 of 19 apply, 7 new
   + [all    API 30] one-time permissions for location, mic, camera
   + [all    API 31] foreground-service notification may be delayed 10 s
   + [all    API 31] restricted App Standby Bucket active
   + [all    API 31] users can switch camera and mic off for all apps
   + [all    API 33] POST_NOTIFICATIONS runtime permission exists
   + [all    API 33] Task Manager: users can stop apps running foreground services
   + [all    API 33] foreground-service notifications are dismissible
C. app retargeted to 33 on Android 13: 19 of 19 apply, 12 new
   + [target API 30] package visibility filtered: declare <queries>
   + [target API 30] camera/mic in a foreground service need foregroundServiceType
   + [target API 30] scoped storage enforced
   + [target API 30] background location granted only in system settings
   + [target API 31] android:exported required on components with intent filters
   + [target API 31] every PendingIntent declares FLAG_IMMUTABLE or FLAG_MUTABLE
   + [target API 31] no foreground-service starts from the background
   + [target API 31] WebView: cookies without SameSite treated as Lax
   + [target API 31] Bluetooth: BLUETOOTH_SCAN/CONNECT/ADVERTISE permissions
   + [target API 33] the app decides when the notification prompt appears
   + [target API 33] Wi-Fi APIs need NEARBY_WIFI_DEVICES
   + [target API 33] WebView setForceDark() is a no-op
What’s happening
  1. Each row is one change with the API level it shipped in (30 = Android 11, 31 = Android 12, 33 = Android 13) and its scope. applies() is the whole rule: the OS must be at least that level, and for "target" changes your targetSdkVersion must be too. The list is a selection of what matters for a device web-API app, not every change.
  2. Scenario A is the starting point HP's 5.0.0 notes give for the second-generation systems (an Android 10 base): an app built for Android 10 on Android 10. None of these rows applies, because they all arrived later.
  3. Scenario B upgrades only the OS. Seven "all apps" changes arrive at once, without anyone touching the app: users (or admins on the device's screen) can now switch the camera and mic off for every app, Android 13 has a notification permission, and a Task Manager in the notification shade can stop an app that runs a foreground service. None of them breaks the build; all of them can change behaviour in the field. This is the set the platform team's OS upgrade imposes on every app team.
  4. Scenario C turns the second dial. Twelve target-gated changes switch on, and several are hard failures: a component with an intent filter and no android:exported won't build (the manifest merger error shown earlier), a PendingIntent without a mutability flag triggers a lint warning and, per the Android 12 page, must declare one, and a foreground-service start from the background throws.
  5. If the devices are actually on Android 11 today with apps targeting 30, the first five rows already apply and the jump is smaller: scenario B would add only the API 31 and 33 "all apps" rows. That's the practical reason to find out the real base version.
AdvancedAndroid 10 to 11 in more detail

If the devices really start on Android 10, these are the Android 11 (API 30) changes that matter for a device's own apps, from developer.android.com's Behavior changes: Apps targeting Android 11 and Behavior changes: all apps pages, and the Android 11 privacy pages (read October 2026):

ChangeScopeWhat it means on a device
Package visibilityapps targeting 30+Queries for other installed apps are filtered. To see or bind another package, declare it in <queries> (details below).
Foreground service types for camera and microphoneapps targeting 30+"If your app targets Android 11 or higher and accesses the camera or microphone in a foreground service, you must include the camera and microphone foreground service types." Directly relevant to a video device's call service.
While-in-use access from the backgroundAndroid 11 privacy page"If your app starts a foreground service while running in the background, the foreground service cannot access the microphone or camera." The emulator (API 36, app targeting 33) logged exactly this warning when the boot receiver started the server: Foreground service started from background can not have location/camera/microphone access.
Scoped storage enforcementapps targeting 30+Shared external storage is limited to app-specific directories and media the app created. Matters if an app writes logs or exports to /sdcard for a USB stick or a support bundle.
Background locationapps targeting 30+Users must go to system settings to grant it. Rarely relevant to a meeting-room device.
One-time permissionsall appsUsers can grant location, microphone and camera "only this time". On a room device with no personal user, it's the device's own UI that would see these prompts.
Conscrypt as the SSL engineall appsThe default SSLSocket is built on Conscrypt's SSLEngine. Worth a regression test of anything that does TLS in-app.
APK Signature Scheme v2 requiredapps targeting 30+"Users can't install or update apps that are only signed with APK Signature Scheme v1 on devices that run Android 11" (the rule is on the apps targeting Android 11 page).
AdvancedForeground services: start rules, types and the Task Manager

Three foreground-service changes in this window matter most to a web-API app, because the server is a foreground service:

  • Android 12, apps targeting 31+: no starts from the background. developer.android.com (Restrictions on starting a foreground service from the background, read October 2026) says the system "throws a ForegroundServiceStartNotAllowedException", and lists exemptions: transitioning from a visible activity, a user tapping a notification or widget, an exact alarm for a user action, a high-priority FCM message, BOOT_COMPLETED, LOCKED_BOOT_COMPLETED or MY_PACKAGE_REPLACED, "apps with certain system roles or permission, such as device owners and profile owners", and a few more. Being a system app is not on that list by itself. Two other entries are worth asking the platform team about, because a device's own apps may meet them: "Your app can start an activity from the background" and "The user turns off battery optimizations for your app". The public docs I found don't say whether PolyOS's apps qualify.
  • Android 13, all apps: the Task Manager. Users "can complete a workflow from the notification drawer to stop apps that have ongoing foreground services". The Handle user-initiated stopping page says the system "removes your app from memory", sends no callback, and suggests checking ApplicationExitInfo for REASON_USER_REQUESTED on the next start. Test it with adb shell cmd activity stop-app PACKAGE_NAME. Whether a device's own system UI exposes this to room users is a platform decision; the public docs I found don't say what PolyOS does.
  • Android 13: notifications. If the user denies POST_NOTIFICATIONS, the Android 13 page says foreground-service notices disappear from the drawer but still show in the Task Manager; the service itself keeps running. On the emulator (API 36, app targeting 33), after tapping "Don't allow", dumpsys still showed isForeground=true and the API answered.

The start rule is easiest to reason about as a function. This model encodes part of the documented list:

fgs-start-rules.mjs
// Can the app call startForegroundService() right now? A model of the Android 12 rule
// (developer.android.com, "Restrictions on starting a foreground service from the background",
// read October 2026). Only a few of the listed exemptions are modelled here.
const EXEMPT_TRIGGERS = new Set([
  "BOOT_COMPLETED", "LOCKED_BOOT_COMPLETED", "MY_PACKAGE_REPLACED", // after reboot or update
  "user tapped notification or widget", "exact alarm for a user action", "high-priority FCM",
]);

function canStartForegroundService({ targetSdk, appVisible, trigger, deviceOwner = false }) {
  if (targetSdk < 31) return "allowed (rule applies from targetSdk 31)";
  if (appVisible) return "allowed (app is in the foreground)";
  if (EXEMPT_TRIGGERS.has(trigger)) return `allowed (exemption: ${trigger})`;
  if (deviceOwner) return "allowed (exemption: device owner)";
  return "ForegroundServiceStartNotAllowedException";
}

const cases = [
  { label: "launcher activity on screen", targetSdk: 33, appVisible: true, trigger: "activity" },
  { label: "BootReceiver after reboot", targetSdk: 33, appVisible: false, trigger: "BOOT_COMPLETED" },
  { label: "BootReceiver after app update", targetSdk: 33, appVisible: false, trigger: "MY_PACKAGE_REPLACED" },
  { label: "custom broadcast in background", targetSdk: 33, appVisible: false, trigger: "custom broadcast" },
  { label: "same, app still targeting 30", targetSdk: 30, appVisible: false, trigger: "custom broadcast" },
  { label: "same, app is device owner", targetSdk: 33, appVisible: false, trigger: "custom broadcast", deviceOwner: true },
];

for (const c of cases) console.log(`${c.label.padEnd(32)} → ${canStartForegroundService(c)}`);
Result
launcher activity on screen      → allowed (app is in the foreground)
BootReceiver after reboot        → allowed (exemption: BOOT_COMPLETED)
BootReceiver after app update    → allowed (exemption: MY_PACKAGE_REPLACED)
custom broadcast in background   → ForegroundServiceStartNotAllowedException
same, app still targeting 30     → allowed (rule applies from targetSdk 31)
same, app is device owner        → allowed (exemption: device owner)
What’s happening
  1. The order of checks mirrors the documentation: the rule only exists for apps targeting 31+, a visible app may always start one, and otherwise the trigger must be on the exemption list.
  2. The first four rows were also observed on the Android 16 emulator with the targetSdk 33 app: the activity start was logged as Background started FGS: Allowed [... code:PROC_STATE_TOP ...], the reboot and update starts succeeded, and a plain broadcast to RestartReceiver (a test receiver that just calls ApiServerService.start()) was refused.
  3. The refusal in the system log reads Background started FGS: Disallowed [... code:DENIED ... targetSdkVersion:33 ...], and the app's own log shows the exception message caught by start(): startForegroundService() not allowed due to mAllowStartForeground false.
  4. The "still targeting 30" row was checked too, with Android's compatibility framework instead of a second build: adb shell am compat disable FGS_BG_START_RESTRICTION_CHANGE_ID com.example.webapi (works for debuggable apps) made the same background broadcast succeed, and am compat reset brought the refusal back. dumpsys platform_compat lists the change as ChangeId(170668199; name=FGS_BG_START_RESTRICTION_CHANGE_ID; enableSinceTargetSdk=31; overridable).
  5. The device-owner row wasn't observed; it comes from the documentation only. The lesson for a device: if your server must restart on events that aren't on the list (a settings change pushed from the cloud, a network coming up), design for it, for example by keeping the service running rather than stopping and restarting it.
AdvancedExported components, PendingIntents and intents

Android 12's "safer component exporting" and "pending intents mutability" are the changes most likely to stop a build when you raise targetSdkVersion to 31 or later. Both were reproduced on the sandbox project by removing the fix:

Android Gradle Plugin 9.3.0, targetSdk 33
# android:exported removed from a <receiver> that has an <intent-filter>:
Manifest merger failed : android:exported needs to be explicitly specified for element
<receiver#com.example.webapi.BootReceiver>. Apps targeting Android 12 and higher are required to
specify an explicit value for `android:exported` when the corresponding component has an intent
filter defined.

# PendingIntent.FLAG_IMMUTABLE replaced by 0 (lint):
UnspecifiedImmutableFlag | Missing PendingIntent mutability flag

The decision behind each fix is what interviewers probe:

  • exported: true only for components other apps genuinely need: the launcher activity, a bound service that partner apps use (protected by a permission), a receiver for broadcasts from other apps. Everything else is false. A false receiver still gets system broadcasts such as BOOT_COMPLETED; the emulator confirmed it.
  • FLAG_IMMUTABLE for nearly every PendingIntent (notification taps, alarms). FLAG_MUTABLE only when another component must fill in the intent, such as inline reply.
  • Context-registered receivers: Android 13 added the ability to say whether a runtime-registered receiver is exported (RECEIVER_EXPORTED / RECEIVER_NOT_EXPORTED), available to apps targeting 13 or using ContextCompat from AndroidX Core 1.9.0 (Android 13 features). Android 14 makes it required for apps targeting 34 (Behavior changes: apps targeting Android 14), so add the flag now.
  • Intent filter matching in Android 13: older write-ups (including a post on the Android Developers blog that I couldn't open) describe Android 13 blocking intents that don't match an exported component's intent filters. The current Behavior changes: apps targeting Android 13 page doesn't list such a change, so I can't confirm it from Google's docs; test explicit intents to other apps' components on a real Android 13 build rather than relying on either version.
AdvancedPackage visibility, and what the emulator showed

From Android 11, an app targeting 30+ sees a filtered list of installed packages. developer.android.com (Package visibility filtering on Android, read October 2026) says the limit "also affects explicit interactions with other apps, such as starting another app's service", and the fix is a <queries> element naming the packages (or intents, or providers) you need. Some packages are visible automatically, including "any app that starts or binds to a service in your app", the app that installed yours, and "certain system packages". adb shell dumpsys package queries prints the device's actual visibility table.

The emulator showed a nuance worth knowing, and worth not relying on. With <queries> removed:

logcat, Android 16 emulator, webapi without <queries>
# webapi still declares <uses-permission> for the settings app's custom permission:
I SettingsClient: bindService returned true (provider visible: true)
# webapi without <queries> and without that <uses-permission>:
I SettingsClient: bindService returned false (provider visible: false)

So on this build, requesting a permission that another app defines made that app visible, even without <queries> and even when the client was signed with a different key (in that run the bind then failed on the permission). The visibility pages I read don't list this rule, so treat it as an implementation detail of one Android version: declare <queries> for every package you bind to. Note also the second line: when a package is invisible, bindService() returns false quietly; it doesn't throw. A client that ignores the return value just never connects.

AdvancedWebView, Bluetooth and Wi-Fi changes

If the device's own screen shows the admin web UI (or any web content) in a WebView, two target-gated WebView changes apply:

  • Android 12 (targeting 31): "Cookies without a SameSite attribute are treated as SameSite=Lax", SameSite=None cookies must also be Secure, and "links between HTTP and HTTPS versions of a site are now treated as cross-site requests". A local UI that logs in over http://127.0.0.1 and calls an https:// API on the same device can lose its session cookie. Set SameSite explicitly and use one scheme.
  • Android 13 (targeting 33): setForceDark() is deprecated and a no-op; WebView sets prefers-color-scheme from the app's theme, and setAlgorithmicDarkeningAllowed() (or the AndroidX WebSettingsCompat version) is the control that remains. A web UI that relied on forced dark mode will look different.

For peripherals and networking:

  • Android 12: BLUETOOTH_SCAN, BLUETOOTH_CONNECT and BLUETOOTH_ADVERTISE replace the old Bluetooth permissions for apps targeting 31 (a room device pairs headsets, speakerphones and controllers).
  • Android 13: apps targeting 33 that call Wi-Fi APIs should request NEARBY_WIFI_DEVICES (with usesPermissionFlags="neverForLocation" if they never derive location) instead of fine location. BluetoothAdapter.enable() and disable() are deprecated and "always return false" for apps targeting 33, except Device Owner, Profile Owner and system apps: a rule in this window with an explicit system-app exemption.
AdvancedWhat's next: Android 14 rules to plan for

Even if the devices stop at Android 13, a later base will bring the Android 14 (API 34) target-gated rules, and code written now can be ready for them (Behavior changes: apps targeting Android 14, read October 2026):

  • Every foreground service "must specify at least one foreground service type". There's no "web server" type; the Foreground service types page offers specialUse for cases no other type covers, and systemExempted, which is "reserved for system applications and specific system integrations" with a listed set of eligible apps.
  • Runtime-registered receivers must pass RECEIVER_EXPORTED or RECEIVER_NOT_EXPORTED.
  • "Implicit intents are only delivered to exported components."
  • The Foreground service types page also notes that camera and microphone services can't be launched from a BOOT_COMPLETED receiver, "with a few exceptions". The versions differ: the background-start page ties BOOT_COMPLETED restrictions to apps targeting Android 14, and Android 15's apps targeting page lists camera (with dataSync, mediaPlayback, phoneCall and mediaProjection) as new for apps targeting 35, adding that the restriction "has been in place for microphone since Android 14". A video device's call service shouldn't plan to start from boot with those types.
AdvancedGotcha: preinstalled doesn't mean exempt
Which of these breaks when only the OS is upgraded?

An app targeting API 29 runs a foreground-service web server and is restarted from a custom broadcast that the platform sends when the network comes up. The device is upgraded from Android 10 to Android 13; the app isn't rebuilt. Which of these now happens: (a) the build fails for missing android:exported; (b) the custom-broadcast restart throws ForegroundServiceStartNotAllowedException; (c) a user can stop the server from the notification shade's Task Manager?

Show answer

Only (c).

What’s happening
  1. (a) is a build-time rule for apps targeting 31+. The app isn't rebuilt and still targets 29, so nothing about its manifest is re-checked; the old APK installs and runs.
  2. (b) is target-gated too: the Android 12 rule applies to apps targeting 31 or higher. On Android 13 with target 29, the background start is still allowed. (The emulator, Android 16 with a target-33 app, showed the same thing in reverse: turning the change off made the start succeed. That's the documented rule, not an observation on Android 13.)
  3. (c) is an "all apps" change in Android 13: the Task Manager applies "regardless of that app's target SDK version". So the first thing the OS upgrade alone changes is that the server can now be stopped by a person, with no callback.
  4. The moment the team retargets to 33, (a) and (b) arrive as well, so the custom-broadcast restart path must be redesigned before retargeting, not after.

#Talking to platform services

Added

Almost nothing on Android talks to the system directly. When an app asks for the battery level, starts an activity or reads a setting, it makes a call into another process: system_server (where the ActivityManager, PackageManager and most framework services live), a HAL process for hardware, or another app. The mechanism under all of it is Binder, Android's inter-process call system: a kernel driver plus a library that turns a method call in one process into a message, delivers it to a thread in another process, and carries the answer back. A device web API that changes a camera setting is, in the end, a Binder call into whichever process owns the camera.

Apps get the same machinery through bound services. A service returns an IBinder from onBind(); a client calls bindService(), gets that IBinder in a callback, and calls methods on it. For calls across apps the interface is written in AIDL (Android Interface Definition Language), and the build generates a Stub (the server side you extend) and a Proxy (the client side you call).

The mental model is an office phone system with caller ID. You don't walk into the other office; you dial its extension (bind), and every call you make is stamped by the exchange with who's calling (Binder.getCallingUid() and getCallingPid() are filled in by the kernel and can't be forged). The person who answers decides, per call, whether you're allowed (permission checks). A normal call waits for the answer (synchronous); a oneway call is a voicemail: delivered, but you don't wait. And if the other office burns down, you hear the line go dead (DeadObjectException, onServiceDisconnected).

Here is the platform side of the example from the first topic: a settings service in its own app, with an AIDL interface and a signature permission. The interface, IDeviceSettings, has five methods: apiVersion(), getSetting(key), setSetting(key, value), and registerListener/unregisterListener for a oneway callback interface, ISettingsListener (the .aidl files are shown after the walkthrough). It was built with AGP 9.3.0 and run on the Android 16 emulator together with the web API app.

DeviceSettingsService.kt
class DeviceSettingsService : Service() {

    // Binder calls arrive on a pool of threads, so the state must be thread-safe.
    private val settings = ConcurrentHashMap(mapOf("display.brightness" to "70", "audio.volume" to "40"))
    private val listeners = RemoteCallbackList<ISettingsListener>()

    private val binder = object : IDeviceSettings.Stub() {
        override fun apiVersion(): Int = 2

        override fun getSetting(key: String?): String? {
            checkCaller()
            return key?.let { settings[it] }
        }

        override fun setSetting(key: String?, value: String?): Boolean {
            checkCaller()
            if (key == null || value == null || !settings.containsKey(key)) return false
            if (value.toIntOrNull() !in 0..100) return false
            settings[key] = value
            Log.i(TAG, "set $key=$value by uid ${Binder.getCallingUid()} pid ${Binder.getCallingPid()}")
            notifyListeners(key, value)
            return true
        }

        override fun registerListener(listener: ISettingsListener?) {
            checkCaller()
            if (listener != null) listeners.register(listener)
        }

        override fun unregisterListener(listener: ISettingsListener?) {
            if (listener != null) listeners.unregister(listener)
        }
    }

    // The manifest's android:permission guards bindService(); this guards every call,
    // because a bound IBinder can be handed on to a process that never bound.
    private fun checkCaller() =
        enforceCallingPermission(PERMISSION, "Caller lacks $PERMISSION")

    // beginBroadcast() must not overlap, and two binder threads can call setSetting at once.
    @Synchronized
    private fun notifyListeners(key: String, value: String) {
        val count = listeners.beginBroadcast()
        try {
            for (i in 0 until count) {
                try {
                    listeners.getBroadcastItem(i).onSettingChanged(key, value)
                } catch (e: RemoteException) {
                    // That client's process died; RemoteCallbackList drops it on its own.
                }
            }
        } finally {
            listeners.finishBroadcast()
        }
    }

    override fun onBind(intent: Intent): IBinder = binder

    companion object {
        const val PERMISSION = "com.example.settings.permission.ACCESS_DEVICE_SETTINGS"
        private const val TAG = "DeviceSettings"
    }
}
What’s happening
  1. The .aidl files (shown after this walkthrough) are the contract. The build turns IDeviceSettings.aidl into Java with an abstract Stub (extends Binder) and a Proxy. Both apps compile the same file, so a client and the service agree on method order and types; the generated Java numbers the methods FIRST_CALL_TRANSACTION + 0 to + 4 in declaration order, which matters when the interface changes (below).
  2. When the web API app calls getSetting("display.brightness"), its proxy writes the interface name and the key into a Parcel, and the Binder driver delivers it to a thread from the Binder thread pool inside the settings app's process. developer.android.com's AIDL page warns that remote calls arrive "from a thread pool", possibly at the same time, so "an implementation of an AIDL interface must be completely thread-safe". Hence ConcurrentHashMap.
  3. checkCaller() runs first in every method. enforceCallingPermission() checks the permission of the process that made this Binder call (the Context reference: it uses the pid and uid from Binder.getCallingPid()/getCallingUid()), and throws SecurityException if it isn't granted; outside an IPC it always throws. The manifest's android:permission on the service already stops unauthorised binding, but a bound IBinder is just an object reference that can be passed to a third process, so the per-call check is the real gate.
  4. setSetting validates (!settings.containsKey(key) and the 0–100 range) and logs who changed what: on the emulator, DeviceSettings: set display.brightness=55 by uid 10150 pid 2469, where uid 10150 was the web API app (u0_a150 in ps). That uid, not anything the caller sends, is what an audit log should record.
  5. notifyListeners calls each registered ISettingsListener. Because that interface is oneway, each call is queued and returns at once, so one slow client can't stall the settings service. RemoteCallbackList tracks listeners per process and, per its reference, removes a listener "if a registered interface's process goes away". @Synchronized is there because its docs say "only one broadcast can be active at a time", and two Binder threads can run setSetting concurrently.
  6. If you changed !settings.containsKey(key) to the idiomatic-looking key !in settings, it wouldn't compile: the Kotlin compiler (via AGP 9.3.0) rejected it with "Method 'contains' from ConcurrentHashMap might have unexpected semantics: it calls 'containsValue' instead of 'containsKey'". ConcurrentHashMap.contains() is a legacy method that searches values.
AdvancedThe AIDL contract files
IDeviceSettings.aidl and ISettingsListener.aidl
// IDeviceSettings.aidl: the contract between the settings service and its clients.
package com.example.settings;

import com.example.settings.ISettingsListener;

interface IDeviceSettings {
    /** Bumped when methods are added, so a client can check before calling a newer one. */
    int apiVersion();

    /** Returns null if the key is unknown. */
    String getSetting(String key);

    /** Returns false if the key is unknown or the value is invalid. */
    boolean setSetting(String key, String value);

    void registerListener(ISettingsListener listener);
    void unregisterListener(ISettingsListener listener);
}

// ISettingsListener.aidl: a callback the client implements and the service calls.
package com.example.settings;

oneway interface ISettingsListener {
    void onSettingChanged(String key, String value);
}
What’s happening
  1. IDeviceSettings is a normal (synchronous) interface: each call waits for the service's answer. Its comments are part of the contract: getSetting returns null for an unknown key, setSetting returns false for an unknown key or an invalid value. Types don't express those rules, so they have to be written down and tested (see Working across SDK, platform and web workstreams).
  2. ISettingsListener is declared oneway: the AIDL page says a oneway remote call "does not block. It sends the transaction data and immediately returns". It also can't return a value, which is fine for a notification.
  3. Each .aidl file holds one interface; both live in src/main/aidl/com/example/settings/ in both apps, so the client and the service compile the same contract. Keeping one copy (a shared module or a published artefact) instead of two hand-synced copies is what stops the version skew shown below.
AdvancedThe settings app's manifest: a signature permission
settings/AndroidManifest.xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <!-- Only apps signed with the same certificate as this app are granted it. -->
    <permission
        android:name="com.example.settings.permission.ACCESS_DEVICE_SETTINGS"
        android:label="Access device settings"
        android:protectionLevel="signature" />

    <application android:label="@string/app_name" android:allowBackup="false">
        <service
            android:name=".DeviceSettingsService"
            android:exported="true"
            android:permission="com.example.settings.permission.ACCESS_DEVICE_SETTINGS">
            <intent-filter>
                <action android:name="com.example.settings.BIND_DEVICE_SETTINGS" />
            </intent-filter>
        </service>
    </application>
</manifest>
What’s happening
  1. <permission> defines a new permission. With protectionLevel="signature", the <permission> reference says the system grants it "only if the requesting application is signed with the same certificate as the application that declared the permission", automatically and without asking the user.
  2. The service is exported="true" because another app must bind to it, and android:permission makes the system refuse bindService() from any app that doesn't hold the permission. Exported plus a signature permission is the standard shape for "only our own apps may use this".
  3. The web API app requests it with <uses-permission>. On the emulator, it was installed before the settings app existed: dumpsys package listed the permission as requested but not granted; after installing the settings app, it showed ACCESS_DEVICE_SETTINGS: granted=true. That's the Android 16 behaviour; I didn't test install order on Android 13.
  4. On a device platform, "the same certificate" is usually the platform key that signs the system image, so every app the platform team builds can share signature permissions, and partner apps signed with their own keys can't.
AdvancedThe client: binding, and what each failure looked like
SettingsClient.kt
class SettingsClient(private val context: Context) : ServiceConnection {

    @Volatile var service: IDeviceSettings? = null
        private set

    fun bind(): Boolean {
        val intent = Intent("com.example.settings.BIND_DEVICE_SETTINGS")
            .setPackage(PROVIDER) // bindService needs an explicit intent
        val ok = try {
            context.bindService(intent, this, Context.BIND_AUTO_CREATE)
        } catch (e: SecurityException) {
            Log.e(TAG, "not allowed to bind: ${e.message}") // e.g. lacking the signature permission
            false
        }
        Log.i(TAG, "bindService returned $ok (provider visible: ${isVisible()})")
        return ok
    }

    private fun isVisible() = try {
        context.packageManager.getPackageInfo(PROVIDER, 0)
        true
    } catch (e: PackageManager.NameNotFoundException) {
        false // filtered by package visibility (Android 11+), or not installed
    }

    fun unbind() {
        // Context's contract: call unbindService() whatever bindService() returned or threw.
        try {
            context.unbindService(this)
        } catch (e: IllegalArgumentException) {
            // The connection was never registered.
        }
        service = null
    }

    override fun onServiceConnected(name: ComponentName, binder: IBinder) {
        service = IDeviceSettings.Stub.asInterface(binder)
        Log.i(TAG, "connected to $name, apiVersion ${service?.apiVersion()}")
    }

    override fun onServiceDisconnected(name: ComponentName) {
        // The provider's process died. The binding stays; Android reconnects when it restarts.
        service = null
        Log.w(TAG, "disconnected from $name")
    }

    override fun onBindingDied(name: ComponentName) {
        // The binding itself is dead (for example, the provider app was updated): bind again.
        Log.w(TAG, "binding died for $name; binding again")
        unbind()
        bind()
    }

    private companion object {
        const val TAG = "SettingsClient"
        const val PROVIDER = "com.example.settings"
    }
}
What’s happening
  1. bind() builds an intent aimed at one package (action plus setPackage). The bindService reference says the intent "must specify an explicit component name"; naming the package, as here, also worked on the emulator, and setComponent() with the full class name is the strictest form. BIND_AUTO_CREATE starts the settings app's process if it isn't running; on the emulator the settings app was never opened and binding started it anyway.
  2. bindService() has three outcomes, and the first version of this class handled only one. Normal: returns true, and onServiceConnected follows (about 0.1 s later on the emulator, logged with apiVersion 2). Package not visible or service not found: returns false, no exception. Not allowed: throws SecurityException. The first version didn't catch it, and a test with a client signed by a different key crashed the whole web API app: FATAL EXCEPTION ... Unable to create service com.example.webapi.ApiServerService: java.lang.SecurityException: Not allowed to bind to service Intent {...}. Now it logs and the API answers 503 with "settingsConnected": false.
  3. unbind() follows the bindService reference: "Regardless of the return value, you should later call unbindService to release the connection", and also after a SecurityException. The IllegalArgumentException catch covers the case where nothing was registered.
  4. onServiceDisconnected means the provider's process died; the binding survives and Android restarts the service. Killing the settings process on the emulator (kill -9) gave: disconnected at 13:57:20.963, system "Scheduling restart of crashed service ... in 1000ms", connected again at 13:57:22.053. A second kill 12 s later was scheduled "in 4000ms": Android backs off for services that keep crashing. During the gap, the web API returned 503 {"title":"Settings service not connected"} to every request.
  5. onBindingDied (API 26+) means the binding itself is finished and Android won't reconnect it; the usual cause is the provider app being updated. Installing a new settings APK logged disconnected, then binding died ...; binding again, then connected 0.1 s later. Without this override, the web API would have stayed at 503 until it restarted.
AdvancedEvolving an AIDL interface without breaking old clients

On a device platform, the web API app and the platform service ship on different schedules, so the two sides of an AIDL interface will be different versions. The generated proxy doesn't send method names; it sends numbers, assigned by position. This model reproduces what that means:

aidl-codes.mjs
// How AIDL calls travel: the generated proxy sends a transaction *number*, not a method name.
// Numbers are FIRST_CALL_TRANSACTION (1) + the method's position in the .aidl file.
const FIRST_CALL_TRANSACTION = 1;
const codesFor = (methods) => Object.fromEntries(methods.map((m, i) => [m, FIRST_CALL_TRANSACTION + i]));

// A tiny Parcel of typed values. Reading past the end, or reading a String where a boolean was
// written, gives null: what the real proxies returned on an Android 16 emulator.
const parcel = (values = []) => ({ values, pos: 0,
  readString() { const x = this.values[this.pos++]; return typeof x === "string" ? x : null; } });

function makeService(methods, impl) {
  const byCode = Object.fromEntries(methods.map((m, i) => [FIRST_CALL_TRANSACTION + i, m]));
  return (code, data) => {
    const method = byCode[code];
    if (!method) return { handled: false, reply: parcel() }; // unknown code: onTransact returns false
    return { handled: true, reply: parcel([impl[method](data)]) };
  };
}

function makeProxy(methods, transact) {
  const codes = codesFor(methods);
  // Like the generated Proxy: the boolean result of transact() is ignored.
  return (method, ...args) => transact(codes[method], parcel(args)).reply.readString();
}

const v2Methods = ["apiVersion", "getSetting", "setSetting", "registerListener", "unregisterListener"];
const settings = { "display.brightness": "70" };
const v2Service = makeService(v2Methods, {
  apiVersion: () => "2",
  getSetting: (d) => settings[d.readString()] ?? null,
  setSetting: (d) => { const k = d.readString(), v = d.readString();
    console.log(`     (service ran setSetting(${k}, ${v}))`); return v !== null; },
  registerListener: () => null, unregisterListener: () => null,
});

const appended = [...v2Methods, "exportAll"];
const inserted = ["apiVersion", "exportAll", ...v2Methods.slice(1)];

for (const [label, methods] of [["v2 client", v2Methods], ["v3 client, method appended", appended],
                                ["v3 client, method inserted", inserted]]) {
  const call = makeProxy(methods, v2Service);
  console.log(`${label}: codes ${JSON.stringify(codesFor(methods))}`);
  console.log(`  getSetting("display.brightness") -> ${call("getSetting", "display.brightness")}`);
  if (methods.includes("exportAll")) console.log(`  exportAll() -> ${call("exportAll")}`);
}
Result
v2 client: codes {"apiVersion":1,"getSetting":2,"setSetting":3,"registerListener":4,"unregisterListener":5}
  getSetting("display.brightness") -> 70
v3 client, method appended: codes {"apiVersion":1,"getSetting":2,"setSetting":3,"registerListener":4,"unregisterListener":5,"exportAll":6}
  getSetting("display.brightness") -> 70
  exportAll() -> null
v3 client, method inserted: codes {"apiVersion":1,"exportAll":2,"getSetting":3,"setSetting":4,"registerListener":5,"unregisterListener":6}
     (service ran setSetting(display.brightness, null))
  getSetting("display.brightness") -> null
  exportAll() -> null
What’s happening
  1. codesFor is what the AIDL compiler does: the generated Java for this chapter's interface contains TRANSACTION_apiVersion = (android.os.IBinder.FIRST_CALL_TRANSACTION + 0) through TRANSACTION_unregisterListener = (... + 4), and IBinder.FIRST_CALL_TRANSACTION is 0x00000001.
  2. Same version on both sides: getSetting is code 2 on both, and the answer is 70.
  3. A newer client that appended exportAll() keeps every old code. Calling the new method on the old service sends code 6, which the old onTransact doesn't know: it returns false, and the generated proxy ignores that boolean (boolean _status = mRemote.transact(...) is never read) and reads an empty reply. Result: null, no exception. That was checked on the emulator with a real v3 client against the real v2 service: exportAll() on old service -> null. Safe, but silent, which is why the interface has apiVersion(): check it before calling anything newer.
  4. A client that inserted exportAll() in the middle shifted every later code by one. Its getSetting sends code 3, which the old service runs as setSetting, reading display.brightness as the key and null as the value. The model's service rejects the null; a real service with weaker checks could have written something. The client gets a wrong answer (null instead of 70) with no error. On the emulator the inserted-method client also got null from getSetting.
  5. The rules that follow: only append methods, never reorder, rename types or change parameters; add an apiVersion() (or capability) call and check it; and for platform-internal interfaces use Stable AIDL. source.android.com (Stable AIDL, updated 9 October 2026) describes interfaces declared with aidl_interface, versions frozen by the build, changes limited to "methods to the end of an interface" and parcelable fields at the end with defaults, and generated getInterfaceVersion()/getInterfaceHash(). It also says the Java backend throws RemoteException "with a message saying the API is not implemented" when a newer client calls an old server, instead of the silent null above.
AdvancedIntents, broadcasts or Binder: which to use

Not every interaction with the platform needs a bound service. The three tools answer different questions:

MechanismShapeGood forWatch out for
Bound service (AIDL)request/response, plus callbackssettings, status, commands that return a result; anything the web API calls per requestversion skew (above), threading, DeadObjectException, binding lifecycle
Explicit intent to a service or activityfire a command"start this", "show this screen"Android 12+ background-start limits for services
Broadcastone-to-many event"network changed", "boot completed", "call started"delivery isn't guaranteed to be immediate; exported receivers can be sent fake events

Two rules keep broadcasts safe on a device:

  • Protect them both ways. A sender can require a permission of receivers with sendBroadcast(intent, receiverPermission), and a receiver can require one of senders with android:permission on <receiver>. With a signature permission, only the platform's own apps can send or hear the event.
  • Say whether runtime receivers are exported. Since Android 13 you can pass RECEIVER_NOT_EXPORTED when registering a receiver in code; Android 14 requires a flag for apps targeting 34 (previous topic).
AdvancedSignature, privileged and system APIs

A device platform's own apps live higher up Android's permission ladder than store apps. The protection levels, from developer.android.com's <permission> reference (last updated 10 February 2025):

LevelGranted toTypical use
normalany app that asks, at installlow-risk features (INTERNET)
dangerousapps the user approves at runtimecamera, microphone, location, notifications
signatureapps signed with the same certificate as the declaring appyour own platform's services (this chapter's example)
signature|privileged (formerly signatureOrSystem, deprecated in API 23)same-signature apps or privileged apps in the system imagefeatures shared by apps "built into a system image" by several vendors

Privileged permissions need one more step. source.android.com (Privileged permission allowlisting, updated 9 October 2026) says privileged apps are "system apps located in a priv-app directory on a system image partition", that from Android 8.0 the device maker grants their privileged permissions explicitly in XML under /etc/permissions/, and that on Android 9 and higher, "If privileged permissions are not granted, the device won't boot" (when enforcement is on). An allowlist entry looks like this (shape from that page; the package and permission are examples, not a real Poly file):

privapp-permissions-example.xml
<permissions>
    <privapp-permissions package="com.example.webapi">
        <permission name="android.permission.WRITE_SECURE_SETTINGS"/>
        <deny-permission name="android.permission.READ_PRIVILEGED_PHONE_STATE"/>
    </privapp-permissions>
</permissions>

And system APIs: framework methods marked @SystemApi or hidden aren't in the public SDK's android.jar. A system app built with the platform can use them; a regular app reaching them by reflection is using "non-SDK interfaces", which every release from Android 11 to 13 restricted further ("using any non-SDK method or field always carries a high risk of breaking your app", in each version's apps targeting behaviour-change page). One more item, from Android 13's Behavior changes: all apps page, that platform apps meet: android:sharedUserId is being retired, and Android 13 added android:sharedUserMaxSdkVersion="32" so new installs behave as if it weren't set, with the warning "If you already define the android:sharedUserId attribute in your manifest, don't remove it. Doing so causes app updates to fail."

AdvancedGotcha: synchronous calls on the main thread
What did the inserted method actually do?

Version 3 of IDeviceSettings.aidl adds String exportAll(); as the second method. The web API app ships with v3; the platform still runs the v2 service. The web API calls setSetting("audio.volume", "30"). Which method runs on the v2 service, and what does the web API see?

Show answer

The v2 service runs registerListener, and the web API sees false, with no exception.

What’s happening
  1. In v3, the order is apiVersion (1), exportAll (2), getSetting (3), setSetting (4), so the v3 proxy sends setSetting as code 4.
  2. In v2, code 4 is registerListener (apiVersion 1, getSetting 2, setSetting 3, registerListener 4). The v2 stub dispatches on the number alone. It does check the interface name at the start of the parcel, but both versions are called com.example.settings.IDeviceSettings, so that check passes.
  3. On the emulator, exactly this happened. The settings process logged W Parcel: Attempt to read object from Parcel ... at offset 92 that is not in the object list: that's registerListener trying to read a Binder object where the proxy wrote two strings. It didn't crash and changed nothing (audio.volume still read 40 afterwards through a correct client).
  4. The v3 client logged setSetting(audio.volume, 30) -> false, and its getSetting(audio.volume) (code 3, which v2 runs as setSetting with a missing value) returned null. A valid write is reported as rejected, a valid read as unknown, and nobody gets an error. That's the case for append-only interfaces, an apiVersion() check, and Stable AIDL in the platform build.

#Working across SDK, platform and web workstreams

Added

A feature like "let admins change the camera's default zoom from the web UI" touches at least three teams: the platform team (the Android build, system services, the camera stack), whoever owns the device API (the HTTP and Telnet layer, often an app or daemon on the device), and the web UI team. If there's a partner SDK, a fourth. Each team can be in a different time zone and on a different release train. The work doesn't fail inside any one team; it fails at the seams, when one side's assumption about the other turns out to be wrong in the field.

The mental model is a relay race: the baton is the request, and the dangerous part is the handover zone. Teams that win relays practise the handover more than the running. In software the handover zones are contracts: the AIDL interface (or system API) between platform and device API, and the OpenAPI document between device API and web UI. A good contract is written down, versioned, owned by one team, and tested from both sides. Everything else in this topic (fakes, contract tests, integration environments) exists to practise the handover before the race.

Here is the most useful practice tool: one contract test suite, run against both the web team's fake of the platform service and the platform team's real build. It's a Node model of the settings service from the previous topic, using zod 4 for the shapes:

contract-fake.mjs
// One contract, two implementations: the web team's fake and the platform team's build.
// The same contract tests run against both, so drift shows up as a failing row, not a field bug.
import { z } from "zod";

const Setting = z.object({ key: z.string(), min: z.number().int(), max: z.number().int() });
const catalogue = z.array(Setting).parse([
  { key: "display.brightness", min: 0, max: 100 },
  { key: "audio.volume", min: 0, max: 100 },
]);
const Value = z.string().regex(/^\d+$/); // values cross the AIDL boundary as decimal strings

function makeFake() { // what the web team develops against
  const state = { "display.brightness": "70", "audio.volume": "40" };
  return {
    apiVersion: () => 2,
    getSetting: (key) => state[key] ?? null,
    setSetting(key, value) {
      const spec = catalogue.find((s) => s.key === key);
      const n = Number(value);
      if (!spec || !Value.safeParse(value).success || n < spec.min || n > spec.max) return false;
      state[key] = value;
      return true;
    },
  };
}

function makePlatformBuild() { // a later platform build that drifted in two places
  const state = { "display.brightness": "70", "audio.volume": "40" };
  return {
    apiVersion: () => 2,
    getSetting(key) {
      if (!(key in state)) throw new Error("IllegalArgumentException: unknown key " + key); // drift 1
      return state[key];
    },
    setSetting(key, value) {
      if (!(key in state)) return false;
      state[key] = String(Math.min(Number(value), 100)); // drift 2: clamps instead of rejecting
      return true;
    },
  };
}

const contract = [
  ["apiVersion is an integer >= 2", (s) => z.number().int().min(2).safeParse(s.apiVersion()).success],
  ["known key returns a decimal string", (s) => Value.safeParse(s.getSetting("audio.volume")).success],
  ["unknown key returns null, never throws", (s) => s.getSetting("nope") === null],
  ["valid set returns true and reads back", (s) => s.setSetting("audio.volume", "55") && s.getSetting("audio.volume") === "55"],
  ["out-of-range set returns false, value kept", (s) => !s.setSetting("audio.volume", "140") && s.getSetting("audio.volume") === "55"],
];

for (const [name, make] of [["web team's fake", makeFake], ["platform build", makePlatformBuild]]) {
  const impl = make();
  const results = contract.map(([label, test]) => {
    let ok;
    try { ok = test(impl); } catch (e) { ok = false; label += ` (threw ${e.message.split(":")[0]})`; }
    return `  ${ok ? "pass" : "FAIL"}  ${label}`;
  });
  console.log(`${name}:\n${results.join("\n")}`);
}
Result
web team's fake:
  pass  apiVersion is an integer >= 2
  pass  known key returns a decimal string
  pass  unknown key returns null, never throws
  pass  valid set returns true and reads back
  pass  out-of-range set returns false, value kept
platform build:
  pass  apiVersion is an integer >= 2
  pass  known key returns a decimal string
  FAIL  unknown key returns null, never throws (threw IllegalArgumentException)
  pass  valid set returns true and reads back
  FAIL  out-of-range set returns false, value kept
What’s happening
  1. The catalogue and Value schemas are the written-down part of the contract: which keys exist, their ranges, and that values travel as decimal strings. zod's parse runs at startup, so a malformed catalogue fails loudly instead of quietly allowing everything.
  2. makeFake() is what the web team builds against before any device build exists: it follows the contract exactly. The web UI and the HTTP layer can be developed and tested on a laptop against it, in any time zone, without waiting for the platform team.
  3. makePlatformBuild() stands for a later real build with two realistic drifts: an unknown key throws instead of returning null, and an out-of-range value is clamped to 100 instead of rejected. Both look like reasonable choices from inside the platform team; both break the HTTP layer (404 becomes a 500; a 422 becomes a silent 200 with the wrong value).
  4. The contract array is written once and run against both implementations. The fake passes all five; the platform build fails exactly the two drifted rules, with the exception name in the label. An IllegalArgumentException thrown in a real AIDL service would reach the client too: the AIDL page notes that exception types supported by Parcel.writeException() are sent back to the caller.
  5. The point isn't the fake; it's that the same tests run against the fake on every web build and against each platform build in an integration environment. When the platform build changes behaviour, the failure names the rule and the team that owns it, weeks before a customer finds the clamped value.
AdvancedWho owns what

The cheapest coordination is clear ownership of each artefact. A shape that works:

ArtefactOwnerConsumersChanges by
AIDL interface / system APIplatform teamdevice API team, partner SDKproposal, review by consumers, append-only versions
Settings catalogue (keys, ranges, restart-required flags)platform team, in the repo with the servicedevice API, web UI (generates forms from it)same review as the interface
OpenAPI document for the device APIdevice API teamweb UI team, partner integrators, SDKdesign-first PR, contract tests, versioning rules
Fakes of platform servicesdevice API teamweb UI team, CIupdated when the contract changes, and tested against the contract
Device simulator / emulator imageplatform or test teameveryonereleased with each platform build

A few practices make the table real across time zones:

  • Write interface proposals before code. A one-page change proposal (new method, new key, behaviour on old versions) that the consuming teams review asynchronously beats a meeting that half the teams attend at midnight.
  • Version every contract and negotiate at runtime. apiVersion() on the AIDL side, capability discovery on the HTTP side (see Capability discovery), so a newer web UI can run against an older device and switch features off instead of failing.
  • Keep the fake honest. The fake lives next to the contract tests, and the contract tests run against the real build in CI on a schedule. A fake nobody checks against reality drifts into a second, imaginary product.
  • Agree on failure semantics, not just shapes. "Unknown key returns null" and "out-of-range returns false" are exactly the kind of rule that types don't capture and that the model above caught. Write them in the interface comments and test them.
AdvancedIntegration environments, from laptop to lab

Each environment answers a different question, and moving a check to the cheapest environment that can answer it is most of the craft:

  1. Fakes on a laptop (minutes): does the web UI and HTTP layer handle every contract outcome, including errors? Runs on every commit.
  2. Android emulator with the team's apps (tens of minutes): do the apps start, bind, survive reboot and update, and follow the Android rules for their target? This chapter's observations (background starts, binding failures, restart back-off) all came from this level, on an Android 16 (API 36) AOSP image with apps targeting 33. A stock emulator image isn't the device's system image, so privileged permissions, SELinux policy and real hardware services aren't covered.
  3. Emulator or virtual device built from the platform's own tree (if the platform team provides one): the real system services and permissions, still no hardware. See Device simulators and emulators.
  4. Lab devices (hours, shared): real cameras, codecs, peripherals and networks. Contract tests against the real platform build run here nightly. See Hardware-in-the-loop and lab testing.
  5. Field pilot (days to weeks): a small ring of real customer rooms on the new build, with telemetry, before a staged rollout.
AdvancedGotcha: a fake that is kinder than the real thing
Which team should change?

The contract says an out-of-range setSetting returns false. The nightly lab run shows the platform build clamping 140 to 100 and returning true. The platform team says clamping is friendlier. Whose change is it, and what happens next?

Show answer

It's the platform team's build that broke the contract, but the right next step is a contract decision, not a quick fix on either side.

What’s happening
  1. The test did its job: a named rule ("out-of-range set returns false, value kept") failed against one implementation. That turns an argument about whose code is wrong into a question about the written contract.
  2. The current contract says reject, so the platform build is out of contract today. Until it's resolved, the device API must not silently accept the new behaviour, because the HTTP API documents 422 for out-of-range values and partners may rely on that.
  3. If clamping really is better, it's a contract change: proposed in writing, reviewed by the consuming teams, versioned (old clients still see rejection, or the API reports the clamped value back), and then the fake and the tests change with it.
  4. What you should never do is "fix" the fake to match the build, which silently moves the contract with no owner having agreed to it.

#Debugging across the stack

Added

A single click on "Save" in a device's admin UI crosses more boundaries than most web requests: the browser, the network between the admin's laptop and the device, the device's HTTP server process, a Binder call into a platform service, and from there into a HAL and the hardware. When the click "doesn't work", the bug is at one of those boundaries, and each boundary has its own tool. Debugging well is mostly knowing which tool sees which layer and lining their timelines up.

The mental model is a parcel's tracking history. Each hop scans the parcel with a timestamp: the browser's Network panel, the server's access log, the app's logcat lines, the system's own ActivityManager messages. You don't guess where the parcel was lost; you read the scans in order and find the first hop that didn't scan it, or scanned it late. That only works if the clocks agree and if something (a request id, a pid, a timestamp) lets you match scans across hops.

Here's the tracking-history idea applied to real logs. These logcat lines came from the emulator run in Talking to platform services, when the settings process was killed twice while the web API was running; the model reads them and answers the question an operator would ask: how long was the API unable to answer?

logcat-outage.mjs
// Turn logcat into answers: how long was the settings service unavailable to the web API?
// The lines are real `adb logcat -v threadtime` output from killing the settings process twice
// on an Android 16 emulator (dates and ids as captured).
const log = `
10-27 13:57:20.962   654  2118 I ActivityManager: Process com.example.settings (pid 1994) has died: prcp FGS
10-27 13:57:20.963  1961  1961 W SettingsClient: disconnected from ComponentInfo{com.example.settings/com.example.settings.DeviceSettingsService}
10-27 13:57:20.963   654  2118 W ActivityManager: Scheduling restart of crashed service com.example.settings/.DeviceSettingsService in 1000ms for connection
10-27 13:57:21.975   654   717 I ActivityManager: Start proc 2234:com.example.settings/u0a151 for service {com.example.settings/com.example.settings.DeviceSettingsService}
10-27 13:57:22.053  1961  1961 I SettingsClient: connected to ComponentInfo{com.example.settings/com.example.settings.DeviceSettingsService}, apiVersion 2
10-27 13:57:33.243   654  2118 I ActivityManager: Process com.example.settings (pid 2234) has died: prcp FGS
10-27 13:57:33.243  1961  1961 W SettingsClient: disconnected from ComponentInfo{com.example.settings/com.example.settings.DeviceSettingsService}
10-27 13:57:33.243   654  2118 W ActivityManager: Scheduling restart of crashed service com.example.settings/.DeviceSettingsService in 4000ms for connection
10-27 13:57:37.253   654   717 I ActivityManager: Start proc 2262:com.example.settings/u0a151 for service {com.example.settings/com.example.settings.DeviceSettingsService}
10-27 13:57:37.326  1961  1961 I SettingsClient: connected to ComponentInfo{com.example.settings/com.example.settings.DeviceSettingsService}, apiVersion 2
`;

// threadtime: date time pid tid level tag: message
const LINE = /^(\d\d-\d\d) (\d\d):(\d\d):(\d\d)\.(\d{3})\s+(\d+)\s+(\d+) ([VDIWEF]) ([^:]+): (.*)$/;
const entries = log.trim().split("\n").map((line) => {
  const [, , h, m, s, ms, pid, , level, tag, msg] = line.match(LINE);
  return { t: ((+h * 60 + +m) * 60 + +s) * 1000 + +ms, pid: +pid, level, tag: tag.trim(), msg };
});

let down = null;
for (const e of entries) {
  if (e.tag === "ActivityManager" && /has died/.test(e.msg)) console.log(`died        ${e.msg.match(/pid \d+/)[0]}`);
  const delay = e.msg.match(/restart of crashed service .* in (\d+)ms/);
  if (delay) console.log(`restart in  ${delay[1]} ms (scheduled by system_server, pid ${e.pid})`);
  if (e.tag === "SettingsClient" && e.msg.startsWith("disconnected")) down = e.t;
  if (e.tag === "SettingsClient" && e.msg.startsWith("connected") && down !== null) {
    console.log(`outage      ${e.t - down} ms of 503s from the web API (client pid ${e.pid})`);
    down = null;
  }
}
Result
died        pid 1994
restart in  1000 ms (scheduled by system_server, pid 654)
outage      1090 ms of 503s from the web API (client pid 1961)
died        pid 2234
restart in  4000 ms (scheduled by system_server, pid 654)
outage      4083 ms of 503s from the web API (client pid 1961)
What’s happening
  1. -v threadtime is logcat's most useful format: date, time to the millisecond, pid, tid, level, tag, message. The regex pulls those apart; the date is ignored because both incidents were on the same day.
  2. The lines interleave two processes. pid 1961 is the web API app (its SettingsClient tag), and pid 654 is system_server, which hosts ActivityManager: it noticed the death, scheduled the restart and started the new process. Reading both is what turns "the API flaked" into a cause.
  3. For the first death the system scheduled a restart in 1,000 ms, and the client was reconnected 1,090 ms after it saw the disconnect: the extra 90 ms is process start plus binding. During that window the web API answered 503 (seen with curl in the second incident).
  4. The second death, 12 s later, was scheduled in 4,000 ms, and the outage was 4,083 ms. Android backs off restarts for a service that keeps crashing, so a crash loop in a platform service shows up at the HTTP layer as growing gaps of 503s. A web UI that treats a 503 with Retry-After as "wait and retry" rides through it; one that shows "device offline" after one failure flaps (see Reconnection and back-off).
  5. The same few lines would be invisible in the web server's own logs, which would only show 503s. That's the lesson: correlate across processes by time and pid, and log enough on your side (the client's disconnected/connected) that the other side's events line up with yours.
AdvancedThe adb toolkit, by question

Every command below was run against the emulator for this chapter unless marked "documented", in which case it comes from the developer.android.com page named. On a production device you'll usually have less: user builds don't allow adb root, and run-as only works for debuggable apps.

QuestionCommandWhat it showed here
Is my process alive, and which pid?adb shell pidof com.example.webapi3216
Is my service running and in the foreground?adb shell dumpsys activity services com.example.webapiisForeground=true foregroundId=1 types=0x00000000
Is anything listening on my port?adb shell netstat -ltntcp6 0 0 [::]:8080 [::]:* LISTEN
Can I call the API from my laptop?adb forward tcp:18609 tcp:8080, then curl http://127.0.0.1:18609/...the responses in the first topic
What did my app log?adb logcat -v threadtime --pid=$(adb shell pidof com.example.webapi)only that process's lines
What did the system decide about my app?adb logcat -v threadtime -s ActivityManager:WBackground started FGS: Disallowed [...]
Which permissions does my app hold?adb shell dumpsys package com.example.webapiPOST_NOTIFICATIONS: granted=false, flags=[ USER_SET|...]
Which packages can my app see?adb shell dumpsys package queriesthe visibility table (Android 11+)
Does a target-gated change cause this bug?adb shell am compat disable <CHANGE> <package> (debuggable apps)turned the background-start rule off and on
What is on screen?adb shell uiautomator dump, adb exec-out screencap -p > s.pngthe Android 13 notification prompt
What happens if the user stops the app?adb shell cmd activity stop-app <package>documented (Handle user-initiated stopping)
What if the app is battery-restricted?adb shell am set-standby-bucket <package> restricted, adb shell cmd appops set <package> RUN_ANY_IN_BACKGROUND ignoredocumented (Android 13 behaviour changes: all apps)

Two habits make these more useful. First, filter by pid and tag: a device's logcat is busy (the emulator logged Wi-Fi, launcher and graphics messages around every test), and --pid or -s Tag cuts it to your story. Second, remember that adb forward (device port to laptop) and adb reverse (laptop port to device) let you debug either direction without opening the device's firewall: forward to call the device API from your laptop's browser and tools, reverse to point an app on the device at a dev server on your laptop.

AdvancedFrom the browser side

The top of the stack has its own tools, and most device "bugs" reported by admins start there:

  • Network panel and HAR files. The browser's DevTools show each request's status, timing and headers. A HAR export from the admin who saw the problem is the first scan of the tracking history: it tells you whether the device answered, with what status, and how long it took. Ask for it before anything else.
  • Clock alignment. The browser's timestamps are the laptop's clock; logcat is the device's. Note the offset (or use the server's Date response header) before lining them up.
  • On-device WebViews. If the device's own screen renders web content in a WebView, Chrome's remote debugging can inspect it from chrome://inspect once the app calls WebView.setWebContentsDebuggingEnabled(true). The Chrome DevTools page (last updated 2015, still the reference) says this isn't tied to the manifest's debuggable flag, so gate the call yourself and never ship it enabled.
  • A request id across hops. Generate an id per request at the edge (or accept an X-Request-Id from the web UI), log it in the HTTP server, and include it in the log line around each platform call. The example in this chapter doesn't do that yet; adding it is the cheapest debugging investment a device API can make.
AdvancedGotcha: your laptop isn't the device's network
Where do you look first?

The admin UI shows "Device unreachable" for about four seconds, a few seconds after an admin saves a setting, and then recovers. The device didn't reboot. Using this chapter's stack, what's the most likely chain of events, and which log proves it?

Show answer

The platform service behind the setting crashed (probably on the new value) and was restarted by Android with back-off; the web API returned 503s meanwhile, and the UI showed them as "unreachable". logcat from system_server (ActivityManager) proves it.

What’s happening
  1. "No reboot, recovers on its own in seconds" rules out the device and the network: the HTTP server kept answering. What the UI calls "unreachable" is probably 503 responses, not timeouts.
  2. "A few seconds after saving a setting" points at the platform service that handles settings: a value that makes it crash is the classic trigger.
  3. In logcat, look for ActivityManager: Process ... has died for that service's package, followed by Scheduling restart of crashed service ... in 4000ms. A four-second gap matches the back-off the emulator showed on a repeated crash (1 s the first time, 4 s the second).
  4. The web API's own disconnected and connected lines bracket the outage exactly, as the model above computed. The fixes then sit in three places: the platform service (validate and don't crash), the device API (503 with Retry-After), and the UI (retry before showing "unreachable"; see Device unreachable, rebooting and offline states).

Written fresh for a device-platform full stack role. Runnable examples were run on Node 26; product facts come from public documentation, cited where used.

RohitDownloads · All chapters

Sync progress across devices

Type the same private phrase on your Mac and your phone, and your Learned ticks follow you between them — on every study site.

The phrase never leaves this device: only a fingerprint of it is sent, and the server stores a fingerprint of that. Anyone who knows the phrase could see or change your ticks, so pick something you don’t use elsewhere.

Sync is on in this browser.

Sync ID

This ID must be the same on every device. If another device shows a different one, its phrase is different (capital letters count): tap “Turn off here” on it and type the phrase again exactly.