Terug naar blog
Transport16 min read

DLNA Cast: Een UPnP-zender en -ontvanger vanaf nul bouwen

Hoe PlainApp DLNA/UPnP AV-casting aan beide kanten implementeert: als zender via SOAP tv's scannen en aansturen, en de telefoon zelf omtoveren tot een UPnP MediaRenderer als ontvanger — met SSDP-detectie, DIDL-Lite-metadata, media-server met Range-verzoeken, GENA-event-callbacks en een vertrouwensmodel met IP-blokkeer-/toestaanlijsten, allemaal in puur Kotlin Multiplatform.

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

Algehele architectuur

Diagram 1
1

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:

RolWat het doetBelangrijkste klassen
Zender ("Cast naar tv")Zoekt naar renderers, geeft opdracht een URL op te halen, bestuurt afspelenDlnaDeviceScanner, DlnaTransportController, CastPlayer
Ontvanger ("Draadloos casten")Adverteert zichzelf als MediaRenderer, accepteert besturing van elke UPnP-controllerDlnaReceiverEngine, 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.

Diagram 2
2

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.

Diagram 3
3

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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
}

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}.

Diagram 4
4

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:

Diagram 5
5

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

Diagram 6
6

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 met Random.nextBytes(16) in plaats van java.util.UUID.randomUUID(), zodat de apparaatidentiteitsgenerator op elk platform identiek is.
  • Percent-decoding. De private percentDecode() van DlnaSoapHandler vervangt java.net.URLDecoder.decode() voor titels die URL-gecodeerd aankomen in een media-URI.
  • Byte-level HTTP-body-lezing. Content-Length is een byte-telling, niet een tekentelling. AndroidDlnaClientConnection.readHttpRequest() leest de body via readBodyBytes(bis, contentLength) tegen de ruwe BufferedInputStream, nooit een BufferedReader/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, waardoor SetAVTransportURI stil zou breken voor niet-ASCII-titels.
  • Base-URL-parsing zonder java.net.URL. DlnaDevice.getBaseUrl() extraheert het schema/host/poort uit een LOCATION-header met gewone string-slicing (substringAfter("://"), substringBefore('/')) in plaats van een java.net.URL te construeren.

Overzicht ontwerppatronen

PatroonWaarWaarom
Gedeeld protocol, gesplitste I/ODlnaSoap/DlnaXmlTemplates in commonMain, sockets in androidMainDraadformaat eenmalig gedefinieerd, platform levert alleen bytes in/uit
In behandeling -> Regelcontrole -> PromoverenrawPendingCastRequest -> pendingCastRequestScheidt "een verzoek is aangekomen" van "een verzoek heeft een menselijke beslissing nodig"
Commando-queue-ontkoppelingChannel<DlnaCommand>HTTP-handlercoroutine raakt nooit ExoPlayer/UI-status rechtstreeks aan
In-wachtrij-commando-herhalingpendingPlayQueuedEen Play dat voorloopt op de goedkeuring van SetAVTransportURI wordt niet gedropt
Bewuste statuscoderespondDlnaFile -> altijd 206Komt 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 byebyestop() stuurt ssdp:byebye voor het annulerenVoorkomt een verouderde 30-minuten-SSDP-cache-entry nadat de gebruiker de functie uitschakelt
Fail-open bij onbekend, fail-closed als standaardtoestaan-/blokkeervoorkeur + dialoogVertrouwt 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.