#Web UIs and APIs on an Android-based device
AddedOn 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
Servicethat callsstartForeground()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 itstargetSdkVersion. - A privileged system app. The same kind of APK, but built into the system image (a
priv-appdirectory) and usually signed with the platform's key. It can hold privileged and signature permissions that regular apps can't, can be markedandroid: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.rcfile on the system image.initstarts it at boot and restarts it when it exits (the AOSPinitREADME: non-oneshotservices are restarted, with a defaultrestart_periodof 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: noService, notargetSdkVersion, 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 service | Privileged system app | Native daemon (init) | |
|---|---|---|---|
| Who starts it | the app (activity, boot broadcast) | the app, or the system if persistent | init, from an .rc file |
| Kept alive by | foreground-service status | persistent and/or foreground service | init restarts it on exit |
| Updated by | installing a new APK | a system image update (or an APK update over the preinstalled copy) | a system image update |
| Extra powers | runtime/normal permissions only | privileged and signature permissions, system APIs | Linux capabilities, SELinux domain set by the platform |
| Android behaviour changes | all of them, by targetSdkVersion | most of them; some rules exempt system apps | none (it's not an app), but SELinux and init rules |
| Port 80/443 | not by default (see below) | not by default | yes, if given the capability |
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.
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
}
}
}
}- Something calls
ApiServerService.start(context): the launcher activity when it opens, or a receiver after a reboot or an update. That callsstartForegroundService(), a promise to Android that the service will go foreground. TheContextreference says the service gets "an amount of time comparable to the ANR interval" to keep it, or the system crashes the process. - Android creates the service:
onCreate()binds to the platform's settings service throughSettingsClient(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 get503. 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 aServerSocketon port 8080 and returnsSTART_STICKY, which asks Android to recreate the service if its process is killed.- 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,logcatshowedApiServer: listening on port 8080about 6 ms after the activity started the service. onDestroy()closes the socket, which makes the blockedaccept()throwSocketException, 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.start()catches the exception Android 12 introduced. On Android 12 and later, an app that targets API 31+ and callsstartForegroundService()from the background getsForegroundServiceStartNotAllowedException(a subclass ofIllegalStateException, which is why thecatchcompiles withminSdk30). Without thecatch, a boot-time or broadcast-time start would crash the app instead of logging.
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:
<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>INTERNETis described in the permission reference as "Allows applications to open network sockets"; that includes the listening socket.FOREGROUND_SERVICEis whatstartForeground()requires for apps targeting API 28 or later (theServicereference says so).POST_NOTIFICATIONSis 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.- The
<queries>element and the custom permission belong to the Binder side: they let this app see and use the platform's settings service. - The service is
exported="false": no other app can start or stop the server. The receiver is alsoexported="false", and on the emulator it still receivedBOOT_COMPLETEDfrom the system after a reboot andMY_PACKAGE_REPLACEDafteradb install -r. - Because the receiver has an
<intent-filter>, atargetSdk33 build must sayandroid:exportedexplicitly. Removing it made the build fail in the manifest merger with "Apps targeting Android 12 and higher are required to specify an explicit value forandroid:exportedwhen the corresponding component has an intent filter defined."
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)
}
}- After a reboot, Android sends
BOOT_COMPLETEDto apps that holdRECEIVE_BOOT_COMPLETEDand aren't in the stopped state (never launched, or force-stopped). The emulator log showedBootReceiver: android.intent.action.BOOT_COMPLETED, thenlistening on port 808015 ms later. - 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.
MY_PACKAGE_REPLACEDis 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 afteradb install -r, despiteSTART_STICKY: requests failed until the app was opened again. With this action added, the next update loggedBootReceiver: android.intent.action.MY_PACKAGE_REPLACEDand the server was listening 8 ms later.- 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 ...].
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.
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")
}
}handle()runs on a worker thread.client.use { }closes the socket however the block exits, including the earlyreturns when a client disconnects mid-request.soTimeout = 5_000makes a silent client fail after 5 s instead of holding a worker forever (four workers means four silent clients would otherwise stop the API).- It reads the request line (
PUT /api/v1/settings/display.brightness HTTP/1.1), then headers until the blank line, keeping onlyContent-Length. The body size is capped at 1,024 characters, because a device API must not let one request allocate unbounded memory. - The loop around
read()is the bug fix. The first version calledinput.read(buffer)once. On the emulator, the firstPUTwith body55came back422even though 55 is valid: oneread()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. route()maps the HTTP request onto the platform service:GET /api/v1/statusreports whether the Binder connection is up,GETandPUTon/api/v1/settings/{key}callgetSettingandsetSettingacross processes.JSONObject(built into Android) builds the body, so a key with a quote in it can't break the JSON.- Each platform failure becomes a different HTTP status: not connected yet or connection lost →
503, the call threwRemoteException(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 -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.
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:
$ 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 [::]:* LISTENSo 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.
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:
// 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);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"}platformstands in for the Binder proxy: one key works, one is unknown, one throws like aDeadObjectException(the remote process died during the call), one never settles, and one throws like aSecurityException.- Known and unknown keys are the easy cases:
200with the value,404for a key the service doesn't have. The unknown key returnsnullrather than throwing, which is a contract choice the next topics come back to. - 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
503withRetry-After, which tells a web UI or a partner script to try again rather than to give up. camera.ptzhangs, andwithTimeoutturns it into504after 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 insidetransact(). 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.- A
SecurityExceptionmeans the device's own software is misconfigured (wrong signature, missing permission), so it's a500; the caller can't fix it by retrying..unref()on the timer and settlingneverSettlesat the end let the script exit cleanly.
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).
- 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.
START_STICKYasks 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.- Nothing in the new version runs until an entry point fires: an activity, a broadcast, a bound service.
MY_PACKAGE_REPLACEDis that entry point, sent only to the app that was just updated. - 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.