DEV Community

Super Funicular
Super Funicular

Posted on

Embedding a Ktor Web Server Inside an Android App

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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())
    }
}
Enter fullscreen mode Exit fullscreen mode

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)