Back to blog
Transport16 min read

DLNA Cast: Building a UPnP Sender and Receiver From Scratch

How PlainApp implements DLNA/UPnP AV casting on both sides: scanning and controlling TVs over SOAP as a sender, and turning the phone itself into a UPnP MediaRenderer as a receiver — with SSDP discovery, DIDL-Lite metadata, Range-request media serving, GENA event callbacks, and a sender-IP allow/deny trust model, all in pure Kotlin Multiplatform.

DLNA (built on UPnP AV) is the protocol behind "Cast to TV" buttons on smart TVs. It runs entirely on the local network, with no cloud account and no pairing step. PlainApp implements both directions of it: it can push a local video, song, or photo to any DLNA-compatible smart TV, and it can turn the phone itself into a MediaRenderer so a TV remote app, VLC, or another PlainApp can cast to it.

This article covers the wire protocol (SSDP + SOAP + DIDL-Lite), the local HTTP server that streams media with the headers TVs actually require, the GENA event subscription for playback state, and the sender-IP trust model that keeps an unauthenticated 1990s-era protocol safe to run on a modern phone.

Table of Contents

High-Level Architecture

Diagram 1
1

DLNA/UPnP AV has no central server and no cloud component — everything happens over local-network UDP multicast (discovery) and HTTP (control + media). PlainApp implements two independent roles that happen to share the same commonMain protocol code:

RoleWhat it doesKey classes
Sender ("Cast to TV")Scans for renderers, tells one to fetch a URL, controls playbackDlnaDeviceScanner, DlnaTransportController, CastPlayer
Receiver ("Wireless Cast")Advertises itself as a MediaRenderer, accepts control from any UPnP controllerDlnaReceiverEngine, DlnaHttpRouter, DlnaSoapHandler, DlnaReceiverViewModel

Both roles reuse DlnaSoap (SOAP envelope builders) and DlnaDevice (the UPnP device model) from features/dlna/common/. The DLNA specification has no authentication of any kind — anyone on the LAN who knows a renderer's control URL can send it commands. This shapes almost every design decision described below, especially the receiver's trust model.

SSDP Discovery: Finding Devices Without a Server

Discovery uses SSDP (Simple Service Discovery Protocol), a thin layer over UDP multicast to 239.255.255.250:1900. There is no directory server — devices announce themselves and answer search queries directly.

Diagram 2
2

As a sender, DlnaDeviceScanner broadcasts an M-SEARCH datagram targeting urn:schemas-upnp-org:service:AVTransport:1 and collects unicast 200 OK replies, each carrying a LOCATION header pointing at the renderer's description.xml. The scanner de-duplicates by hostAddress — it doesn't parse the device XML itself; that's left to CastViewModel.searchAsync(), which fetches LOCATION, calls device.update(xml), and only surfaces devices where device.isAVTransport() returns true:

fun search(): Flow<DlnaDevice> = searchDlnaDevicesRaw().transform { ssdp ->
    if (devices.none { it.hostAddress == ssdp.hostAddress }) {
        val device = DlnaDevice(ssdp.hostAddress, ssdp.header)
        devices.add(device)
        emit(device)
    }
}

As a receiver, DlnaReceiverEngine.runSsdpLoop() sends three NOTIFY ssdp:alive datagrams on start (root device, MediaRenderer:1 device type, AVTransport:1 service type), re-announces every 30 seconds (CACHE-CONTROL: max-age=1800), and answers incoming M-SEARCH requests with unicast responses. On stop(), it sends ssdp:byebye immediately rather than waiting for the 30-minute cache to expire — so a TV remote app stops listing PlainApp the moment "Wireless Cast" is turned off.

Port fallback

The receiver's HTTP server tries port 7878 first, then 7879, then 7880, preferring whichever port worked last time:

private val CANDIDATE_PORTS = listOf(7878, 7879, 7880)
private fun openServerSocket(): DlnaServerSocket? {
    val candidates = lastPort
        ?.let { listOf(it) + CANDIDATE_PORTS.filter { p -> p != it } }
        ?: CANDIDATE_PORTS
    for (port in candidates) {
        val ss = createDlnaServerSocket(port)
        if (ss != null) return ss
    }
    return null
}

If all three ports are taken (rare, but possible with other DLNA apps running), startError is set and surfaced in the UI rather than failing silently.

AVTransport Control: The SOAP Protocol

Once a renderer is found, playback is controlled through UPnP AVTransport, a SOAP-over-HTTP service. Every action is a POST to the renderer's control URL with a SOAPAction header and an XML body wrapped in a SOAP envelope.

Diagram 3
3

DlnaTransportController builds each request with a shared helper:

