Why Put a Web Server Inside a Phone?
When I was building Background Camera RemoteStream — an Android app that records video with the screen off — I needed a way for users to control the camera remotely. The phone's screen is off, so you can't tap anything. The solution: embed a web server inside the app and let users control it from any browser on the same WiFi.
This turned out to be one of the most interesting technical challenges in the whole project. Here's how it works.
Choosing Ktor
I evaluated several options for an embedded HTTP server on Android:
- NanoHTTPD — Lightweight and popular, but limited. No WebSocket support out of the box, no coroutine integration, and the API feels dated.
- Ktor — JetBrains' async HTTP framework. Full-featured, coroutine-native, WebSocket support, and works on Android.
- Spring Boot — Way too heavy for an Android app.
Ktor won because it's Kotlin-native, supports coroutines (essential for Android), and has first-class WebSocket support for real-time updates.
Basic Setup
The server runs inside an Android foreground service alongside the camera. Here's the basic structure:
class CameraService : Service() {
private var server: ApplicationEngine? = null
private fun startWebServer() {
server = embeddedServer(Netty, port = 8080) {
install(WebSockets)
install(ContentNegotiation) {
json()
}
routing {
static("/") {
resources("web")
defaultResource("web/index.html")
}
webSocket("/ws") {
handleWebSocketSession(this)
}
route("/api") {
cameraRoutes()
recordingRoutes()
streamingRoutes()
}
}
}.start(wait = false)
}
}
The wait = false parameter is critical on Android — you don't want to block the service thread.
Serving Static Files
The web control interface is a single-page app bundled as static resources inside the APK. I put the HTML, CSS, and JavaScript files in src/main/resources/web/ and serve them with Ktor's static file routing.
This means the entire remote control UI works offline — no internet required. Just connect to the phone's IP on your local network.
WebSocket for Real-Time Updates
The remote control needs to show real-time status: is the camera recording? What's the current resolution? How much storage is left? Polling an HTTP endpoint would work but adds latency and unnecessary requests.
WebSockets give us instant bidirectional communication:
private suspend fun handleWebSocketSession(session: WebSocketSession) {
// Send initial state
session.send(Frame.Text(currentState.toJson()))
// Add to active sessions
activeSessions.add(session)
try {
for (frame in session.incoming) {
when (frame) {
is Frame.Text -> {
val command = parseCommand(frame.readText())
executeCommand(command)
}
else -> {}
}
}
} finally {
activeSessions.remove(session)
}
}
// Broadcast state changes to all connected clients
private suspend fun broadcastState() {
val stateJson = currentState.toJson()
activeSessions.forEach { session ->
try {
session.send(Frame.Text(stateJson))
} catch (e: Exception) {
activeSessions.remove(session)
}
}
}
Whenever the camera state changes — recording starts, resolution changes, storage fills up — all connected browsers get an instant update.
REST API for Camera Control
The REST endpoints handle commands like start/stop recording, switch cameras, and change settings:
fun Route.cameraRoutes() {
post("/camera/start-recording") {
cameraController.startRecording()
call.respond(HttpStatusCode.OK, StatusResponse("recording"))
}
post("/camera/stop-recording") {
cameraController.stopRecording()
call.respond(HttpStatusCode.OK, StatusResponse("idle"))
}
post("/camera/switch") {
cameraController.switchCamera()
call.respond(HttpStatusCode.OK, StatusResponse("switched"))
}
get("/camera/status") {
call.respond(cameraController.getStatus())
}
}
Android-Specific Challenges
Port conflicts: Other apps might be using port 8080. The app tries the configured port first, then falls back to alternatives (8081, 8082, etc.).
WiFi discovery: Users need to know the phone's IP address. The app displays it in the notification and also supports mDNS/Bonjour so you can access it via a .local hostname.
Battery impact: Running a web server sounds expensive, but Ktor with Netty is event-driven. When no one's connected, it's essentially idle. The camera hardware draws far more power than the server.
Network permissions: Android requires ACCESS_WIFI_STATE and INTERNET permissions. The server only binds to the local network interface — it's not accessible from the internet unless the user explicitly sets up port forwarding.
Lifecycle management: The server must start and stop with the foreground service. If the service is killed by the OS (rare with a foreground service, but possible), the server goes down too. On restart, it rebinds to the same port.
Security Considerations
Running an HTTP server on a phone raises security questions:
- No authentication by default — anyone on the same WiFi can access it. This is a deliberate trade-off for ease of use. Adding optional PIN protection is on the roadmap.
- HTTP, not HTTPS — local network traffic isn't encrypted. For a camera control interface on a home WiFi network, this is acceptable. For public networks, I'd recommend not using the remote control.
- Input validation — all API inputs are validated and sanitized. The server only accepts known commands with expected parameter types.
The Frontend
The web UI is vanilla HTML/CSS/JavaScript — no React, no build tools, no npm. It needs to be small (bundled in the APK) and load instantly on any browser. The JavaScript connects via WebSocket on page load and updates the UI reactively as state changes come in.
Results
The embedded Ktor server adds roughly 2MB to the APK size and negligible battery drain when idle. Users can control their camera from any device with a browser — laptop, tablet, another phone — without installing anything. The WebSocket connection means the control interface feels responsive and live.
This architecture pattern — embedding a web server in a mobile app for local control — works well for any app that needs a secondary control surface. IoT controllers, media players, home automation — anywhere you want browser-based access without a cloud dependency.
Resources
Happy to answer questions about the implementation — drop a comment below.
Top comments (0)