DLNA (gebaseerd op UPnP AV) is het protocol achter de "Cast naar tv"-knoppen op
smart-tv's. Het werkt volledig op het lokale netwerk, zonder cloudaccount
en zonder koppelstap. PlainApp implementeert beide richtingen ervan: het
kan een lokale video, nummer of foto naar elke DLNA-compatibele smart-tv duwen,
en het kan de telefoon zelf omtoveren tot een MediaRenderer zodat een
tv-afstandsbedieningsapp, VLC of een andere PlainApp er naartoe kan casten.
Dit artikel behandelt het draadprotocol (SSDP + SOAP + DIDL-Lite), de lokale HTTP-server die media streamt met de headers die tv's daadwerkelijk nodig hebben, het GENA-event-abonnement voor afspeelstatus, en het zender-IP-vertrouwensmodel dat een niet-geauthenticeerd protocol uit de jaren '90 veilig laat werken op een moderne telefoon.
Inhoudsopgave
- Inhoudsopgave
- Algehele architectuur
- SSDP-detectie: apparaten vinden zonder server
- AVTransport-besturing: het SOAP-protocol
- DIDL-Lite-metadata en de dubbel-escaped-kwesie
- Media serveren aan de tv: Range-verzoeken en DLNA-headers
- GENA-events: afspeelstatus-callbacks
- Ontvangermodus: een UPnP MediaRenderer worden
- Beveiliging: zendervertrouwen via toestaan-/blokkeerlijsten
- Platformsplitsing: commonMain-orchestratie, androidMain-sockets
- Zuivere-Kotlin-technische notities
- Overzicht ontwerppatronen
- Verder lezen
Algehele architectuur
DLNA/UPnP AV heeft geen centrale server en geen cloudcomponent — alles gebeurt via UDP-multicast (detectie) en HTTP (besturing + media) op het lokale netwerk. PlainApp implementeert twee onafhankelijke rollen die toevallig dezelfde commonMain-protocolcode delen:
| Rol | Wat het doet | Belangrijkste klassen |
|---|---|---|
| Zender ("Cast naar tv") | Zoekt naar renderers, geeft opdracht een URL op te halen, bestuurt afspelen | DlnaDeviceScanner, DlnaTransportController, CastPlayer |
| Ontvanger ("Draadloos casten") | Adverteert zichzelf als MediaRenderer, accepteert besturing van elke UPnP-controller | DlnaReceiverEngine, DlnaHttpRouter, DlnaSoapHandler, DlnaReceiverViewModel |
Beide rollen hergebruiken DlnaSoap (SOAP-envelopbouwers) en DlnaDevice (het
UPnP-apparaatmodel) uit features/dlna/common/. De DLNA-specificatie
heeft geen authenticatie — iedereen op het LAN die de besturings-URL van een
renderer kent, kan er commando's naartoe sturen. Dit vormt bijna elk
ontwerpbesluit dat hieronder wordt beschreven, met name het vertrouwensmodel van de ontvanger.
SSDP-detectie: apparaten vinden zonder server
Detectie gebruikt SSDP (Simple Service Discovery Protocol), een dunne laag
over UDP-multicast naar 239.255.255.250:1900. Er is geen directoryserver —
apparaten kondigen zichzelf aan en beantwoorden zoekopdrachten rechtstreeks.
Als zender verzendt DlnaDeviceScanner een M-SEARCH-datagram
gericht op urn:schemas-upnp-org:service:AVTransport:1 en verzamelt
unicast 200 OK-antwoorden, elk met een LOCATION-header die naar
de description.xml van de renderer verwijst. De scanner de-dupliceert op
hostAddress — het parseert de apparaat-XML niet zelf; dat wordt overgelaten aan
CastViewModel.searchAsync(), die LOCATION ophaalt,
device.update(xml) aanroept en alleen apparaten toont waar
device.isAVTransport() true retourneert:
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)
}
}
Als ontvanger verzendt DlnaReceiverEngine.runSsdpLoop() bij het opstarten drie
NOTIFY ssdp:alive-datagrammen (rootapparaat, MediaRenderer:1-
apparaattype, AVTransport:1-servicetype), kondigt zich elke 30 seconden opnieuw aan
(CACHE-CONTROL: max-age=1800), en beantwoordt inkomende M-SEARCH-verzoeken
met unicast-antwoorden. Bij stop() verzendt het onmiddellijk ssdp:byebye
in plaats van te wachten tot de 30-minuten-cache verloopt — zodat een tv-
afstandsbedieningsapp PlainApp niet langer vermeldt zodra "Draadloos casten" wordt uitgezet.
Port-fallback
De HTTP-server van de ontvanger probeert eerst poort 7878, daarna 7879,
daarna 7880, waarbij de voorkeur uitgaat naar de poort die de vorige keer werkte:
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
}
Als alle drie de poorten bezet zijn (zeldzaam, maar mogelijk als er andere DLNA-apps
actief zijn), wordt startError gezet en getoond in de UI in plaats van
stil te falen.
AVTransport-besturing: het SOAP-protocol
Zodra een renderer is gevonden, wordt het afspelen aangestuurd via UPnP
AVTransport, een SOAP-over-HTTP-service. Elke actie is een POST naar de
besturings-URL van de renderer met een SOAPAction-header en een XML-body
gewikkeld in een SOAP-envelop.
DlnaTransportController bouwt elk verzoek met een gedeelde 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 zet SOAPAction: "<serviceType>#<action>" en post
DlnaSoap.requestEnvelope(soapBody) — dezelfde envelopconstanten die ook
door de ontvanger-kant worden gebruikt om antwoorden te bouwen, zodat het draadformaat
slechts eenmaal in commonMain hoeft te worden gedefinieerd.
DlnaHttpRouter.handleSoap() van de ontvanger spiegelt dit aan de andere
kant: het leest de soapaction-header, extraheert de actienaam na de
# en stuurt door op basis daarvan — SetAVTransportURI, Play, Pause, Stop,
Seek, GetTransportInfo, GetPositionInfo, GetMediaInfo,
GetDeviceCapabilities. RenderingControl (volume) is een stub die altijd
100 retourneert — PlainApp stelt geen apparaatvolume bloot via UPnP.
DIDL-Lite-metadata en de dubbel-escaped-kwesie
SetAVTransportURI heeft twee parameters: CurrentURI (de media-URL)
en CurrentURIMetaData — een DIDL-Lite-XML-fragment dat de
titel, mediaklasse en albumhoes beschrijft, ingebed als een XML-escaped string
binnen de buitenste 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("&", "&").replace("<", "<").replace(">", ">")
}
De DIDL-Lite-XML wordt tweemaal geescaped: eenmaal voor de titeltekst zelf
(zodat een nummer genaamd Fire & Ice de DIDL-Lite-tags niet breekt), en eenmaal
voor het hele DIDL-Lite-document (zodat zijn eigen </> de buitenste
SOAP-envelop waarin het als tekst is ingebed niet breken). Dit is een bekende
UPnP-eigenaardigheid, geen bug — CurrentURIMetaData is gedefinieerd als tekenreeksinhoud,
niet als geneste XML-elementen.
Aan de ontvangerkant keert DlnaSoapHandler dit om: parseSoapAction
unescaped de SOAP-body eenmaal om de DIDL-Lite-tekst te krijgen, waarna
extractTitleFromDidlMeta/extractMediaTypeFromDidlMeta/
extractAlbumArtUriFromDidlMeta elk een tweede ronde van entity-
unescaping en tag-extractie uitvoeren op die innerlijke tekenreeks:
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
}
}
Als <upnp:class> ontbreekt (sommige zenders laten het weg), valt
cleanMediaTitle() terug op de bestandsextensie van de URI zelf — media-type-
detectie faalt nooit hard, het degradeert alleen naar UNKNOWN wat naar
de videospeler routeert als een veilige standaardwaarde.
Media serveren aan de tv: Range-verzoeken en DLNA-headers
Een SetAVTransportURI-aanroep vertelt de renderer alleen waar hij de
media moet ophalen — de daadwerkelijke bytes worden geserveerd door PlainApps eigen lokale HTTP-
server, op /media/{id}.
UrlHelper.getMediaHttpUrl(path) registreert het echte pad (dat een
content://-URI, een externe URL of een gewoon bestandspad kan zijn) onder een kort id
en retourneert http://<apparaat-ip>:<poort>/media/<id>.<ext>. De route vertakt
dan op basis van wat voor bron het werkelijk is:
when {
path.isUrl() -> call.proxyUrl(path) // externe URL: stroomopwaarts antwoord streamen
isContentUri(path) -> call.respondStream { sink -> streamContentUri(path, sink) }
path.isImageFast() -> call.respondFile(path) // afbeeldingen: gewoon statisch serveren
else -> call.respondDlnaFile(path) // audio/video: DLNA-bewust serveren
}
respondDlnaFile is het interessante geval — veel smart-tv's en DLNA-
renderers weigeren een stream af te spelen tenzij het eruitziet als een correct
DLNA-mediaserverantwoord:
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) // sommige tv-OS accepteren alleen 206
}
applicationCall.respond(LocalFileContent(file))
return true
}
Let op: de status is altijd 206 Partial Content, niet 200 OK — sommige tv-
firmware behandelt een gewoon 200-antwoord als "niet seekable" en weigert het
af te spelen, zelfs voor een GET van een volledig bestand. Deze ene statuscodekeuze is het
verschil tussen "speelt prima" en "tv blijft voor altijd een spinner tonen" op
verschillende echte apparaten.
Dezelfde route serveert ook albumhoezen voor gecaste audio-items:
UrlHelper.getAlbumArtHttpUrl() wijst een content://media/.../albumart/<id>
URI toe aan hetzelfde /media/{id}-pad, zodat content://-streaming en
DLNA-bestandsserveren één codepad delen, ongeacht of de "media"
in kwestie het nummer of de hoesafbeelding is.
GENA-events: afspeelstatus-callbacks
Na het starten van het afspelen abonneert de zender zich op de AVTransport-eventing-service van de renderer (GENA — General Event Notification Architecture) zodat hij op de hoogte raakt van statuswijzigingen zonder te pollen:
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 zijn aangepaste HTTP-methoden (niet in de
standaardwerkwoordenset), afgehandeld via Ktors generieke HttpMethod("SUBSCRIBE")-
verzoekbouwer. De renderer stuurt vervolgens een NOTIFY naar
callbackUrl — PlainApps eigen /callback/cast-route — wanneer de
transportstatus, positie of duur verandert:
if (xml.contains("TransportState val=\"STOPPED\"") && !xml.contains("AVTransportURIMetaData")) {
// ga naar het volgende afspeellijstitem
} else if (xml.contains("TransportState val=\"PLAYING\"")) {
CastPlayer.isPlaying.value = true
}
De dubbele-callback-beveiliging
Sommige renderers sturen twee NOTIFY-callbacks kort achter elkaar voor dezelfde
STOPPED-overgang — de tweede draagt toevallig
AVTransportURIMetaData terwijl de eerste dat niet heeft. Als de afspeellijst bij
beide zou worden opgeschoven, zou er elke keer dat het afspelen natuurlijk stopt een nummer worden overgeslagen. De
!xml.contains("AVTransportURIMetaData")-controle is een bewust,
smal filter: alleen de eerste STOPPED-melding (zonder metadata)
activeert automatisch doorgaan. Een startPositionUpdater()-taak pollt ook
elke seconde GetPositionInfo als vangnet, aangezien PlainApps eigen
SUBSCRIBE-bevestiging geen events terugstuurt naar andere
controllers — alleen de zenderkant consumeert GENA-callbacks van tv's.
Ontvangermodus: een UPnP MediaRenderer worden
Draai de richting om: elke DLNA-controller (een tv-afstandsbedieningsapp, VLC, een andere
PlainApp) kan media naar de telefoon zelf duwen. DlnaReceiverEngine opent
dezelfde soort HTTP + SSDP-server als hierboven beschreven, maar nu als de
MediaRenderer die wordt aangestuurd in plaats van de aanstuurder.
DlnaHttpRouter.route() serveert description.xml (gebouwd door
DlnaXmlTemplates.deviceDescription(), met een AVTransport- en een
stub-RenderingControl-service) en stuurt SOAP-acties door naar
DlnaSoapHandler. Een SetAVTransportURI-aanroep speelt niets af
onmiddellijk — het slaat een PendingCastRequest op en wacht:
if (uri.isNotEmpty()) {
DlnaRendererState.rawPendingCastRequest.value =
PendingCastRequest(senderIp, senderName, uri, title, mediaType, albumArtUri)
DlnaRendererState.pendingPlayQueued.value = false
}
Een Play dat aankomt voordat het openstaande verzoek is afgehandeld, start
ook geen afspelen — het zet pendingPlayQueued = true zodat het commando
automatisch wordt herhaald zodra het cast-verzoek is geaccepteerd, in plaats van
stil te worden genegeerd:
val hasPending = DlnaRendererState.rawPendingCastRequest.value != null ||
DlnaRendererState.pendingCastRequest.value != null
if (hasPending) {
DlnaRendererState.pendingPlayQueued.value = true
} else {
DlnaRendererState.commandChannel.trySend(DlnaCommand.Play)
}
Geaccepteerde commando's stromen door een enkele Channel<DlnaCommand> die
DlnaReceiverViewModel afvoert, waarmee de ruwe socket-afhandelingscoroutine
wordt ontkoppeld van de UI-/spelerstatus — de HTTP-handler raakt nooit
ExoPlayer rechtstreeks aan. Het afspelen routeert vervolgens naar een van drie
full-screen-composables op basis van DlnaMediaType: DlnaReceiverAudioPlayerContent (gradiënt-
achtergrond, albumhoes, zoekbalk), een afbeeldingsviewer of
DlnaReceiverVideoPlayerContent (ExoPlayer).
Beveiliging: zendervertrouwen via toestaan-/blokkeerlijsten
DLNA heeft geen authenticatie — elk apparaat op het LAN kan een
renderer een SetAVTransportURI sturen. Het omtoveren van een persoonlijke telefoon tot een
niet-geauthenticeerde MediaRenderer zou iedereen op hetzelfde Wi-Fi-netwerk (een
gedeeld kantoornetwerk, het huis van een vriend, een vijandig gastnetwerk) in staat stellen
willekeurige media-URL's ernaartoe te duwen. PlainApp dicht dit gat met een
vertrouwenslijst per zender-IP, die elk inkomend cast-verzoek controleert:
DlnaRendererState.rawPendingCastRequest.filterNotNull().collect { pending ->
val allowed = DlnaAllowedSendersPreference.getAsync()
val denied = DlnaDeniedSendersPreference.getAsync()
when {
DlnaAllowedSendersPreference.containsIp(allowed, pending.senderIp) -> {
// Automatisch accepteren: commando's direct verzenden zonder dialoog te tonen
}
DlnaDeniedSendersPreference.containsIp(denied, pending.senderIp) -> {
// Automatisch weigeren: stil negeren
}
else -> {
// Onbekende zender: promoveren naar UI-zichtbare status voor gebruiker om te beslissen
DlnaRendererState.pendingCastRequest.value = pending
}
}
}
Een cast-verzoek van een onbekende zender toont een bevestigingsdialoog met een
optionele "onthoud deze keuze"-vlag; onthouden kiezen schrijft het
IP van de zender naar de toestaan- of blokkeervoorkeur, zodat toekomstige verzoeken van hetzelfde
adres de dialoog overslaan. Dit wordt volledig afgedwongen in
DlnaReceiverViewModel, boven het draadprotocol — de SOAP-handler
retourneert zelf altijd 200 OK ongeacht de vertrouwensbeslissing (volgens de
UPnP-specificatie is de transportaanroep geslaagd; of de media
daadwerkelijk wordt afgespeeld, is een aparte, lokale beslissing).
Platformsplitsing: commonMain-orchestratie, androidMain-sockets
Volgens hetzelfde patroon als de andere netwerkfuncties van PlainApp is alleen de
ruwe byte-level socket-I/O platformspecifiek. DlnaServerSocket en
DlnaSsdpSocket zijn expect-interfaces; Android's actual-
implementaties wrappen java.net.ServerSocket en java.net.MulticastSocket
rechtstreeks. Al het andere — poortselectie, de SSDP-alive/byebye-
cadans, HTTP-routing, SOAP-parsing, DIDL-Lite-metadata en alle vier
DlnaCommand-statusovergangen — leeft in commonMain en is unit-
testbaar op de JVM zonder een Android-apparaat.
iOS krijgt SOAP-besturing aan de zenderkant (het is een gewone HTTP-client, geen sockets om te implementeren), maar de ontvanger is een bewuste no-op:
actual fun startDlnaRenderer() {}
iOS biedt geen manier om een achtergrond-UDP-multicast-listener betrouwbaar
genoeg te laten draaien om een goede MediaRenderer-burger te zijn, dus "Draadloos casten"
(ontvangen) is alleen Android; "Cast naar tv" (verzenden) werkt op beide.
Zuivere-Kotlin-technische notities
Een paar implementatiedetails bestaan specifiek om platformafhankelijkheden te vermijden die Kotlin Multiplatform-deling zouden breken:
- UUID v4-generatie.
DlnaReceiverEngine.randomUuid()maakt handmatig een RFC 4122 v4-UUID metRandom.nextBytes(16)in plaats vanjava.util.UUID.randomUUID(), zodat de apparaatidentiteitsgenerator op elk platform identiek is. - Percent-decoding. De private
percentDecode()vanDlnaSoapHandlervervangtjava.net.URLDecoder.decode()voor titels die URL-gecodeerd aankomen in een media-URI. - Byte-level HTTP-body-lezing.
Content-Lengthis een byte-telling, niet een tekentelling.AndroidDlnaClientConnection.readHttpRequest()leest de body viareadBodyBytes(bis, contentLength)tegen de ruweBufferedInputStream, nooit eenBufferedReader/CharArray— een titel met multi-byte UTF-8 (bijv. Chinese karakters, 3 bytes elk) zou anders de body te kort lezen en blijven wachten op bytes die al zijn aangekomen, waardoorSetAVTransportURIstil zou breken voor niet-ASCII-titels. - Base-URL-parsing zonder
java.net.URL.DlnaDevice.getBaseUrl()extraheert het schema/host/poort uit eenLOCATION-header met gewone string-slicing (substringAfter("://"),substringBefore('/')) in plaats van eenjava.net.URLte construeren.
Overzicht ontwerppatronen
| Patroon | Waar | Waarom |
|---|---|---|
| Gedeeld protocol, gesplitste I/O | DlnaSoap/DlnaXmlTemplates in commonMain, sockets in androidMain | Draadformaat eenmalig gedefinieerd, platform levert alleen bytes in/uit |
| In behandeling -> Regelcontrole -> Promoveren | rawPendingCastRequest -> pendingCastRequest | Scheidt "een verzoek is aangekomen" van "een verzoek heeft een menselijke beslissing nodig" |
| Commando-queue-ontkoppeling | Channel<DlnaCommand> | HTTP-handlercoroutine raakt nooit ExoPlayer/UI-status rechtstreeks aan |
| In-wachtrij-commando-herhaling | pendingPlayQueued | Een Play dat voorloopt op de goedkeuring van SetAVTransportURI wordt niet gedropt |
| Bewuste statuscode | respondDlnaFile -> altijd 206 | Komt overeen met wat echte tv-firmware verwacht, niet alleen spec-minimum 200 |
| Smalle duplicaatfilter | !xml.contains("AVTransportURIMetaData") | Onderscheidt de twee STOPPED-callbacks die sommige renderers sturen, zonder een generiek dedup-mechanisme |
| Onmiddellijk byebye | stop() stuurt ssdp:byebye voor het annuleren | Voorkomt een verouderde 30-minuten-SSDP-cache-entry nadat de gebruiker de functie uitschakelt |
| Fail-open bij onbekend, fail-closed als standaard | toestaan-/blokkeervoorkeur + dialoog | Vertrouwt een eerste zender niet automatisch en blokkeert hem ook niet — een mens beslist eenmaal |
Verder lezen
- UPnP Device Architecture — de onderliggende SSDP/GENA/SOAP-specificatie.
- Voor de WebSocket-gebaseerde low-latency-schermspiegelfunctie (een ander, niet-DLNA-castingpad), zie Screen Mirror.