private suspend fun executeAVTransportCommand(
    device: DlnaDevice,
    action: String,
    parameters: String = "<InstanceID>0</InstanceID>",
): String {
    val st = device.getAVTransportService()?.serviceType ?: return ""
    return executeSOAPRequest(device, action, "<u:$action xmlns:u=\"$st\">$parameters</u:$action>")
}

executeSOAPRequest sets SOAPAction: "<serviceType>#<action>" and posts DlnaSoap.requestEnvelope(soapBody) — the same envelope constants used by the receiver side to build responses, so the wire format only needs to be defined once in commonMain.

The receiver's DlnaHttpRouter.handleSoap() mirrors this on the other end: it reads the soapaction header, extracts the action name after the #, and dispatches on it —SetAVTransportURI, Play, Pause, Stop, Seek, GetTransportInfo, GetPositionInfo, GetMediaInfo, GetDeviceCapabilities. RenderingControl (volume) is stubbed with a static 100 — PlainApp doesn't expose device volume through UPnP.

DIDL-Lite Metadata and the Double-Escaping Quirk

SetAVTransportURI carries two parameters: CurrentURI (the media URL) and CurrentURIMetaData — a DIDL-Lite XML fragment describing the title, media class, and album art, embedded as an XML-escaped string inside the outer SOAP body:

private fun buildDidlLiteMetadata(mediaUrl: String, title: String, albumArtUri: String): String {
    val upnpClass = when {
        ext in setOf("mp3", "m4a", "flac", ...) -> "object.item.audioItem.musicTrack"
        ext in setOf("jpg", "jpeg", "png", ...) -> "object.item.imageItem"
        else -> "object.item.videoItem"
    }
    val didl = """<DIDL-Lite xmlns="..."><item id="0" parentID="-1" restricted="0">
        <dc:title>$escapedTitle</dc:title><upnp:class>$upnpClass</upnp:class>$albumArtTag</item></DIDL-Lite>"""
    return didl.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
}

The DIDL-Lite XML is escaped twice: once for the title text itself (so a song called Fire & Ice doesn't break the DIDL-Lite tags), and once for the entire DIDL-Lite document (so its own </> don't break the outer SOAP envelope it's embedded in as text). This is a well-known UPnP quirk, not a bug — CurrentURIMetaData is defined as string content, not as nested XML elements.

On the receiver side, DlnaSoapHandler reverses this: parseSoapAction un-escapes the SOAP body once to get the DIDL-Lite text, then extractTitleFromDidlMeta/extractMediaTypeFromDidlMeta/ extractAlbumArtUriFromDidlMeta each do a second pass of entity unescaping and tag extraction on that inner string:

fun extractMediaTypeFromDidlMeta(meta: String, fallbackUri: String = ""): DlnaMediaType {
    val cls = meta.substring(classStart + 12, classEnd).lowercase()
    return when {
        "audioitem" in cls || "musictrack" in cls -> DlnaMediaType.AUDIO
        "imageitem" in cls || "photo" in cls -> DlnaMediaType.IMAGE
        "videoitem" in cls -> DlnaMediaType.VIDEO
        else -> DlnaMediaType.UNKNOWN
    }
}

If <upnp:class> is missing (some senders omit it), cleanMediaTitle() falls back to the file extension of the URI itself — media type detection never hard-fails, it just degrades to UNKNOWN which routes to the video player as a safe default.

Serving Media to the TV: Range Requests & DLNA Headers

A SetAVTransportURI call only tells the renderer where to fetch the media from — the actual bytes are served by PlainApp's own local HTTP server, at /media/{id}.

Diagram 4
4

UrlHelper.getMediaHttpUrl(path) registers the real path (which might be a content:// URI, a remote URL, or a plain file path) under a short id and returns http://<device-ip>:<port>/media/<id>.<ext>. The route then branches on what kind of source it actually is:

when {
    path.isUrl() -> call.proxyUrl(path)                 // remote URL: stream upstream response
    isContentUri(path) -> call.respondStream { sink -> streamContentUri(path, sink) }
    path.isImageFast() -> call.respondFile(path)         // images: plain static serve
    else -> call.respondDlnaFile(path)                   // audio/video: DLNA-aware serving
}

respondDlnaFile is the interesting case — many smart TVs and DLNA renderers refuse to play a stream unless it looks like a proper DLNA media server response:

override suspend fun respondDlnaFile(path: String): Boolean {
    val file = java.io.File(path)
    if (!file.exists()) return false
    applicationCall.response.run {
        header("realTimeInfo.dlna.org", "DLNA.ORG_TLAG=*")
        header("contentFeatures.dlna.org", "")
        header("transferMode.dlna.org", "Streaming")
        header("Connection", "keep-alive")
        header("Server", "DLNADOC/1.50 UPnP/1.0 Plain/1.0 Android/${android.os.Build.VERSION.RELEASE}")
        status(HttpStatusCode.PartialContent) // some TV OS only accept 206
    }
    applicationCall.respond(LocalFileContent(file))
    return true
}

Note the status is always 206 Partial Content, not 200 OK — some TV firmware treats a plain 200 response as "not seekable" and refuses to play it, even for a full-file GET. This one status-code choice is the difference between "plays fine" and "TV shows a spinner forever" on several real devices.

The same route serves album art for cast audio items too: UrlHelper.getAlbumArtHttpUrl() maps a content://media/.../albumart/<id> URI into the identical /media/{id} path, so content:// streaming and DLNA file serving share one code path regardless of whether the "media" in question is the song or its cover image.

GENA Events: Playback State Callbacks

After starting playback, the sender subscribes to the renderer's AVTransport eventing service (GENA — General Event Notification Architecture) so it learns about state changes without polling:

suspend fun subscribeEvent(device: DlnaDevice, callbackUrl: String): String {
    val service = device.getAVTransportService() ?: return ""
    val response = createHttpClient().subscribe(baseUrl + eventSubURL) {
        headers { set("NT", "upnp:event"); set("TIMEOUT", "Second-3600"); set("CALLBACK", "<$callbackUrl>") }
    }
    return response.headers["SID"].orEmpty()
}

SUBSCRIBE/RENEW/UNSUBSCRIBE are custom HTTP methods (not in the standard verb set), handled via Ktor's generic HttpMethod("SUBSCRIBE") request builder. The renderer then NOTIFYs callbackUrl — PlainApp's own /callback/cast route — whenever transport state, position, or duration changes:

if (xml.contains("TransportState val=\"STOPPED\"") && !xml.contains("AVTransportURIMetaData")) {
    // advance to next playlist item
} else if (xml.contains("TransportState val=\"PLAYING\"")) {
    CastPlayer.isPlaying.value = true
}

The duplicate-callback guard

Some renderers send two NOTIFY callbacks in quick succession for the same STOPPED transition — the second one happens to carry AVTransportURIMetaData while the first doesn't. Advancing the playlist on both would skip a track every time playback naturally stops. The !xml.contains("AVTransportURIMetaData") check is a deliberate, narrow filter: only the first STOPPED notification (without metadata) triggers auto-advance. A startPositionUpdater() job also polls GetPositionInfo every second as a fallback, since PlainApp's own SUBSCRIBE acknowledgment doesn't push events back to other controllers — only the sender side consumes GENA callbacks from TVs.

Receiver Mode: Becoming a UPnP MediaRenderer

Flip the direction: any DLNA controller (a TV remote app, VLC, another PlainApp) can push media to the phone itself. DlnaReceiverEngine opens the same kind of HTTP + SSDP server described above, but as the MediaRenderer being controlled rather than the controller.

DlnaHttpRouter.route() serves description.xml (built by DlnaXmlTemplates.deviceDescription(), listing an AVTransport and a stub RenderingControl service) and dispatches SOAP actions to DlnaSoapHandler. A SetAVTransportURI call doesn't play anything immediately — it stores a PendingCastRequest and waits:

if (uri.isNotEmpty()) {
    DlnaRendererState.rawPendingCastRequest.value =
        PendingCastRequest(senderIp, senderName, uri, title, mediaType, albumArtUri)
    DlnaRendererState.pendingPlayQueued.value = false
}

A Play that arrives before the pending request is resolved doesn't start playback either — it sets pendingPlayQueued = true so the command replays automatically once the cast request is accepted, instead of being silently lost:

val hasPending = DlnaRendererState.rawPendingCastRequest.value != null ||
    DlnaRendererState.pendingCastRequest.value != null
if (hasPending) {
    DlnaRendererState.pendingPlayQueued.value = true
} else {
    DlnaRendererState.commandChannel.trySend(DlnaCommand.Play)
}

Accepted commands flow through a single Channel<DlnaCommand> that DlnaReceiverViewModel drains, decoupling the raw socket-handling coroutine from UI/player state — the HTTP handler never touches ExoPlayer directly. Playback then routes to one of three full-screen composables by DlnaMediaType: DlnaReceiverAudioPlayerContent (gradient background, album art, seek bar), an image viewer, or DlnaReceiverVideoPlayerContent (ExoPlayer).

Security: Sender Trust via Allow/Deny Lists

DLNA has no authentication by design — any device on the LAN can send a renderer a SetAVTransportURI. Turning a personal phone into an unauthenticated MediaRenderer would let anyone on the same Wi-Fi (a shared office network, a friend's house, a hostile guest network) push arbitrary media URLs to it. PlainApp closes this gap with a per-sender-IP trust list, gating every incoming cast request:

Diagram 5
5

DlnaRendererState.rawPendingCastRequest.filterNotNull().collect { pending ->
    val allowed = DlnaAllowedSendersPreference.getAsync()
    val denied = DlnaDeniedSendersPreference.getAsync()
    when {
        DlnaAllowedSendersPreference.containsIp(allowed, pending.senderIp) -> {
            // Auto-accept: send commands directly without showing a dialog
        }
        DlnaDeniedSendersPreference.containsIp(denied, pending.senderIp) -> {
            // Auto-reject: silently discard
        }
        else -> {
            // Unknown sender: promote to UI-visible state for the user to decide
            DlnaRendererState.pendingCastRequest.value = pending
        }
    }
}

An unknown sender's cast request surfaces a confirmation dialog with an optional "remember this choice" flag; choosing to remember writes the sender's IP to the allow or deny preference so future requests from the same address skip the dialog. This is enforced entirely in DlnaReceiverViewModel, above the wire protocol — the SOAP handler itself always returns 200 OK regardless of the trust decision (per the UPnP spec, the transport call succeeded; whether the media actually plays is a separate, local decision).

Platform Split: commonMain Orchestration, androidMain Sockets

Diagram 6
6

Following the same pattern as PlainApp's other network features, only the raw byte-level socket I/O is platform-specific. DlnaServerSocket and DlnaSsdpSocket are expect interfaces; Android's actual implementations wrap java.net.ServerSocket and java.net.MulticastSocket directly. Everything else — port selection, the SSDP alive/byebye cadence, HTTP routing, SOAP parsing, DIDL-Lite metadata, and all four DlnaCommand state transitions — lives in commonMain and is unit testable on the JVM without an Android device.

iOS gets sender-side SOAP control (it's a plain HTTP client, no sockets to implement), but the receiver is a deliberate no-op:

actual fun startDlnaRenderer() {}

iOS doesn't expose a way to run a background UDP multicast listener reliably enough to be a good MediaRenderer citizen, so "Wireless Cast" (receiving) is Android-only; "Cast to TV" (sending) works on both.

Pure-Kotlin Engineering Notes

A few implementation details exist specifically to avoid platform dependencies that would break Kotlin Multiplatform sharing:

  • UUID v4 generation. DlnaReceiverEngine.randomUuid() hand-rolls an RFC 4122 v4 UUID from Random.nextBytes(16) instead of java.util.UUID.randomUUID(), so the device identity generator is identical on every platform.
  • Percent-decoding. DlnaSoapHandler's private percentDecode() replaces java.net.URLDecoder.decode() for titles that arrive URL-encoded in a media URI.
  • Byte-level HTTP body reads. Content-Length is a byte count, not a character count. AndroidDlnaClientConnection.readHttpRequest() reads the body via readBodyBytes(bis, contentLength) against the raw BufferedInputStream, never a BufferedReader/CharArray — a title containing multi-byte UTF-8 (e.g. Chinese characters, 3 bytes each) would otherwise under-read the body and stall waiting for bytes that already arrived, silently breaking SetAVTransportURI for non-ASCII titles.
  • Base URL parsing without java.net.URL. DlnaDevice.getBaseUrl() extracts the scheme/host/port from a LOCATION header with plain string slicing (substringAfter("://"), substringBefore('/')) instead of constructing a java.net.URL.

Design Patterns Recap

PatternWhereWhy
Shared Protocol, Split I/ODlnaSoap/DlnaXmlTemplates in commonMain, sockets in androidMainWire format defined once, platform only supplies bytes in/out
Pending → Rule Check → PromoterawPendingCastRequestpendingCastRequestSeparates "a request arrived" from "a request needs a human decision"
Command Queue DecouplingChannel<DlnaCommand>HTTP handler coroutine never touches ExoPlayer/UI state directly
Queued-Command ReplaypendingPlayQueuedA Play that races ahead of SetAVTransportURI's approval isn't dropped
Deliberate Status CoderespondDlnaFile → always 206Matches what real TV firmware expects, not just spec-minimum 200
Narrow Duplicate Filter!xml.contains("AVTransportURIMetaData")Distinguishes the two STOPPED callbacks some renderers send, without a generic dedup mechanism
Immediate Byebyestop() sends ssdp:byebye before cancelAvoids a stale 30-minute SSDP cache entry after the user turns the feature off
Fail-Open on Unknown, Fail-Closed by Defaultallow/deny preference + dialogNeither auto-trusts nor auto-blocks a first-time sender — a human decides once

Further Reading

  • UPnP Device Architecture — the underlying SSDP/GENA/SOAP specification.
  • For the WebSocket-based low-latency screen mirroring feature (a different, non-DLNA casting path), see Screen Mirror.